mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Merge remote-tracking branch 'public-gh/master' into pap-1167-mcp-server-package
* public-gh/master: (51 commits) test(cli): align env input fixtures with project scope fix(export): strip project env values from company packages fix(ui): address review follow-ups fix(runtime): handle empty dev runner responses fix(ui): remove runtime-only preflight hook dependency test(ui): wait for async issue search results refactor(ui): inline document diff rendering test(cli): keep import preview fixtures aligned with manifest shape test(cli): cover project env in import preview fixtures fix(ui): restore attachment delete state hook order Speed up issue search Narrow parent issue and time-ago columns in inbox grid Add optional Parent Issue column to inbox show/hide columns Move sub-issues inline and remove sub-issues tab Display image attachments as square-cropped gallery grid Offset scroll-to-bottom button when properties panel is open Polish board approval card styling Default sub-issues to parent workspace Relax sub-issue dialog banner layout Improve issue approval visibility ...
This commit is contained in:
commit
2329a33f32
92 changed files with 31524 additions and 806 deletions
|
|
@ -220,6 +220,7 @@ describe("renderCompanyImportPreview", () => {
|
|||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
env: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
|
|
@ -250,6 +251,7 @@ describe("renderCompanyImportPreview", () => {
|
|||
key: "OPENAI_API_KEY",
|
||||
description: null,
|
||||
agentSlug: "ceo",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
|
|
@ -265,6 +267,7 @@ describe("renderCompanyImportPreview", () => {
|
|||
key: "OPENAI_API_KEY",
|
||||
description: null,
|
||||
agentSlug: "ceo",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
|
|
@ -432,6 +435,7 @@ describe("import selection catalog", () => {
|
|||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
env: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -184,6 +184,11 @@ Invariant: at least one root `company` level goal per company.
|
|||
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
|
||||
- `lead_agent_id` uuid fk `agents.id` null
|
||||
- `target_date` date null
|
||||
- `env` jsonb null (same secret-aware env binding format used by agent config)
|
||||
|
||||
Invariant:
|
||||
|
||||
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
|
||||
|
||||
## 7.6 `issues` (core task entity)
|
||||
|
||||
|
|
|
|||
362
doc/plans/2026-04-06-smart-model-routing.md
Normal file
362
doc/plans/2026-04-06-smart-model-routing.md
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
# 2026-04-06 Smart Model Routing
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-06
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
- `doc/SPEC-implementation.md`
|
||||
- `doc/PRODUCT.md`
|
||||
- `doc/plans/2026-03-14-adapter-skill-sync-rollout.md`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines a V1 plan for "smart model routing" in Paperclip.
|
||||
|
||||
The goal is not to build a generic cross-provider router in the server. The goal is:
|
||||
|
||||
- let supported adapters use a cheaper model for lightweight heartbeat orchestration work
|
||||
- keep the main task execution on the adapter's normal primary model
|
||||
- preserve Paperclip's existing task, session, and audit invariants
|
||||
- report cost and model usage truthfully when more than one model participates in a single heartbeat
|
||||
|
||||
The motivating use case is a local coding adapter where a cheap model can handle the first fast pass:
|
||||
|
||||
- read the wake context
|
||||
- orient to the task and workspace
|
||||
- leave an immediate progress comment when appropriate
|
||||
- perform bounded lightweight triage
|
||||
|
||||
Then the primary model does the substantive work.
|
||||
|
||||
## 2. Hermes Findings
|
||||
|
||||
Hermes does have a real "smart model routing" feature, but it is narrower than the name suggests.
|
||||
|
||||
Observed behavior:
|
||||
|
||||
- `agent/smart_model_routing.py` implements a conservative classifier for "simple" turns
|
||||
- the cheap path only triggers for short, single-line, non-code, non-URL, non-tool-heavy messages
|
||||
- complexity is detected with hardcoded thresholds plus a keyword denylist like `debug`, `implement`, `test`, `plan`, `tool`, `docker`, and similar terms
|
||||
- if the cheap route cannot be resolved, Hermes silently falls back to the primary model
|
||||
|
||||
Important architectural detail:
|
||||
|
||||
- Hermes applies this routing before constructing the agent for that turn
|
||||
- the route is resolved in `cron/scheduler.py` and passed into agent creation as the active provider/model/runtime
|
||||
|
||||
More useful than the routing heuristic itself is Hermes' broader model-slot design:
|
||||
|
||||
- main conversational model
|
||||
- fallback model for failover
|
||||
- auxiliary model slots for side tasks like compression and classification
|
||||
|
||||
That separation is a better fit for Paperclip than copying Hermes' exact keyword heuristic.
|
||||
|
||||
## 3. Current Paperclip State
|
||||
|
||||
Paperclip already has the right execution shape for adapter-specific routing, but it currently assumes one model per heartbeat run.
|
||||
|
||||
Current implementation facts:
|
||||
|
||||
- `server/src/services/heartbeat.ts` builds rich run context, including `paperclipWake`, workspace metadata, and session handoff context
|
||||
- each adapter receives a single resolved `config` object and executes once
|
||||
- built-in local adapters read one `config.model` and pass it directly to the underlying CLI
|
||||
- UI config today exposes one main `model` field plus adapter-specific thinking-effort controls
|
||||
- cost accounting currently records one provider/model tuple per run via `AdapterExecutionResult`
|
||||
|
||||
What this means:
|
||||
|
||||
- there is no shared routing layer in the server today
|
||||
- model choice already lives at the adapter boundary, which is good
|
||||
- multi-model execution in a single heartbeat needs explicit contract work or cost reporting will become misleading
|
||||
|
||||
## 4. Product Decision
|
||||
|
||||
Paperclip should implement smart model routing as an adapter-local, opt-in execution pattern.
|
||||
|
||||
V1 decision:
|
||||
|
||||
1. Do not add a global server-side router that tries to understand every adapter.
|
||||
2. Do not copy Hermes' prompt-keyword classifier as Paperclip's default routing policy.
|
||||
3. Add an adapter-specific "cheap preflight" phase for supported adapters.
|
||||
4. Keep the primary model as the canonical work model.
|
||||
5. Persist only the primary session unless an adapter can prove that cross-model session resume is safe.
|
||||
|
||||
Rationale:
|
||||
|
||||
- Paperclip heartbeats are structured, issue-scoped, and already include wake metadata
|
||||
- routing by execution phase is more reliable than routing by free-text prompt complexity
|
||||
- session semantics differ by adapter, so resume behavior must stay adapter-owned
|
||||
|
||||
## 5. Proposed V1 Behavior
|
||||
|
||||
## 5.1 Config shape
|
||||
|
||||
Supported adapters should add an optional routing block to `adapterConfig`.
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```ts
|
||||
smartModelRouting?: {
|
||||
enabled: boolean;
|
||||
cheapModel: string;
|
||||
cheapThinkingEffort?: string;
|
||||
maxPreflightTurns?: number;
|
||||
allowInitialProgressComment?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- keep existing `model` as the primary model
|
||||
- `cheapModel` is adapter-specific, not global
|
||||
- adapters that cannot safely support this block simply ignore it
|
||||
|
||||
For adapters with provider-specific model fields later, the shape can expand to include provider/base-url overrides. V1 should start simple.
|
||||
|
||||
## 5.2 Routing policy
|
||||
|
||||
Supported adapters should run cheap preflight only when all are true:
|
||||
|
||||
- `smartModelRouting.enabled` is true
|
||||
- `cheapModel` is configured
|
||||
- the run is issue-scoped
|
||||
- the adapter is starting a fresh session, not resuming a persisted one
|
||||
- the run is expected to do real task work rather than just resume an existing thread
|
||||
|
||||
Supported adapters should skip cheap preflight when any are true:
|
||||
|
||||
- a persisted task session already exists
|
||||
- the adapter cannot safely isolate preflight from the primary session
|
||||
- the issue or wake type implies the task is already mid-flight and continuity matters more than first-response speed
|
||||
|
||||
This is intentionally phase-based, not text-heuristic-based.
|
||||
|
||||
## 5.3 Cheap preflight responsibilities
|
||||
|
||||
The cheap phase should be narrow and bounded.
|
||||
|
||||
Allowed responsibilities:
|
||||
|
||||
- ingest wake context and issue summary
|
||||
- inspect the workspace at a shallow level
|
||||
- leave a short "starting investigation" style comment when appropriate
|
||||
- collect a compact handoff summary for the primary phase
|
||||
|
||||
Not allowed in V1:
|
||||
|
||||
- long tool loops
|
||||
- risky file mutations
|
||||
- being the canonical persisted task session
|
||||
- deciding final completion without either explicit adapter support or a trivial success case
|
||||
|
||||
Implementation detail:
|
||||
|
||||
- the adapter should inject an explicit preflight prompt telling the model this is a bounded orchestration pass
|
||||
- preflight should use a very small turn budget, for example 1-2 turns
|
||||
|
||||
## 5.4 Primary execution responsibilities
|
||||
|
||||
After preflight, the adapter launches the normal primary execution using the existing prompt and primary model.
|
||||
|
||||
The primary phase should receive:
|
||||
|
||||
- the normal Paperclip prompt
|
||||
- any preflight-generated handoff summary
|
||||
- normal workspace and wake context
|
||||
|
||||
The primary phase remains the source of truth for:
|
||||
|
||||
- persisted session state
|
||||
- final task completion
|
||||
- most file changes
|
||||
- most cost
|
||||
|
||||
## 6. Required Contract Changes
|
||||
|
||||
The current `AdapterExecutionResult` is too narrow for truthful multi-model accounting.
|
||||
|
||||
Add an optional segmented execution report, for example:
|
||||
|
||||
```ts
|
||||
executionSegments?: Array<{
|
||||
phase: "cheap_preflight" | "primary";
|
||||
provider?: string | null;
|
||||
biller?: string | null;
|
||||
model?: string | null;
|
||||
billingType?: AdapterBillingType | null;
|
||||
usage?: UsageSummary;
|
||||
costUsd?: number | null;
|
||||
summary?: string | null;
|
||||
}>
|
||||
```
|
||||
|
||||
V1 server behavior:
|
||||
|
||||
- if `executionSegments` is absent, keep current single-result behavior unchanged
|
||||
- if present, write one `cost_events` row per segment that has cost or token usage
|
||||
- store the segment array in run usage/result metadata for later UI inspection
|
||||
- keep the existing top-level `provider` / `model` fields as a summary, preferably the primary phase when present
|
||||
|
||||
This avoids breaking existing adapters while giving routed adapters truthful reporting.
|
||||
|
||||
## 7. Adapter Rollout Plan
|
||||
|
||||
## 7.1 Phase 1: contract and server plumbing
|
||||
|
||||
Work:
|
||||
|
||||
1. Extend adapter result types with segmented execution metadata.
|
||||
2. Update heartbeat cost recording to emit multiple cost events when segments are present.
|
||||
3. Include segment summaries in run metadata for transcript/debug views.
|
||||
|
||||
Success criteria:
|
||||
|
||||
- existing adapters behave exactly as before
|
||||
- a routed adapter can report cheap plus primary usage without collapsing them into one fake model
|
||||
|
||||
## 7.2 Phase 2: `codex_local`
|
||||
|
||||
Why first:
|
||||
|
||||
- Codex already has rich prompt/handoff handling
|
||||
- the adapter already injects Paperclip skills and workspace metadata cleanly
|
||||
- the current implementation already distinguishes bootstrap, wake delta, and handoff prompt sections
|
||||
|
||||
Implementation work:
|
||||
|
||||
1. Add config support for `smartModelRouting`.
|
||||
2. Add a cheap-preflight prompt builder.
|
||||
3. Run cheap preflight only on fresh sessions.
|
||||
4. Pass a compact preflight handoff note into the primary prompt.
|
||||
5. Report segmented usage and model metadata.
|
||||
|
||||
Important guardrail:
|
||||
|
||||
- do not resume the cheap-model session as the primary session in V1
|
||||
|
||||
## 7.3 Phase 3: `claude_local`
|
||||
|
||||
Implementation work is similar, but the session model-switch risk is even less attractive.
|
||||
|
||||
Same rule:
|
||||
|
||||
- cheap preflight is ephemeral
|
||||
- primary Claude session remains canonical
|
||||
|
||||
## 7.4 Phase 4: other adapters
|
||||
|
||||
Candidates:
|
||||
|
||||
- `cursor`
|
||||
- `gemini_local`
|
||||
- `opencode_local`
|
||||
- external plugin adapters through `createServerAdapter()`
|
||||
|
||||
These should come later because each runtime has different session and model-switch semantics.
|
||||
|
||||
## 8. UI and Config Changes
|
||||
|
||||
For supported built-in adapters, the agent config UI should expose:
|
||||
|
||||
- `model` as the primary model
|
||||
- `smart model routing` toggle
|
||||
- `cheap model`
|
||||
- optional cheap thinking effort
|
||||
- optional `allow initial progress comment` toggle
|
||||
|
||||
The run detail UI should also show when routing occurred, for example:
|
||||
|
||||
- cheap preflight model
|
||||
- primary model
|
||||
- token/cost split
|
||||
|
||||
This matters because Paperclip's board UI is supposed to make cost and behavior legible.
|
||||
|
||||
## 9. Why Not Copy Hermes Exactly
|
||||
|
||||
Hermes' cheap-route heuristic is useful precedent, but Paperclip should not start there.
|
||||
|
||||
Reasons:
|
||||
|
||||
- Hermes is optimizing free-form conversational turns
|
||||
- Paperclip agents run structured, issue-scoped heartbeats with explicit task and workspace context
|
||||
- Paperclip already knows whether a run is fresh vs resumed, issue-scoped vs approval follow-up, and what workspace/session exists
|
||||
- those execution facts are stronger routing signals than prompt keyword matching
|
||||
|
||||
If Paperclip later wants a cheap-only completion path for trivial runs, that can be a second-stage feature built on observed run data, not the first implementation.
|
||||
|
||||
## 10. Risks
|
||||
|
||||
## 10.1 Duplicate or noisy comments
|
||||
|
||||
If the cheap phase posts an update and the primary phase posts another near-identical update, the issue thread gets worse.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep cheap comments optional
|
||||
- make the preflight prompt explicitly avoid repeating status if a useful comment was already posted
|
||||
|
||||
## 10.2 Misleading cost reporting
|
||||
|
||||
If we only record the primary model, the board loses visibility into the routing cost tradeoff.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- add segmented execution reporting before shipping adapter behavior
|
||||
|
||||
## 10.3 Session corruption
|
||||
|
||||
Cross-model session reuse may fail or degrade context quality.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- V1 does not persist or resume cheap preflight sessions
|
||||
|
||||
## 10.4 Cheap model overreach
|
||||
|
||||
A cheap model with full tools and permissions may do too much low-quality work.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- hard cap preflight turns
|
||||
- use an explicit orchestration-only prompt
|
||||
- start with supported adapters where we can test the behavior well
|
||||
|
||||
## 11. Verification Plan
|
||||
|
||||
Required tests:
|
||||
|
||||
- adapter unit tests for route eligibility
|
||||
- adapter unit tests for "fresh session -> cheap preflight + primary"
|
||||
- adapter unit tests for "resumed session -> primary only"
|
||||
- heartbeat tests for segmented cost-event creation
|
||||
- UI tests for config save/load of cheap-model fields
|
||||
|
||||
Manual checks:
|
||||
|
||||
- create a fresh issue for a routed Codex or Claude agent
|
||||
- verify the run metadata shows both phases
|
||||
- verify only the primary session is persisted
|
||||
- verify cost rows reflect both models
|
||||
- verify the issue thread does not get duplicate kickoff comments
|
||||
|
||||
## 12. Recommended Sequence
|
||||
|
||||
1. Add segmented execution reporting to the adapter/server contract.
|
||||
2. Implement `codex_local` cheap preflight.
|
||||
3. Validate cost visibility and transcript UX.
|
||||
4. Implement `claude_local` cheap preflight.
|
||||
5. Decide later whether any adapters need Hermes-style text heuristics in addition to phase-based routing.
|
||||
|
||||
## 13. Recommendation
|
||||
|
||||
Paperclip should ship smart model routing as:
|
||||
|
||||
- adapter-specific
|
||||
- opt-in
|
||||
- phase-based
|
||||
- session-safe
|
||||
- cost-truthful
|
||||
|
||||
The right V1 is not "choose the cheapest model for simple prompts." The right V1 is "use a cheap model for bounded orchestration work on fresh runs, then hand off to the primary model for the real task."
|
||||
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
|
||||
|
|
@ -176,4 +176,49 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"restores statements incrementally when backup comments precede the first breakpoint",
|
||||
async () => {
|
||||
const restoreConnectionString = await createTempDatabase();
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
const backupDir = createTempDir("paperclip-db-restore-manual-");
|
||||
const backupFile = path.join(backupDir, "manual.sql");
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
backupFile,
|
||||
[
|
||||
"-- Paperclip database backup",
|
||||
"-- Created: 2026-04-06T00:00:00.000Z",
|
||||
"",
|
||||
"BEGIN;",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
"CREATE TABLE public.restore_stream_test (id integer primary key, payload text not null);",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
"INSERT INTO public.restore_stream_test (id, payload)",
|
||||
"VALUES (1, 'hello');",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
"COMMIT;",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
backupFile,
|
||||
});
|
||||
|
||||
const rows = await restoreSql.unsafe<{ payload: string }[]>(`
|
||||
SELECT payload
|
||||
FROM public.restore_stream_test
|
||||
`);
|
||||
expect(rows).toEqual([{ payload: "hello" }]);
|
||||
} finally {
|
||||
await restoreSql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import postgres from "postgres";
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
|
|
@ -45,6 +45,11 @@ type TableDefinition = {
|
|||
tablename: string;
|
||||
};
|
||||
|
||||
type ExtensionDefinition = {
|
||||
extension_name: string;
|
||||
schema_name: string;
|
||||
};
|
||||
|
||||
const DRIZZLE_SCHEMA = "drizzle";
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
||||
|
|
@ -142,6 +147,42 @@ function tableKey(schemaName: string, tableName: string): string {
|
|||
return `${schemaName}.${tableName}`;
|
||||
}
|
||||
|
||||
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
|
||||
const stream = createReadStream(backupFile, { encoding: "utf8" });
|
||||
const reader = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
let statementLines: string[] = [];
|
||||
|
||||
const flushStatement = () => {
|
||||
const statement = statementLines.join("\n").trim();
|
||||
statementLines = [];
|
||||
return statement;
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const line of reader) {
|
||||
if (line === STATEMENT_BREAKPOINT) {
|
||||
const statement = flushStatement();
|
||||
if (statement.length > 0) {
|
||||
yield statement;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
statementLines.push(line);
|
||||
}
|
||||
|
||||
const trailingStatement = flushStatement();
|
||||
if (trailingStatement.length > 0) {
|
||||
yield trailingStatement;
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
|
||||
const stream = createWriteStream(filePath, { encoding: "utf8" });
|
||||
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
|
||||
|
|
@ -340,6 +381,25 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emit("");
|
||||
}
|
||||
|
||||
const extensions = await sql<ExtensionDefinition[]>`
|
||||
SELECT
|
||||
e.extname AS extension_name,
|
||||
n.nspname AS schema_name
|
||||
FROM pg_extension e
|
||||
JOIN pg_namespace n ON n.oid = e.extnamespace
|
||||
WHERE e.extname <> 'plpgsql'
|
||||
ORDER BY e.extname
|
||||
`;
|
||||
if (extensions.length > 0) {
|
||||
emit("-- Extensions");
|
||||
for (const extension of extensions) {
|
||||
emitStatement(
|
||||
`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(extension.extension_name)} WITH SCHEMA ${quoteIdentifier(extension.schema_name)};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequences");
|
||||
for (const seq of sequences) {
|
||||
|
|
@ -626,13 +686,7 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi
|
|||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
const contents = await readFile(opts.backupFile, "utf8");
|
||||
const statements = contents
|
||||
.split(STATEMENT_BREAKPOINT)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
for await (const statement of readRestoreStatements(opts.backupFile)) {
|
||||
await sql.unsafe(statement).execute();
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -401,4 +401,70 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays migration 0050 safely when projects.env already exists",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const stiffLuckmanHash = await migrationHash("0050_stiff_luckman.sql");
|
||||
|
||||
await sql.unsafe(
|
||||
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${stiffLuckmanHash}'`,
|
||||
);
|
||||
|
||||
const columns = await sql.unsafe<{ column_name: string }[]>(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'projects'
|
||||
AND column_name = 'env'
|
||||
`,
|
||||
);
|
||||
expect(columns).toHaveLength(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
const pendingState = await inspectMigrations(connectionString);
|
||||
expect(pendingState).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: ["0050_stiff_luckman.sql"],
|
||||
reason: "pending-migrations",
|
||||
});
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const finalState = await inspectMigrations(connectionString);
|
||||
expect(finalState.status).toBe("upToDate");
|
||||
|
||||
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>(
|
||||
`
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'projects'
|
||||
AND column_name = 'env'
|
||||
`,
|
||||
);
|
||||
expect(columns).toEqual([
|
||||
expect.objectContaining({
|
||||
column_name: "env",
|
||||
is_nullable: "YES",
|
||||
data_type: "jsonb",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
1
packages/db/src/migrations/0050_stiff_luckman.sql
Normal file
1
packages/db/src/migrations/0050_stiff_luckman.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "env" jsonb;
|
||||
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);
|
||||
12772
packages/db/src/migrations/meta/0050_snapshot.json
Normal file
12772
packages/db/src/migrations/meta/0050_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
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
|
|
@ -351,6 +351,20 @@
|
|||
"when": 1775349863293,
|
||||
"tag": "0049_flawless_abomination",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "7",
|
||||
"when": 1775487782768,
|
||||
"tag": "0050_stiff_luckman",
|
||||
"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.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),
|
||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
|
||||
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
|
||||
descriptionSearchIdx: index("issues_description_search_idx").using("gin", table.description.op("gin_trgm_ops")),
|
||||
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
||||
.on(table.companyId, table.originKind, table.originId)
|
||||
.where(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
|
||||
import type { AgentEnvConfig } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
|
@ -15,6 +16,7 @@ export const projects = pgTable(
|
|||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||
targetDate: date("target_date"),
|
||||
color: text("color"),
|
||||
env: jsonb("env").$type<AgentEnvConfig>(),
|
||||
pauseReason: text("pause_reason"),
|
||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||
|
|
|
|||
|
|
@ -200,7 +200,12 @@ export const PROJECT_COLORS = [
|
|||
"#3b82f6", // blue
|
||||
] as const;
|
||||
|
||||
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const;
|
||||
export const APPROVAL_TYPES = [
|
||||
"hire_agent",
|
||||
"approve_ceo_strategy",
|
||||
"budget_override_required",
|
||||
"request_board_approval",
|
||||
] as const;
|
||||
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
||||
|
||||
export const APPROVAL_STATUSES = [
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
|
||||
export interface CompanyPortabilityInclude {
|
||||
company: boolean;
|
||||
agents: boolean;
|
||||
|
|
@ -10,6 +13,7 @@ export interface CompanyPortabilityEnvInput {
|
|||
key: string;
|
||||
description: string | null;
|
||||
agentSlug: string | null;
|
||||
projectSlug: string | null;
|
||||
kind: "secret" | "plain";
|
||||
requirement: "required" | "optional";
|
||||
defaultValue: string | null;
|
||||
|
|
@ -52,13 +56,12 @@ export interface CompanyPortabilityProjectManifestEntry {
|
|||
targetDate: string | null;
|
||||
color: string | null;
|
||||
status: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
|
||||
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type {
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
|
|
@ -65,6 +66,7 @@ export interface Project {
|
|||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({
|
|||
key: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
agentSlug: z.string().min(1).nullable(),
|
||||
projectSlug: z.string().min(1).nullable(),
|
||||
kind: z.enum(["secret", "plain"]),
|
||||
requirement: z.enum(["required", "optional"]),
|
||||
defaultValue: z.string().nullable(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { PROJECT_STATUSES } from "../constants.js";
|
||||
import { envConfigSchema } from "./secret.js";
|
||||
|
||||
const executionWorkspaceStrategySchema = z
|
||||
.object({
|
||||
|
|
@ -102,6 +103,7 @@ const projectFields = {
|
|||
leadAgentId: z.string().uuid().optional().nullable(),
|
||||
targetDate: z.string().optional().nullable(),
|
||||
color: z.string().optional().nullable(),
|
||||
env: envConfigSchema.optional().nullable(),
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||
archivedAt: z.string().datetime().optional().nullable(),
|
||||
};
|
||||
|
|
|
|||
93
scripts/dev-runner-output.mjs
Normal file
93
scripts/dev-runner-output.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
function normalizeByteLimit(maxBytes) {
|
||||
return Math.max(1, Math.trunc(maxBytes));
|
||||
}
|
||||
|
||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const chunks = [];
|
||||
let bufferedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
return {
|
||||
append(chunk) {
|
||||
if (chunk === null || chunk === undefined) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
chunks.push(buffer);
|
||||
bufferedBytes += buffer.length;
|
||||
totalBytes += buffer.length;
|
||||
|
||||
while (bufferedBytes > limit && chunks.length > 0) {
|
||||
const overflow = bufferedBytes - limit;
|
||||
const head = chunks[0];
|
||||
if (head.length <= overflow) {
|
||||
chunks.shift();
|
||||
bufferedBytes -= head.length;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks[0] = head.subarray(overflow);
|
||||
bufferedBytes -= overflow;
|
||||
truncated = true;
|
||||
}
|
||||
},
|
||||
|
||||
finish() {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text: body,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseJsonResponseWithLimit(response, maxBytes = DEFAULT_JSON_RESPONSE_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return JSON.parse("");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > limit) {
|
||||
await reader.cancel("response too large");
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
102
scripts/dev-runner-output.ts
Normal file
102
scripts/dev-runner-output.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
export type CapturedOutput = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
function normalizeByteLimit(maxBytes: number) {
|
||||
return Math.max(1, Math.trunc(maxBytes));
|
||||
}
|
||||
|
||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const chunks: Buffer[] = [];
|
||||
let bufferedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
return {
|
||||
append(chunk: Buffer | string | null | undefined) {
|
||||
if (chunk === null || chunk === undefined) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
chunks.push(buffer);
|
||||
bufferedBytes += buffer.length;
|
||||
totalBytes += buffer.length;
|
||||
|
||||
while (bufferedBytes > limit && chunks.length > 0) {
|
||||
const overflow = bufferedBytes - limit;
|
||||
const head = chunks[0]!;
|
||||
if (head.length <= overflow) {
|
||||
chunks.shift();
|
||||
bufferedBytes -= head.length;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks[0] = head.subarray(overflow);
|
||||
bufferedBytes -= overflow;
|
||||
truncated = true;
|
||||
}
|
||||
},
|
||||
|
||||
finish(): CapturedOutput {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text: body,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
|
||||
truncated,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseJsonResponseWithLimit<T>(
|
||||
response: Response,
|
||||
maxBytes = DEFAULT_JSON_RESPONSE_BYTES,
|
||||
): Promise<T> {
|
||||
const limit = normalizeByteLimit(maxBytes);
|
||||
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response has no body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > limit) {
|
||||
await reader.cancel("response too large");
|
||||
throw new Error(`Response exceeds ${limit} bytes`);
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
|
|
@ -250,30 +251,33 @@ async function runPnpm(args, options = {}) {
|
|||
const spawned = spawn(pnpmBin, args, {
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: options.env ?? process.env,
|
||||
cwd: options.cwd,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
const stdoutBuffer = createCapturedOutputBuffer();
|
||||
const stderrBuffer = createCapturedOutputBuffer();
|
||||
|
||||
if (spawned.stdout) {
|
||||
spawned.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += String(chunk);
|
||||
stdoutBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
if (spawned.stderr) {
|
||||
spawned.stderr.on("data", (chunk) => {
|
||||
stderrBuffer += String(chunk);
|
||||
stderrBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
spawned.on("error", reject);
|
||||
spawned.on("exit", (code, signal) => {
|
||||
const stdout = stdoutBuffer.finish();
|
||||
const stderr = stderrBuffer.finish();
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -426,7 +430,7 @@ async function getDevHealthPayload() {
|
|||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await response.json();
|
||||
return await parseJsonResponseWithLimit(response);
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
|
|||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||
import {
|
||||
|
|
@ -315,27 +316,29 @@ async function runPnpm(args: string[], options: {
|
|||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
const stdoutBuffer = createCapturedOutputBuffer();
|
||||
const stderrBuffer = createCapturedOutputBuffer();
|
||||
|
||||
if (spawned.stdout) {
|
||||
spawned.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += String(chunk);
|
||||
stdoutBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
if (spawned.stderr) {
|
||||
spawned.stderr.on("data", (chunk) => {
|
||||
stderrBuffer += String(chunk);
|
||||
stderrBuffer.append(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
spawned.on("error", reject);
|
||||
spawned.on("exit", (code, signal) => {
|
||||
const stdout = stdoutBuffer.finish();
|
||||
const stderr = stderrBuffer.finish();
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -484,7 +487,7 @@ async function getDevHealthPayload() {
|
|||
if (!response.ok) {
|
||||
throw new Error(`Health request failed (${response.status})`);
|
||||
}
|
||||
return await response.json();
|
||||
return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response);
|
||||
}
|
||||
|
||||
async function waitForChildExit() {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/usr/bin/env -S node --import tsx
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { repoRoot } from "./dev-service-profile.ts";
|
||||
|
||||
type WorkspaceLinkMismatch = {
|
||||
workspaceDir: string;
|
||||
packageName: string;
|
||||
expectedPath: string;
|
||||
actualPath: string | null;
|
||||
|
|
@ -44,11 +45,11 @@ function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
|||
|
||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
||||
|
||||
function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
||||
const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json"));
|
||||
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
||||
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
||||
const dependencies = {
|
||||
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
};
|
||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
||||
|
||||
|
|
@ -58,11 +59,12 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
|||
const expectedPath = workspacePackagePaths.get(packageName);
|
||||
if (!expectedPath) continue;
|
||||
|
||||
const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/"));
|
||||
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
|
||||
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
||||
if (actualPath === path.resolve(expectedPath)) continue;
|
||||
|
||||
mismatches.push({
|
||||
workspaceDir,
|
||||
packageName,
|
||||
expectedPath: path.resolve(expectedPath),
|
||||
actualPath,
|
||||
|
|
@ -72,53 +74,32 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
|||
return mismatches;
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureServerWorkspaceLinksCurrent() {
|
||||
const mismatches = findServerWorkspaceLinkMismatches();
|
||||
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
||||
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (mismatches.length === 0) return;
|
||||
|
||||
console.log("[paperclip] detected stale workspace package links for server; relinking dependencies...");
|
||||
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
|
||||
for (const mismatch of mismatches) {
|
||||
console.log(
|
||||
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
await runCommand(
|
||||
pnpmBin,
|
||||
["install", "--force", "--config.confirmModulesPurge=false"],
|
||||
repoRoot,
|
||||
);
|
||||
for (const mismatch of mismatches) {
|
||||
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
||||
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
await fs.symlink(mismatch.expectedPath, linkPath);
|
||||
}
|
||||
|
||||
const remainingMismatches = findServerWorkspaceLinkMismatches();
|
||||
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||
if (remainingMismatches.length === 0) return;
|
||||
|
||||
throw new Error(
|
||||
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent();
|
||||
for (const workspaceDir of ["server", "ui"]) {
|
||||
await ensureWorkspaceLinksCurrent(workspaceDir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
|
|||
done < <(list_base_node_modules_paths)
|
||||
|
||||
if [[ "$needs_install" -eq 1 ]]; then
|
||||
backup_suffix=".paperclip-backup-$BASHPID"
|
||||
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
|
||||
moved_symlink_paths=()
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
|
|
@ -377,6 +377,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
|
|||
|
||||
restore_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
|
|
@ -388,6 +389,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
|
|||
|
||||
cleanup_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,24 @@ function createApp() {
|
|||
return app;
|
||||
}
|
||||
|
||||
function createAgentApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "api_key",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", approvalRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("approval routes idempotent retries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -107,4 +125,56 @@ describe("approval routes idempotent retries", () => {
|
|||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets agents create generic issue-linked board approval requests", async () => {
|
||||
mockApprovalService.create.mockResolvedValue({
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: { title: "Approve hosting spend" },
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-04-06T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await request(createAgentApp())
|
||||
.post("/api/companies/company-1/approvals")
|
||||
.send({
|
||||
type: "request_board_approval",
|
||||
issueIds: ["00000000-0000-0000-0000-000000000001"],
|
||||
payload: { title: "Approve hosting spend" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
decisionNote: null,
|
||||
}),
|
||||
);
|
||||
expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled();
|
||||
expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith(
|
||||
"approval-1",
|
||||
["00000000-0000-0000-0000-000000000001"],
|
||||
{ agentId: "agent-1", userId: null },
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "approval.created",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseAllowedTypes,
|
||||
matchesContentType,
|
||||
DEFAULT_ALLOWED_TYPES,
|
||||
INLINE_ATTACHMENT_TYPES,
|
||||
isInlineAttachmentContentType,
|
||||
matchesContentType,
|
||||
normalizeContentType,
|
||||
parseAllowedTypes,
|
||||
} from "../attachment-types.js";
|
||||
|
||||
describe("parseAllowedTypes", () => {
|
||||
|
|
@ -95,3 +98,28 @@ describe("matchesContentType", () => {
|
|||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeContentType", () => {
|
||||
it("lowercases and trims explicit types", () => {
|
||||
expect(normalizeContentType(" Application/Zip ")).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream when the type is missing", () => {
|
||||
expect(normalizeContentType(undefined)).toBe("application/octet-stream");
|
||||
expect(normalizeContentType("")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInlineAttachmentContentType", () => {
|
||||
it("allows the configured inline-safe types", () => {
|
||||
for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain"]) {
|
||||
expect(isInlineAttachmentContentType(contentType)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects potentially unsafe or binary download types", () => {
|
||||
expect(INLINE_ATTACHMENT_TYPES).not.toContain("text/html");
|
||||
expect(isInlineAttachmentContentType("text/html")).toBe(false);
|
||||
expect(isInlineAttachmentContentType("application/zip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1149,6 +1149,7 @@ describe("company portability", () => {
|
|||
key: "ANTHROPIC_API_KEY",
|
||||
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
|
||||
agentSlug: "claudecoder",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
|
|
@ -1158,6 +1159,7 @@ describe("company portability", () => {
|
|||
key: "GH_TOKEN",
|
||||
description: "Provide GH_TOKEN for agent claudecoder",
|
||||
agentSlug: "claudecoder",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
|
|
@ -1166,6 +1168,128 @@ describe("company portability", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("exports project env as portable inputs without concrete values", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: "Ship it",
|
||||
leadAgentId: "agent-1",
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "plain",
|
||||
value: "sk-project-secret",
|
||||
},
|
||||
DOCS_MODE: {
|
||||
type: "plain",
|
||||
value: "strict",
|
||||
},
|
||||
GITHUB_TOKEN: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("OPENAI_API_KEY:");
|
||||
expect(extension).toContain("DOCS_MODE:");
|
||||
expect(extension).toContain("GITHUB_TOKEN:");
|
||||
expect(extension).not.toContain("sk-project-secret");
|
||||
expect(extension).not.toContain('type: "secret_ref"');
|
||||
expect(extension).not.toContain("11111111-1111-1111-1111-111111111111");
|
||||
expect(extension).toContain('default: "strict"');
|
||||
expect(extension).toContain('kind: "secret"');
|
||||
expect(extension).toContain('kind: "plain"');
|
||||
});
|
||||
|
||||
it("reads project env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
projectSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Launch",
|
||||
urlKey: "launch",
|
||||
description: "Ship it",
|
||||
leadAgentId: "agent-1",
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: "planned",
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "plain",
|
||||
value: "sk-project-secret",
|
||||
},
|
||||
},
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const preview = await portability.previewImport({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: {
|
||||
company: false,
|
||||
agents: false,
|
||||
projects: true,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "new_company",
|
||||
newCompanyName: "Imported Paperclip",
|
||||
},
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(preview.errors).toEqual([]);
|
||||
expect(preview.envInputs).toContainEqual({
|
||||
key: "OPENAI_API_KEY",
|
||||
description: "Optional default for OPENAI_API_KEY on project launch",
|
||||
agentSlug: null,
|
||||
projectSlug: "launch",
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
|
|
|||
45
server/src/__tests__/dev-runner-output.test.ts
Normal file
45
server/src/__tests__/dev-runner-output.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "../../../scripts/dev-runner-output.mjs";
|
||||
|
||||
describe("createCapturedOutputBuffer", () => {
|
||||
it("keeps small output unchanged", () => {
|
||||
const capture = createCapturedOutputBuffer(32);
|
||||
capture.append("hello");
|
||||
capture.append(" world");
|
||||
|
||||
expect(capture.finish()).toEqual({
|
||||
text: "hello world",
|
||||
totalBytes: 11,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains only the bounded tail when output grows large", () => {
|
||||
const capture = createCapturedOutputBuffer(8);
|
||||
capture.append("abcd");
|
||||
capture.append(Buffer.from("efgh"));
|
||||
capture.append("ijkl");
|
||||
|
||||
const result = capture.finish();
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.totalBytes).toBe(12);
|
||||
expect(result.text).toContain("total 12 bytes");
|
||||
expect(result.text.endsWith("efghijkl")).toBe(true);
|
||||
});
|
||||
|
||||
it("parses bounded JSON responses", async () => {
|
||||
const response = new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(parseJsonResponseWithLimit<{ ok: boolean }>(response, 64)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects oversized JSON responses before parsing them", async () => {
|
||||
const response = new Response(JSON.stringify({ payload: "x".repeat(128) }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(parseJsonResponseWithLimit(response, 32)).rejects.toThrow("Response exceeds 32 bytes");
|
||||
});
|
||||
});
|
||||
|
|
@ -63,4 +63,14 @@ describe("dev server status helpers", () => {
|
|||
waitingForIdle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores oversized persisted status files", () => {
|
||||
const filePath = createTempStatusFile({
|
||||
dirty: true,
|
||||
changedPathsSample: ["x".repeat(70 * 1024)],
|
||||
pendingMigrations: [],
|
||||
});
|
||||
|
||||
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
65
server/src/__tests__/heartbeat-project-env.test.ts
Normal file
65
server/src/__tests__/heartbeat-project-env.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
|
||||
|
||||
describe("resolveExecutionRunAdapterConfig", () => {
|
||||
it("overlays project env on top of agent env and unions secret keys", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||
config: {
|
||||
env: {
|
||||
SHARED_KEY: "agent",
|
||||
AGENT_ONLY: "agent-only",
|
||||
},
|
||||
other: "value",
|
||||
},
|
||||
secretKeys: new Set(["AGENT_SECRET"]),
|
||||
});
|
||||
const resolveEnvBindings = vi.fn().mockResolvedValue({
|
||||
env: {
|
||||
SHARED_KEY: "project",
|
||||
PROJECT_ONLY: "project-only",
|
||||
},
|
||||
secretKeys: new Set(["PROJECT_SECRET"]),
|
||||
});
|
||||
|
||||
const result = await resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
executionRunConfig: { env: { SHARED_KEY: "agent" } },
|
||||
projectEnv: { SHARED_KEY: "project" },
|
||||
secretsSvc: {
|
||||
resolveAdapterConfigForRuntime,
|
||||
resolveEnvBindings,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig).toMatchObject({
|
||||
other: "value",
|
||||
env: {
|
||||
SHARED_KEY: "project",
|
||||
AGENT_ONLY: "agent-only",
|
||||
PROJECT_ONLY: "project-only",
|
||||
},
|
||||
});
|
||||
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
|
||||
});
|
||||
|
||||
it("skips project env resolution when the project has no bindings", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||
config: { env: { AGENT_ONLY: "agent-only" } },
|
||||
secretKeys: new Set<string>(),
|
||||
});
|
||||
const resolveEnvBindings = vi.fn();
|
||||
|
||||
const result = await resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
executionRunConfig: { env: { AGENT_ONLY: "agent-only" } },
|
||||
projectEnv: null,
|
||||
secretsSvc: {
|
||||
resolveAdapterConfigForRuntime,
|
||||
resolveEnvBindings,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
|
||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
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]);
|
||||
});
|
||||
|
||||
it("applies result limits to issue search", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
const exactIdentifierId = randomUUID();
|
||||
const titleMatchId = randomUUID();
|
||||
const descriptionMatchId = randomUUID();
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: exactIdentifierId,
|
||||
companyId,
|
||||
issueNumber: 42,
|
||||
identifier: "PAP-42",
|
||||
title: "Completely unrelated",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: titleMatchId,
|
||||
companyId,
|
||||
title: "Search ranking issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: descriptionMatchId,
|
||||
companyId,
|
||||
title: "Another item",
|
||||
description: "Contains the search keyword",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
q: "search",
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
||||
});
|
||||
|
||||
it("accepts issue identifiers through getById", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import express from "express";
|
|||
import request from "supertest";
|
||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||
|
||||
const unknownHostname = "blocked-host.invalid";
|
||||
|
||||
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||
const app = express();
|
||||
app.use(
|
||||
|
|
@ -42,15 +44,15 @@ describe("privateHostnameGuard", () => {
|
|||
|
||||
it("blocks unknown hostnames with remediation command", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
||||
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.error).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
||||
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||
});
|
||||
|
||||
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
|
||||
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
||||
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const mockGoalService = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
||||
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -46,6 +49,7 @@ vi.mock("../services/index.js", () => ({
|
|||
goalService: () => mockGoalService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
|
|
@ -77,6 +81,7 @@ describe("project and goal telemetry routes", () => {
|
|||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
|
|
|
|||
188
server/src/__tests__/project-routes-env.test.ts
Normal file
188
server/src/__tests__/project-routes-env.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
listWorkspaces: vi.fn(),
|
||||
updateWorkspace: vi.fn(),
|
||||
removeWorkspace: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackProjectCreated: mockTrackProjectCreated,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", projectRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function buildProject(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
urlKey: "project-1",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Project",
|
||||
description: null,
|
||||
status: "backlog",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/project",
|
||||
effectiveLocalFolder: "/tmp/project",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("project env routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||
mockProjectService.listWorkspaces.mockResolvedValue([]);
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
});
|
||||
|
||||
it("normalizes env bindings on create and logs only env keys", async () => {
|
||||
const normalizedEnv = {
|
||||
API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-4111-8111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
};
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv);
|
||||
mockProjectService.create.mockResolvedValue(buildProject({ env: normalizedEnv }));
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({
|
||||
name: "Project",
|
||||
env: normalizedEnv,
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
normalizedEnv,
|
||||
expect.objectContaining({ fieldPath: "env" }),
|
||||
);
|
||||
expect(mockProjectService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ env: normalizedEnv }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
envKeys: ["API_KEY"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes env bindings on update and avoids logging raw values", async () => {
|
||||
const normalizedEnv = {
|
||||
PLAIN_KEY: { type: "plain", value: "top-secret" },
|
||||
};
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv);
|
||||
mockProjectService.getById.mockResolvedValue(buildProject());
|
||||
mockProjectService.update.mockResolvedValue(buildProject({ env: normalizedEnv }));
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.patch("/api/projects/project-1")
|
||||
.send({
|
||||
env: normalizedEnv,
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockProjectService.update).toHaveBeenCalledWith(
|
||||
"project-1",
|
||||
expect.objectContaining({ env: normalizedEnv }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
details: {
|
||||
changedKeys: ["env"],
|
||||
envKeys: ["PLAIN_KEY"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensureServerWorkspaceLinksCurrent,
|
||||
ensureRuntimeServicesForRun,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
|
|
@ -187,6 +188,75 @@ describe("sanitizeRuntimeServiceBaseEnv", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||
it("relinks stale server workspace dependencies inside the current repo root", async () => {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-"));
|
||||
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-stale-"));
|
||||
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
|
||||
const expectedPackageDir = path.join(repoRoot, "packages", "db");
|
||||
const stalePackageDir = path.join(staleRoot, "db");
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@paperclipai/server",
|
||||
dependencies: {
|
||||
"@paperclipai/db": "workspace:*",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(expectedPackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stalePackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(expectedPackageDir));
|
||||
});
|
||||
|
||||
it("skips relinking when server workspace dependencies already point at the repo", async () => {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-current-"));
|
||||
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
|
||||
const expectedPackageDir = path.join(repoRoot, "packages", "db");
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@paperclipai/server",
|
||||
dependencies: {
|
||||
"@paperclipai/db": "workspace:*",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(expectedPackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db"));
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
|
@ -413,6 +483,96 @@ describe("realizeExecutionWorkspace", () => {
|
|||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||
});
|
||||
|
||||
it("uses the latest repo-managed provision script when reusing an existing worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "provision.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'v1\\n' > .paperclip-provision-version",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add initial provision script"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-449",
|
||||
title: "Reuse latest provision script",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v1\n");
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "provision.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'v2\\n' > .paperclip-provision-version",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Update provision script"]);
|
||||
|
||||
await expect(fs.readFile(path.join(initial.cwd, "scripts", "provision.sh"), "utf8")).resolves.toContain("v1");
|
||||
|
||||
const reused = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-449",
|
||||
title: "Reuse latest provision script",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n");
|
||||
});
|
||||
|
||||
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const previousCwd = process.cwd();
|
||||
|
|
@ -663,9 +823,82 @@ describe("realizeExecutionWorkspace", () => {
|
|||
await fs.realpath(path.join(repoRoot, "packages", "shared")),
|
||||
);
|
||||
},
|
||||
15_000,
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("provisions successfully when install is needed but there are no symlinked node_modules to move", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "workspace-root",
|
||||
private: true,
|
||||
packageManager: "pnpm@9.15.4",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "pnpm-lock.yaml"),
|
||||
[
|
||||
"lockfileVersion: '9.0'",
|
||||
"",
|
||||
"settings:",
|
||||
" autoInstallPeers: true",
|
||||
" excludeLinksFromLockfile: false",
|
||||
"",
|
||||
"importers:",
|
||||
" .: {}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755);
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, "node_modules"), { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, "node_modules", ".keep"), "", "utf8");
|
||||
|
||||
await runGit(repoRoot, ["add", "package.json", "pnpm-lock.yaml", "scripts/provision-worktree.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add minimal provision fixture"]);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-552",
|
||||
title: "Install without moved symlinks",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip", "config.json"), "utf8")).resolves.toContain(
|
||||
"\"database\"",
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
|
@ -724,6 +957,57 @@ describe("realizeExecutionWorkspace", () => {
|
|||
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
|
||||
});
|
||||
|
||||
it("truncates oversized provision command output before storing it in memory", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "noisy.js"),
|
||||
'process.stdout.write("x".repeat(400000));\n',
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/noisy.js"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add noisy provision script"]);
|
||||
|
||||
await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "node ./scripts/noisy.js",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1142",
|
||||
title: "Limit noisy provision output",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
const provisionOperation = operations.find((operation) => operation.phase === "workspace_provision");
|
||||
expect(provisionOperation?.result.metadata).toMatchObject({
|
||||
stdoutTruncated: true,
|
||||
stderrTruncated: false,
|
||||
});
|
||||
expect(provisionOperation?.result.stdout).toContain("[output truncated to last");
|
||||
expect(provisionOperation?.result.stdout?.length ?? 0).toBeLessThan(300000);
|
||||
});
|
||||
|
||||
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-450-recreate-missing-worktree";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* Shared attachment content-type configuration.
|
||||
*
|
||||
* By default only image types are allowed. Set the
|
||||
* By default a curated set of image/document/text types are allowed. Set the
|
||||
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||
* allowed set.
|
||||
* allowed set for routes that use this allowlist.
|
||||
*
|
||||
* Examples:
|
||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
||||
|
|
@ -29,6 +29,17 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
|||
"text/html",
|
||||
];
|
||||
|
||||
export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream";
|
||||
export const SVG_CONTENT_TYPE = "image/svg+xml";
|
||||
export const INLINE_ATTACHMENT_TYPES: readonly string[] = [
|
||||
"image/*",
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
"text/csv",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a comma-separated list of MIME type patterns into a normalised array.
|
||||
* Returns the default image-only list when the input is empty or undefined.
|
||||
|
|
@ -59,6 +70,15 @@ export function matchesContentType(contentType: string, allowedPatterns: string[
|
|||
});
|
||||
}
|
||||
|
||||
export function normalizeContentType(contentType: string | null | undefined): string {
|
||||
const normalized = (contentType ?? "").trim().toLowerCase();
|
||||
return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE;
|
||||
}
|
||||
|
||||
export function isInlineAttachmentContentType(contentType: string): boolean {
|
||||
return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]);
|
||||
}
|
||||
|
||||
// ---------- Module-level singletons read once at startup ----------
|
||||
|
||||
const allowedPatterns: string[] = parseAllowedTypes(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
|
||||
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
|
||||
|
||||
export type PersistedDevServerStatus = {
|
||||
dirty: boolean;
|
||||
|
|
@ -44,6 +46,9 @@ export function readPersistedDevServerStatus(
|
|||
if (!filePath || !existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
|
||||
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@ import { logger } from "../middleware/logger.js";
|
|||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import {
|
||||
isInlineAttachmentContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeContentType,
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
|
|
@ -341,6 +346,9 @@ export function issueRoutes(
|
|||
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: unreadForUserFilterRaw;
|
||||
const rawLimit = req.query.limit as string | undefined;
|
||||
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : null;
|
||||
const limit = parsedLimit ?? undefined;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
|
|
@ -358,6 +366,10 @@ export function issueRoutes(
|
|||
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
|
||||
res.status(400).json({ error: "limit must be a positive integer" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
|
|
@ -376,6 +388,7 @@ export function issueRoutes(
|
|||
includeRoutineExecutions:
|
||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||
q: req.query.q as string | undefined,
|
||||
limit,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
|
@ -2108,11 +2121,7 @@ export function issueRoutes(
|
|||
res.status(400).json({ error: "Missing file field 'file'" });
|
||||
return;
|
||||
}
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
const contentType = normalizeContentType(file.mimetype);
|
||||
if (file.buffer.length <= 0) {
|
||||
res.status(422).json({ error: "Attachment is empty" });
|
||||
return;
|
||||
|
|
@ -2176,11 +2185,17 @@ export function issueRoutes(
|
|||
assertCompanyAccess(req, attachment.companyId);
|
||||
|
||||
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
||||
res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
|
||||
const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
|
||||
res.setHeader("Content-Type", responseContentType);
|
||||
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (responseContentType === SVG_CONTENT_TYPE) {
|
||||
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
}
|
||||
const filename = attachment.originalFilename ?? "attachment";
|
||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment";
|
||||
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
|
||||
object.stream.on("error", (err) => {
|
||||
next(err);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "@paperclipai/shared";
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
|
||||
|
|
@ -18,7 +18,9 @@ import { getTelemetryClient } from "../telemetry.js";
|
|||
export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = projectService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
|
||||
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
|
|
@ -82,6 +84,13 @@ export function projectRoutes(db: Db) {
|
|||
};
|
||||
|
||||
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
||||
if (projectData.env !== undefined) {
|
||||
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
|
||||
companyId,
|
||||
projectData.env,
|
||||
{ strictMode: strictSecretsMode, fieldPath: "env" },
|
||||
);
|
||||
}
|
||||
const project = await svc.create(companyId, projectData);
|
||||
let createdWorkspaceId: string | null = null;
|
||||
if (workspace) {
|
||||
|
|
@ -107,6 +116,7 @@ export function projectRoutes(db: Db) {
|
|||
details: {
|
||||
name: project.name,
|
||||
workspaceId: createdWorkspaceId,
|
||||
envKeys: project.env ? Object.keys(project.env).sort() : [],
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
|
|
@ -128,6 +138,12 @@ export function projectRoutes(db: Db) {
|
|||
if (typeof body.archivedAt === "string") {
|
||||
body.archivedAt = new Date(body.archivedAt);
|
||||
}
|
||||
if (body.env !== undefined) {
|
||||
body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, {
|
||||
strictMode: strictSecretsMode,
|
||||
fieldPath: "env",
|
||||
});
|
||||
}
|
||||
const project = await svc.update(id, body);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
|
|
@ -143,7 +159,13 @@ export function projectRoutes(db: Db) {
|
|||
action: "project.updated",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: req.body,
|
||||
details: {
|
||||
changedKeys: Object.keys(req.body).sort(),
|
||||
envKeys:
|
||||
body.env && typeof body.env === "object" && !Array.isArray(body.env)
|
||||
? Object.keys(body.env as Record<string, unknown>).sort()
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(project);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
CompanyPortabilitySidebarOrder,
|
||||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanySkill,
|
||||
AgentEnvConfig,
|
||||
RoutineVariable,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
ROUTINE_TRIGGER_KINDS,
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
deriveProjectUrlKey,
|
||||
envConfigSchema,
|
||||
normalizeAgentUrlKey,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
|
|
@ -387,6 +389,88 @@ function isSensitiveEnvKey(key: string) {
|
|||
);
|
||||
}
|
||||
|
||||
function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
|
||||
const parsed = envConfigSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function extractPortableScopedEnvInputs(
|
||||
scope: {
|
||||
label: string;
|
||||
warningPrefix: string;
|
||||
agentSlug: string | null;
|
||||
projectSlug: string | null;
|
||||
},
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
if (!isPlainRecord(envValue)) return [];
|
||||
const env = envValue as Record<string, unknown>;
|
||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
||||
|
||||
for (const [key, binding] of Object.entries(env)) {
|
||||
if (key.toUpperCase() === "PATH") {
|
||||
warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Provide ${key} for ${scope.label}`,
|
||||
agentSlug: scope.agentSlug,
|
||||
projectSlug: scope.projectSlug,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
||||
const defaultValue = asString(binding.value);
|
||||
const isSensitive = isSensitiveEnvKey(key);
|
||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
||||
? "system_dependent"
|
||||
: "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on ${scope.label}`,
|
||||
agentSlug: scope.agentSlug,
|
||||
projectSlug: scope.projectSlug,
|
||||
kind: isSensitive ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
||||
portability,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof binding === "string") {
|
||||
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on ${scope.label}`,
|
||||
agentSlug: scope.agentSlug,
|
||||
projectSlug: scope.projectSlug,
|
||||
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: isSensitiveEnvKey(key) ? "" : binding,
|
||||
portability,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
type ResolvedSource = {
|
||||
manifest: CompanyPortabilityManifest;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
|
|
@ -419,6 +503,7 @@ type ProjectLike = {
|
|||
targetDate: string | null;
|
||||
color: string | null;
|
||||
status: string;
|
||||
env: Record<string, unknown> | null;
|
||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||
workspaces?: Array<{
|
||||
id: string;
|
||||
|
|
@ -1528,68 +1613,33 @@ function extractPortableEnvInputs(
|
|||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
if (!isPlainRecord(envValue)) return [];
|
||||
const env = envValue as Record<string, unknown>;
|
||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
||||
return extractPortableScopedEnvInputs(
|
||||
{
|
||||
label: `agent ${agentSlug}`,
|
||||
warningPrefix: `Agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
projectSlug: null,
|
||||
},
|
||||
envValue,
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [key, binding] of Object.entries(env)) {
|
||||
if (key.toUpperCase() === "PATH") {
|
||||
warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Provide ${key} for agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: "secret",
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
||||
const defaultValue = asString(binding.value);
|
||||
const isSensitive = isSensitiveEnvKey(key);
|
||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
||||
? "system_dependent"
|
||||
: "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: isSensitive ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
||||
portability,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof binding === "string") {
|
||||
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: binding,
|
||||
portability,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
function extractPortableProjectEnvInputs(
|
||||
projectSlug: string,
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
return extractPortableScopedEnvInputs(
|
||||
{
|
||||
label: `project ${projectSlug}`,
|
||||
warningPrefix: `Project ${projectSlug}`,
|
||||
agentSlug: null,
|
||||
projectSlug,
|
||||
},
|
||||
envValue,
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
|
||||
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
|
|
@ -2175,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
|||
const seen = new Set<string>();
|
||||
const out: CompanyPortabilityManifest["envInputs"] = [];
|
||||
for (const value of values) {
|
||||
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
|
||||
const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
|
|
@ -2232,6 +2282,31 @@ function readAgentEnvInputs(
|
|||
key,
|
||||
description: asString(record.description) ?? null,
|
||||
agentSlug,
|
||||
projectSlug: null,
|
||||
kind: record.kind === "plain" ? "plain" : "secret",
|
||||
requirement: record.requirement === "required" ? "required" : "optional",
|
||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function readProjectEnvInputs(
|
||||
extension: Record<string, unknown>,
|
||||
projectSlug: string,
|
||||
): CompanyPortabilityManifest["envInputs"] {
|
||||
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
|
||||
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
|
||||
if (!env) return [];
|
||||
|
||||
return Object.entries(env).flatMap(([key, value]) => {
|
||||
if (!isPlainRecord(value)) return [];
|
||||
const record = value as EnvInputRecord;
|
||||
return [{
|
||||
key,
|
||||
description: asString(record.description) ?? null,
|
||||
agentSlug: null,
|
||||
projectSlug,
|
||||
kind: record.kind === "plain" ? "plain" : "secret",
|
||||
requirement: record.requirement === "required" ? "required" : "optional",
|
||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||
|
|
@ -2531,12 +2606,14 @@ function buildManifestFromPackageFiles(
|
|||
targetDate: asString(extension.targetDate),
|
||||
color: asString(extension.color),
|
||||
status: asString(extension.status),
|
||||
env: normalizePortableProjectEnv(extension.env),
|
||||
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
||||
? extension.executionWorkspacePolicy
|
||||
: null,
|
||||
workspaces,
|
||||
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
||||
});
|
||||
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
|
||||
if (frontmatter.kind && frontmatter.kind !== "project") {
|
||||
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
||||
}
|
||||
|
|
@ -3144,6 +3221,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
for (const project of selectedProjectRows) {
|
||||
const slug = projectSlugById.get(project.id)!;
|
||||
const projectPath = `projects/${slug}/PROJECT.md`;
|
||||
const envInputsStart = envInputs.length;
|
||||
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
|
||||
envInputs.push(...exportedEnvInputs);
|
||||
const projectEnvInputs = dedupeEnvInputs(
|
||||
envInputs
|
||||
.slice(envInputsStart)
|
||||
.filter((inputValue) => inputValue.projectSlug === slug),
|
||||
);
|
||||
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
|
||||
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
||||
files[projectPath] = buildMarkdown(
|
||||
|
|
@ -3167,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
) ?? undefined,
|
||||
workspaces: portableWorkspaces.extension,
|
||||
});
|
||||
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
|
||||
extension.inputs = {
|
||||
env: buildEnvInputMap(projectEnvInputs),
|
||||
};
|
||||
}
|
||||
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
||||
}
|
||||
|
||||
|
|
@ -3506,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
|
||||
for (const envInput of manifest.envInputs) {
|
||||
if (envInput.portability === "system_dependent") {
|
||||
warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`);
|
||||
const scope = envInput.agentSlug
|
||||
? ` for agent ${envInput.agentSlug}`
|
||||
: envInput.projectSlug
|
||||
? ` for project ${envInput.projectSlug}`
|
||||
: "";
|
||||
warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4095,6 +4190,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
||||
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
||||
: "backlog",
|
||||
env: manifestProject.env,
|
||||
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
|||
"pi_local",
|
||||
]);
|
||||
|
||||
type RuntimeConfigSecretResolver = Pick<
|
||||
ReturnType<typeof secretService>,
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
|
||||
>;
|
||||
|
||||
export async function resolveExecutionRunAdapterConfig(input: {
|
||||
companyId: string;
|
||||
executionRunConfig: Record<string, unknown>;
|
||||
projectEnv: unknown;
|
||||
secretsSvc: RuntimeConfigSecretResolver;
|
||||
}) {
|
||||
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
input.executionRunConfig,
|
||||
);
|
||||
const projectEnvResolution = input.projectEnv
|
||||
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
|
||||
: { env: {}, secretKeys: new Set<string>() };
|
||||
if (Object.keys(projectEnvResolution.env).length > 0) {
|
||||
resolvedConfig.env = {
|
||||
...parseObject(resolvedConfig.env),
|
||||
...projectEnvResolution.env,
|
||||
};
|
||||
for (const key of projectEnvResolution.secretKeys) {
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
return { resolvedConfig, secretKeys };
|
||||
}
|
||||
|
||||
export function applyPersistedExecutionWorkspaceConfig(input: {
|
||||
config: Record<string, unknown>;
|
||||
workspaceConfig: ExecutionWorkspaceConfig | null;
|
||||
|
|
@ -2309,17 +2339,20 @@ export function heartbeatService(db: Db) {
|
|||
: null;
|
||||
const contextProjectId = readNonEmptyString(context.projectId);
|
||||
const executionProjectId = issueContext?.projectId ?? contextProjectId;
|
||||
const projectExecutionWorkspacePolicy = executionProjectId
|
||||
const projectContext = executionProjectId
|
||||
? await db
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.select({
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
env: projects.env,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
||||
.then((rows) =>
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
);
|
||||
const taskSession = taskKey
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||
: null;
|
||||
|
|
@ -2416,10 +2449,12 @@ export function heartbeatService(db: Db) {
|
|||
: persistedWorkspaceManagedConfig;
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
executionRunConfig,
|
||||
);
|
||||
projectEnv: projectContext?.env ?? null,
|
||||
secretsSvc,
|
||||
});
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const runtimeConfig = {
|
||||
...resolvedConfig,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export interface IssueFilters {
|
|||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
|
|
@ -911,6 +912,9 @@ export function issueService(db: Db) {
|
|||
return {
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
||||
? Math.max(1, Math.floor(filters.limit))
|
||||
: undefined;
|
||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||
|
|
@ -999,7 +1003,7 @@ export function issueService(db: Db) {
|
|||
END
|
||||
`;
|
||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||
const rows = await db
|
||||
const baseQuery = db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(...conditions))
|
||||
|
|
@ -1009,6 +1013,7 @@ export function issueService(db: Db) {
|
|||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
);
|
||||
const rows = limit === undefined ? await baseQuery : await baseQuery.limit(limit);
|
||||
const withLabels = await withIssueLabels(db, rows);
|
||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||
const withRuns = withActiveRuns(withLabels, runMap);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
|||
}
|
||||
|
||||
export function secretService(db: Db) {
|
||||
type NormalizeEnvOptions = {
|
||||
strictMode?: boolean;
|
||||
fieldPath?: string;
|
||||
};
|
||||
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
|
|
@ -94,10 +99,10 @@ export function secretService(db: Db) {
|
|||
async function normalizeEnvConfig(
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
opts?: { strictMode?: boolean },
|
||||
opts?: NormalizeEnvOptions,
|
||||
): Promise<AgentEnvConfig> {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) throw unprocessable("adapterConfig.env must be an object");
|
||||
if (!record) throw unprocessable(`${opts?.fieldPath ?? "env"} must be an object`);
|
||||
|
||||
const normalized: AgentEnvConfig = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
|
|
@ -292,6 +297,12 @@ export function secretService(db: Db) {
|
|||
opts?: { strictMode?: boolean },
|
||||
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
||||
|
||||
normalizeEnvBindingsForPersistence: async (
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
opts?: NormalizeEnvOptions,
|
||||
) => normalizeEnvConfig(companyId, envValue, opts),
|
||||
|
||||
normalizeHireApprovalPayloadForPersistence: async (
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
|
@ -101,6 +102,18 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
|
|||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
||||
const DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES = 256 * 1024;
|
||||
|
||||
type ProcessOutputCapture = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
type ProcessOutputAccumulator = {
|
||||
append(chunk: string): void;
|
||||
finish(): ProcessOutputCapture;
|
||||
};
|
||||
|
||||
export async function resetRuntimeServicesForTests() {
|
||||
for (const record of runtimeServicesById.values()) {
|
||||
|
|
@ -122,6 +135,128 @@ function stableStringify(value: unknown): string {
|
|||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
type WorkspaceLinkMismatch = {
|
||||
packageName: string;
|
||||
expectedPath: string;
|
||||
actualPath: string | null;
|
||||
};
|
||||
|
||||
function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function findWorkspaceRoot(startCwd: string) {
|
||||
let current = path.resolve(startCwd);
|
||||
while (true) {
|
||||
if (existsSync(path.join(current, "pnpm-workspace.yaml"))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||
const packagePaths = new Map<string, string>();
|
||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
||||
|
||||
function visit(dirPath: string) {
|
||||
if (!existsSync(dirPath)) return;
|
||||
|
||||
const packageJsonPath = path.join(dirPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
||||
packagePaths.set(packageJson.name, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (ignoredDirNames.has(entry.name)) continue;
|
||||
visit(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
visit(path.join(rootDir, "packages"));
|
||||
visit(path.join(rootDir, "server"));
|
||||
visit(path.join(rootDir, "ui"));
|
||||
visit(path.join(rootDir, "cli"));
|
||||
|
||||
return packagePaths;
|
||||
}
|
||||
|
||||
function findServerWorkspaceLinkMismatches(rootDir: string): WorkspaceLinkMismatch[] {
|
||||
const serverPackageJsonPath = path.join(rootDir, "server", "package.json");
|
||||
if (!existsSync(serverPackageJsonPath)) return [];
|
||||
|
||||
const serverPackageJson = readJsonFile(serverPackageJsonPath);
|
||||
const dependencies = {
|
||||
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
|
||||
};
|
||||
const workspacePackagePaths = discoverWorkspacePackagePaths(rootDir);
|
||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
||||
|
||||
for (const [packageName, version] of Object.entries(dependencies)) {
|
||||
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
||||
|
||||
const expectedPath = workspacePackagePaths.get(packageName);
|
||||
if (!expectedPath) continue;
|
||||
const normalizedExpectedPath = existsSync(expectedPath) ? path.resolve(realpathSync(expectedPath)) : path.resolve(expectedPath);
|
||||
|
||||
const linkPath = path.join(rootDir, "server", "node_modules", ...packageName.split("/"));
|
||||
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
||||
if (actualPath === normalizedExpectedPath) continue;
|
||||
|
||||
mismatches.push({
|
||||
packageName,
|
||||
expectedPath: normalizedExpectedPath,
|
||||
actualPath,
|
||||
});
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
export async function ensureServerWorkspaceLinksCurrent(
|
||||
startCwd: string,
|
||||
opts?: {
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const workspaceRoot = findWorkspaceRoot(startCwd);
|
||||
if (!workspaceRoot) return;
|
||||
|
||||
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
||||
if (mismatches.length === 0) return;
|
||||
|
||||
if (opts?.onLog) {
|
||||
await opts.onLog("stdout", "[runtime] detected stale workspace package links for server; relinking dependencies...\n");
|
||||
for (const mismatch of mismatches) {
|
||||
await opts.onLog(
|
||||
"stdout",
|
||||
`[runtime] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mismatch of mismatches) {
|
||||
const linkPath = path.join(workspaceRoot, "server", "node_modules", ...mismatch.packageName.split("/"));
|
||||
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
await fs.symlink(mismatch.expectedPath, linkPath);
|
||||
}
|
||||
|
||||
const remainingMismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
||||
if (remainingMismatches.length === 0) return;
|
||||
|
||||
throw new Error(
|
||||
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
|
|
@ -258,30 +393,96 @@ function formatCommandForDisplay(command: string, args: string[]) {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator {
|
||||
const limit = Math.max(1, Math.trunc(maxBytes));
|
||||
let chunks: string[] = [];
|
||||
let truncated = false;
|
||||
let totalBytes = 0;
|
||||
|
||||
return {
|
||||
append(chunk: string) {
|
||||
if (!chunk) return;
|
||||
chunks.push(chunk);
|
||||
totalBytes += Buffer.byteLength(chunk, "utf8");
|
||||
|
||||
let currentBytes = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0);
|
||||
if (currentBytes <= limit) return;
|
||||
|
||||
const combined = Buffer.from(chunks.join(""), "utf8");
|
||||
const tail = combined.subarray(Math.max(0, combined.length - limit)).toString("utf8");
|
||||
chunks = [tail];
|
||||
truncated = true;
|
||||
currentBytes = Buffer.byteLength(tail, "utf8");
|
||||
if (currentBytes > limit) {
|
||||
chunks = [Buffer.from(tail, "utf8").subarray(Math.max(0, currentBytes - limit)).toString("utf8")];
|
||||
}
|
||||
},
|
||||
finish(): ProcessOutputCapture {
|
||||
const text = chunks.join("");
|
||||
if (!truncated) {
|
||||
return {
|
||||
text,
|
||||
truncated: false,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${text}`,
|
||||
truncated: true,
|
||||
totalBytes,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeProcess(input: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
|
||||
maxStdoutBytes?: number;
|
||||
maxStderrBytes?: number;
|
||||
}): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
stdoutTruncated: boolean;
|
||||
stderrTruncated: boolean;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
}> {
|
||||
const proc = await new Promise<{
|
||||
stdout: ProcessOutputAccumulator;
|
||||
stderr: ProcessOutputAccumulator;
|
||||
code: number | null;
|
||||
}>((resolve, reject) => {
|
||||
const child = spawn(input.command, input.args, {
|
||||
cwd: input.cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: input.env ?? process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdout = createProcessOutputCapture(input.maxStdoutBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
|
||||
const stderr = createProcessOutputCapture(input.maxStderrBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
stdout.append(String(chunk));
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
stderr.append(String(chunk));
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||
});
|
||||
return proc;
|
||||
const stdout = proc.stdout.finish();
|
||||
const stderr = proc.stderr.finish();
|
||||
return {
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
code: proc.code,
|
||||
stdoutTruncated: stdout.truncated,
|
||||
stderrTruncated: stderr.truncated,
|
||||
stdoutBytes: stdout.totalBytes,
|
||||
stderrBytes: stderr.totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
|
|
@ -377,8 +578,35 @@ function buildWorkspaceCommandEnv(input: {
|
|||
return env;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string) {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
|
||||
const patterns = [
|
||||
/^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
|
||||
/^(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = command.match(pattern);
|
||||
if (!match?.groups) continue;
|
||||
|
||||
const relativePath = match.groups.relative;
|
||||
const repoManagedPath = path.join(repoRoot, relativePath.slice(2));
|
||||
if (!existsSync(repoManagedPath)) continue;
|
||||
|
||||
const prefix = match.groups.prefix ?? "";
|
||||
const suffix = match.groups.suffix ?? "";
|
||||
return `${prefix}${quoteShellArg(repoManagedPath)}${suffix}`;
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
async function runWorkspaceCommand(input: {
|
||||
command: string;
|
||||
resolvedCommand?: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
label: string;
|
||||
|
|
@ -386,7 +614,7 @@ async function runWorkspaceCommand(input: {
|
|||
const shell = resolveShell();
|
||||
const proc = await executeProcess({
|
||||
command: shell,
|
||||
args: ["-c", input.command],
|
||||
args: ["-c", input.resolvedCommand ?? input.command],
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
|
|
@ -438,6 +666,15 @@ async function recordGitOperation(
|
|||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
||||
metadata:
|
||||
result.stdoutTruncated || result.stderrTruncated
|
||||
? {
|
||||
stdoutTruncated: result.stdoutTruncated,
|
||||
stderrTruncated: result.stderrTruncated,
|
||||
stdoutBytes: result.stdoutBytes,
|
||||
stderrBytes: result.stderrBytes,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -458,6 +695,7 @@ async function recordWorkspaceCommandOperation(
|
|||
input: {
|
||||
phase: "workspace_provision" | "workspace_teardown";
|
||||
command: string;
|
||||
resolvedCommand?: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
label: string;
|
||||
|
|
@ -482,7 +720,7 @@ async function recordWorkspaceCommandOperation(
|
|||
const shell = resolveShell();
|
||||
const result = await executeProcess({
|
||||
command: shell,
|
||||
args: ["-c", input.command],
|
||||
args: ["-c", input.resolvedCommand ?? input.command],
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
|
|
@ -495,6 +733,15 @@ async function recordWorkspaceCommandOperation(
|
|||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
||||
metadata:
|
||||
result.stdoutTruncated || result.stderrTruncated
|
||||
? {
|
||||
stdoutTruncated: result.stdoutTruncated,
|
||||
stderrTruncated: result.stderrTruncated,
|
||||
stdoutBytes: result.stdoutBytes,
|
||||
stderrBytes: result.stderrBytes,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -522,10 +769,12 @@ async function provisionExecutionWorktree(input: {
|
|||
}) {
|
||||
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
|
||||
if (!provisionCommand) return;
|
||||
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
|
||||
|
||||
await recordWorkspaceCommandOperation(input.recorder, {
|
||||
phase: "workspace_provision",
|
||||
command: provisionCommand,
|
||||
resolvedCommand: resolvedProvisionCommand,
|
||||
cwd: input.worktreePath,
|
||||
env: buildWorkspaceCommandEnv({
|
||||
base: input.base,
|
||||
|
|
@ -542,6 +791,7 @@ async function provisionExecutionWorktree(input: {
|
|||
worktreePath: input.worktreePath,
|
||||
branchName: input.branchName,
|
||||
created: input.created,
|
||||
resolvedCommand: resolvedProvisionCommand === provisionCommand ? null : resolvedProvisionCommand,
|
||||
},
|
||||
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
|
||||
});
|
||||
|
|
@ -769,6 +1019,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
}) {
|
||||
const warnings: string[] = [];
|
||||
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
|
||||
const repoRoot = input.workspace.providerType === "git_worktree" && workspacePath
|
||||
? await resolveGitRepoRootForWorkspaceCleanup(
|
||||
workspacePath,
|
||||
input.projectWorkspace?.cwd ?? null,
|
||||
)
|
||||
: null;
|
||||
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
|
||||
workspace: input.workspace,
|
||||
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
|
||||
|
|
@ -784,9 +1040,13 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
|
||||
for (const command of cleanupCommands) {
|
||||
try {
|
||||
const resolvedCommand = repoRoot
|
||||
? resolveRepoManagedWorkspaceCommand(command, repoRoot)
|
||||
: command;
|
||||
await recordWorkspaceCommandOperation(input.recorder, {
|
||||
phase: "workspace_teardown",
|
||||
command,
|
||||
resolvedCommand,
|
||||
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
|
||||
env: cleanupEnv,
|
||||
label: `Execution workspace cleanup command "${command}"`,
|
||||
|
|
@ -795,6 +1055,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
providerType: input.workspace.providerType,
|
||||
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
|
||||
},
|
||||
successMessage: `Completed cleanup command "${command}"\n`,
|
||||
});
|
||||
|
|
@ -804,10 +1065,6 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
}
|
||||
|
||||
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
||||
const repoRoot = await resolveGitRepoRootForWorkspaceCleanup(
|
||||
workspacePath,
|
||||
input.projectWorkspace?.cwd ?? null,
|
||||
);
|
||||
const worktreeExists = await directoryExists(workspacePath);
|
||||
if (worktreeExists) {
|
||||
if (!repoRoot) {
|
||||
|
|
@ -1374,7 +1631,11 @@ async function startLocalRuntimeService(input: {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(serviceCwd, {
|
||||
onLog: input.onLog,
|
||||
});
|
||||
|
||||
const shell = resolveShell();
|
||||
const child = spawn(shell, ["-lc", command], {
|
||||
cwd: serviceCwd,
|
||||
|
|
|
|||
|
|
@ -133,6 +133,37 @@ If a blocker is moved to `cancelled`, it does **not** count as resolved for bloc
|
|||
|
||||
When you receive one of these wake reasons, check the issue state and continue the work or mark it done.
|
||||
|
||||
## Requesting Board Approval
|
||||
|
||||
Agents can create approval requests for arbitrary issue-linked work. Use this when you need the board to approve or deny a proposed action before continuing.
|
||||
|
||||
Recommended generic type:
|
||||
|
||||
- `request_board_approval` for open-ended approval requests like spend approval, vendor approval, launch approval, or other board decisions
|
||||
|
||||
Create the approval and link it to the relevant issue in one call:
|
||||
|
||||
```json
|
||||
POST /api/companies/{companyId}/approvals
|
||||
{
|
||||
"type": "request_board_approval",
|
||||
"requestedByAgentId": "{your-agent-id}",
|
||||
"issueIds": ["{issue-id}"],
|
||||
"payload": {
|
||||
"title": "Approve monthly hosting spend",
|
||||
"summary": "Estimated cost is $42/month for provider X.",
|
||||
"recommendedAction": "Approve provider X and continue setup.",
|
||||
"risks": ["Costs may increase with usage."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `issueIds` links the approval into the issue thread/UI.
|
||||
- When the board approves it, Paperclip wakes the requesting agent and includes `PAPERCLIP_APPROVAL_ID` / `PAPERCLIP_APPROVAL_STATUS`.
|
||||
- Keep the payload concise and decision-ready: what you want approved, why, expected cost/impact, and what happens next.
|
||||
|
||||
## Project Setup Workflow (CEO/Manager Common Path)
|
||||
|
||||
When asked to set up a new project with workspace config (local folder and/or GitHub repo), use:
|
||||
|
|
@ -335,6 +366,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
|||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||
| Release task | `POST /api/issues/:issueId/release` |
|
||||
| List agents | `GET /api/companies/:companyId/agents` |
|
||||
| Create approval | `POST /api/companies/:companyId/approvals` |
|
||||
| List company skills | `GET /api/companies/:companyId/skills` |
|
||||
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
||||
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3100);
|
||||
// Use a dedicated port so e2e tests always start their own server in local_trusted mode,
|
||||
// even when the dev server is running on :3100 in authenticated mode.
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
|
|
@ -29,6 +31,11 @@ export default defineConfig({
|
|||
timeout: 120_000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(PORT),
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
|
||||
},
|
||||
},
|
||||
outputDir: "./test-results",
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
|
||||
|
|
|
|||
399
tests/e2e/signoff-policy.spec.ts
Normal file
399
tests/e2e/signoff-policy.spec.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E: Signoff execution policy flow.
|
||||
*
|
||||
* Validates the full signoff lifecycle through the API and UI:
|
||||
* 1. Create a company with executor + reviewer + approver agents
|
||||
* 2. Create an issue with a two-stage execution policy (review → approval)
|
||||
* 3. Executor marks done → issue routes to reviewer (in_review)
|
||||
* 4. Reviewer approves → issue routes to approver
|
||||
* 5. Approver approves → execution completes, issue marked done
|
||||
* 6. Verify "changes requested" flow returns to executor
|
||||
*
|
||||
* Requires local_trusted deployment mode (set in playwright.config.ts webServer env).
|
||||
*
|
||||
* Agent auth flow:
|
||||
* - Board request (local_trusted auto-auth) handles setup/teardown.
|
||||
* - Agent-specific actions use API keys + heartbeat run IDs.
|
||||
* - Reviewers/approvers invoke heartbeat runs (gets run IDs) then PATCH
|
||||
* directly without checkout (checkout would force in_progress, breaking
|
||||
* the in_review state the signoff policy requires).
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
const COMPANY_NAME = `E2E-Signoff-${Date.now()}`;
|
||||
|
||||
interface AgentAuth {
|
||||
agentId: string;
|
||||
token: string;
|
||||
keyId: string;
|
||||
request: APIRequestContext;
|
||||
}
|
||||
|
||||
interface TestContext {
|
||||
companyId: string;
|
||||
companyPrefix: string;
|
||||
executor: AgentAuth;
|
||||
reviewer: AgentAuth;
|
||||
approver: AgentAuth;
|
||||
boardRequest: APIRequestContext;
|
||||
issueIds: string[];
|
||||
}
|
||||
|
||||
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
|
||||
async function createAgentRequest(token: string): Promise<APIRequestContext> {
|
||||
return pwRequest.newContext({
|
||||
baseURL: BASE_URL,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoke a heartbeat run for an agent, returning the run ID. */
|
||||
async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promise<string> {
|
||||
const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const run = await res.json();
|
||||
return run.id;
|
||||
}
|
||||
|
||||
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
|
||||
async function agentPatch(
|
||||
board: APIRequestContext,
|
||||
agent: AgentAuth,
|
||||
issueId: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
const runId = await invokeHeartbeat(board, agent.agentId);
|
||||
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
||||
headers: { "X-Paperclip-Run-Id": runId },
|
||||
data,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Checkout an issue as an agent, then PATCH it. Used for executor mark-done. */
|
||||
async function agentCheckoutAndPatch(
|
||||
board: APIRequestContext,
|
||||
agent: AgentAuth,
|
||||
issueId: string,
|
||||
expectedStatuses: string[],
|
||||
patchData: Record<string, unknown>,
|
||||
) {
|
||||
const runId = await invokeHeartbeat(board, agent.agentId);
|
||||
// Checkout (sets executionRunId so PATCH is allowed)
|
||||
const checkoutRes = await agent.request.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
||||
headers: { "X-Paperclip-Run-Id": runId },
|
||||
data: { agentId: agent.agentId, expectedStatuses },
|
||||
});
|
||||
if (!checkoutRes.ok()) {
|
||||
// If agent checkout fails (e.g. run expired), fall back to board checkout
|
||||
// then PATCH with the agent's identity
|
||||
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
||||
data: { agentId: agent.agentId, expectedStatuses },
|
||||
});
|
||||
if (!boardCheckout.ok()) {
|
||||
throw new Error(`Board checkout failed: ${await boardCheckout.text()}`);
|
||||
}
|
||||
// Board PATCH (executor mark-done triggers signoff regardless of actor)
|
||||
const res = await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
||||
data: patchData,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
// PATCH with agent identity
|
||||
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
||||
headers: { "X-Paperclip-Run-Id": runId },
|
||||
data: patchData,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function setupCompany(boardRequest: APIRequestContext): Promise<TestContext> {
|
||||
// Verify server is in local_trusted mode
|
||||
const healthRes = await boardRequest.get(`${BASE_URL}/api/health`);
|
||||
expect(healthRes.ok()).toBe(true);
|
||||
const health = await healthRes.json();
|
||||
if (health.deploymentMode !== "local_trusted") {
|
||||
throw new Error(
|
||||
`Signoff e2e tests require local_trusted deployment mode, ` +
|
||||
`but server is in "${health.deploymentMode}" mode. ` +
|
||||
`Set PAPERCLIP_DEPLOYMENT_MODE=local_trusted or use the webServer config.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create company
|
||||
const companyRes = await boardRequest.post(`${BASE_URL}/api/companies`, {
|
||||
data: { name: COMPANY_NAME },
|
||||
});
|
||||
if (!companyRes.ok()) {
|
||||
const errBody = await companyRes.text();
|
||||
throw new Error(`POST /api/companies → ${companyRes.status()}: ${errBody}`);
|
||||
}
|
||||
const company = await companyRes.json();
|
||||
const companyId = company.id;
|
||||
const companyPrefix = company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E";
|
||||
|
||||
// Helper: create agent + API key + request context
|
||||
async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> {
|
||||
const agentRes = await boardRequest.post(`${BASE_URL}/api/companies/${companyId}/agents`, {
|
||||
data: { name, role, title, adapterType: "process", adapterConfig: { command: "echo done" } },
|
||||
});
|
||||
expect(agentRes.ok()).toBe(true);
|
||||
const agent = await agentRes.json();
|
||||
|
||||
const keyRes = await boardRequest.post(`${BASE_URL}/api/agents/${agent.id}/keys`, {
|
||||
data: { name: `e2e-${name.toLowerCase()}` },
|
||||
});
|
||||
expect(keyRes.ok()).toBe(true);
|
||||
const keyData = await keyRes.json();
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
token: keyData.token,
|
||||
keyId: keyData.id,
|
||||
request: await createAgentRequest(keyData.token),
|
||||
};
|
||||
}
|
||||
|
||||
const executor = await createAgent("Executor", "engineer", "Software Engineer");
|
||||
const reviewer = await createAgent("Reviewer", "qa", "QA Engineer");
|
||||
const approver = await createAgent("Approver", "cto", "CTO");
|
||||
|
||||
return {
|
||||
companyId,
|
||||
companyPrefix,
|
||||
executor,
|
||||
reviewer,
|
||||
approver,
|
||||
boardRequest,
|
||||
issueIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function createIssueWithPolicy(ctx: TestContext, title: string, stages?: unknown[]) {
|
||||
const defaultStages = [
|
||||
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
|
||||
{ type: "approval", participants: [{ type: "agent", agentId: ctx.approver.agentId }] },
|
||||
];
|
||||
const res = await ctx.boardRequest.post(`${BASE_URL}/api/companies/${ctx.companyId}/issues`, {
|
||||
data: {
|
||||
title,
|
||||
status: "in_progress",
|
||||
assigneeAgentId: ctx.executor.agentId,
|
||||
executionPolicy: { stages: stages ?? defaultStages },
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const issue = await res.json();
|
||||
ctx.issueIds.push(issue.id);
|
||||
return issue;
|
||||
}
|
||||
|
||||
test.describe("Signoff execution policy", () => {
|
||||
let ctx: TestContext;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const boardRequest = await pwRequest.newContext({ baseURL: BASE_URL });
|
||||
ctx = await setupCompany(boardRequest);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (!ctx) return;
|
||||
const board = ctx.boardRequest;
|
||||
|
||||
// Dispose agent request contexts
|
||||
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
|
||||
await agent.request.dispose();
|
||||
}
|
||||
|
||||
// Clean up issues, keys, agents, company (best-effort)
|
||||
for (const issueId of ctx.issueIds) {
|
||||
await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
||||
data: { status: "cancelled", comment: "E2E test cleanup." },
|
||||
}).catch(() => {});
|
||||
}
|
||||
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
|
||||
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}/keys/${agent.keyId}`).catch(() => {});
|
||||
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}`).catch(() => {});
|
||||
}
|
||||
await board.delete(`${BASE_URL}/api/companies/${ctx.companyId}`).catch(() => {});
|
||||
await board.dispose();
|
||||
});
|
||||
|
||||
test("happy path: executor → review → approval → done", async ({ page }) => {
|
||||
const issue = await createIssueWithPolicy(ctx, "Signoff happy path");
|
||||
const issueId = issue.id;
|
||||
|
||||
// Verify policy was saved
|
||||
expect(issue.executionPolicy).toBeTruthy();
|
||||
expect(issue.executionPolicy.stages).toHaveLength(2);
|
||||
expect(issue.executionPolicy.stages[0].type).toBe("review");
|
||||
expect(issue.executionPolicy.stages[1].type).toBe("approval");
|
||||
|
||||
// Step 1: Executor marks done → should route to reviewer
|
||||
const step1Res = await agentCheckoutAndPatch(
|
||||
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
||||
{ status: "done", comment: "Implemented the feature, ready for review." },
|
||||
);
|
||||
expect(step1Res.ok()).toBe(true);
|
||||
const step1Issue = await step1Res.json();
|
||||
|
||||
expect(step1Issue.status).toBe("in_review");
|
||||
expect(step1Issue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
||||
expect(step1Issue.executionState).toBeTruthy();
|
||||
expect(step1Issue.executionState.status).toBe("pending");
|
||||
expect(step1Issue.executionState.currentStageType).toBe("review");
|
||||
expect(step1Issue.executionState.returnAssignee).toMatchObject({
|
||||
type: "agent",
|
||||
agentId: ctx.executor.agentId,
|
||||
});
|
||||
|
||||
// Step 2: Navigate to issue in UI and verify execution label
|
||||
await page.goto(`/${ctx.companyPrefix}/issues/${issue.identifier}`);
|
||||
await expect(page.locator("text=Review pending")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Step 3: Reviewer approves → should route to approver
|
||||
const step3Res = await agentPatch(
|
||||
ctx.boardRequest, ctx.reviewer, issueId,
|
||||
{ status: "done", comment: "QA signoff complete. Looks good." },
|
||||
);
|
||||
expect(step3Res.ok()).toBe(true);
|
||||
const step3Issue = await step3Res.json();
|
||||
|
||||
expect(step3Issue.status).toBe("in_review");
|
||||
expect(step3Issue.assigneeAgentId).toBe(ctx.approver.agentId);
|
||||
expect(step3Issue.executionState.status).toBe("pending");
|
||||
expect(step3Issue.executionState.currentStageType).toBe("approval");
|
||||
expect(step3Issue.executionState.completedStageIds).toHaveLength(1);
|
||||
|
||||
// Step 4: Verify UI shows approval pending
|
||||
await page.reload();
|
||||
await expect(page.locator("text=Approval pending")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Step 5: Approver approves → should complete
|
||||
const step5Res = await agentPatch(
|
||||
ctx.boardRequest, ctx.approver, issueId,
|
||||
{ status: "done", comment: "Approved. Ship it." },
|
||||
);
|
||||
expect(step5Res.ok()).toBe(true);
|
||||
const step5Issue = await step5Res.json();
|
||||
|
||||
expect(step5Issue.status).toBe("done");
|
||||
expect(step5Issue.executionState.status).toBe("completed");
|
||||
expect(step5Issue.executionState.completedStageIds).toHaveLength(2);
|
||||
expect(step5Issue.executionState.lastDecisionOutcome).toBe("approved");
|
||||
});
|
||||
|
||||
test("changes requested: reviewer bounces back to executor", async () => {
|
||||
const issue = await createIssueWithPolicy(ctx, "Signoff changes requested");
|
||||
const issueId = issue.id;
|
||||
|
||||
// Executor marks done → routes to reviewer
|
||||
const doneRes = await agentCheckoutAndPatch(
|
||||
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
||||
{ status: "done", comment: "Ready for review." },
|
||||
);
|
||||
expect(doneRes.ok()).toBe(true);
|
||||
expect((await doneRes.json()).status).toBe("in_review");
|
||||
|
||||
// Reviewer requests changes → returns to executor
|
||||
const changesRes = await agentPatch(
|
||||
ctx.boardRequest, ctx.reviewer, issueId,
|
||||
{ status: "in_progress", comment: "Needs another pass on edge cases." },
|
||||
);
|
||||
expect(changesRes.ok()).toBe(true);
|
||||
const changesIssue = await changesRes.json();
|
||||
|
||||
expect(changesIssue.status).toBe("in_progress");
|
||||
expect(changesIssue.assigneeAgentId).toBe(ctx.executor.agentId);
|
||||
expect(changesIssue.executionState.status).toBe("changes_requested");
|
||||
expect(changesIssue.executionState.lastDecisionOutcome).toBe("changes_requested");
|
||||
|
||||
// Executor re-submits → goes back to reviewer (same stage)
|
||||
const resubmitRes = await agentCheckoutAndPatch(
|
||||
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
||||
{ status: "done", comment: "Fixed the edge cases." },
|
||||
);
|
||||
expect(resubmitRes.ok()).toBe(true);
|
||||
const resubmitIssue = await resubmitRes.json();
|
||||
|
||||
expect(resubmitIssue.status).toBe("in_review");
|
||||
expect(resubmitIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
||||
expect(resubmitIssue.executionState.status).toBe("pending");
|
||||
expect(resubmitIssue.executionState.currentStageType).toBe("review");
|
||||
});
|
||||
|
||||
test("comment required: approval without comment fails", async () => {
|
||||
const issue = await createIssueWithPolicy(ctx, "Signoff comment required");
|
||||
const issueId = issue.id;
|
||||
|
||||
// Executor marks done → routes to reviewer
|
||||
await agentCheckoutAndPatch(
|
||||
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
||||
{ status: "done", comment: "Done." },
|
||||
);
|
||||
|
||||
// Reviewer tries to approve without comment → should fail
|
||||
const noCommentRes = await agentPatch(
|
||||
ctx.boardRequest, ctx.reviewer, issueId,
|
||||
{ status: "done" },
|
||||
);
|
||||
expect(noCommentRes.ok()).toBe(false);
|
||||
const errorBody = await noCommentRes.json();
|
||||
expect(JSON.stringify(errorBody)).toContain("comment");
|
||||
});
|
||||
|
||||
test("non-participant cannot advance stage", async () => {
|
||||
const issue = await createIssueWithPolicy(ctx, "Signoff access control");
|
||||
const issueId = issue.id;
|
||||
|
||||
// Executor marks done → routes to reviewer
|
||||
const doneRes = await agentCheckoutAndPatch(
|
||||
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
|
||||
{ status: "done", comment: "Done." },
|
||||
);
|
||||
expect(doneRes.ok()).toBe(true);
|
||||
|
||||
// Verify issue is in_review with reviewer
|
||||
const issueRes = await ctx.boardRequest.get(`${BASE_URL}/api/issues/${issueId}`);
|
||||
const inReviewIssue = await issueRes.json();
|
||||
expect(inReviewIssue.status).toBe("in_review");
|
||||
expect(inReviewIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
|
||||
expect(inReviewIssue.executionState.currentStageType).toBe("review");
|
||||
|
||||
// Non-participant (approver at this stage) tries to advance → should be rejected
|
||||
const advanceRes = await agentPatch(
|
||||
ctx.boardRequest, ctx.approver, issueId,
|
||||
{ status: "done", comment: "I'm the approver, not the reviewer." },
|
||||
);
|
||||
expect(advanceRes.ok()).toBe(false);
|
||||
expect(advanceRes.status()).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test("review-only policy: reviewer approval completes execution", async () => {
|
||||
const issue = await createIssueWithPolicy(ctx, "Signoff review-only", [
|
||||
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
|
||||
]);
|
||||
|
||||
// Executor marks done → routes to reviewer
|
||||
const doneRes = await agentCheckoutAndPatch(
|
||||
ctx.boardRequest, ctx.executor, issue.id, ["in_progress"],
|
||||
{ status: "done", comment: "Ready for review." },
|
||||
);
|
||||
expect(doneRes.ok()).toBe(true);
|
||||
expect((await doneRes.json()).status).toBe("in_review");
|
||||
|
||||
// Reviewer approves → should complete immediately (no approval stage)
|
||||
const approveRes = await agentPatch(
|
||||
ctx.boardRequest, ctx.reviewer, issue.id,
|
||||
{ status: "done", comment: "LGTM." },
|
||||
);
|
||||
expect(approveRes.ok()).toBe(true);
|
||||
const doneIssue = await approveRes.json();
|
||||
expect(doneIssue.status).toBe("done");
|
||||
expect(doneIssue.executionState.status).toBe("completed");
|
||||
expect(doneIssue.executionState.completedStageIds).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -30,7 +30,6 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lexical/link": "0.35.0",
|
||||
"lexical": "0.35.0",
|
||||
"@mdxeditor/editor": "^3.52.4",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
|
|
@ -41,13 +40,14 @@
|
|||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"lexical": "0.35.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mermaid": "^11.12.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const issuesApi = {
|
|||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -53,6 +54,7 @@ export const issuesApi = {
|
|||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.q) params.set("q", filters.q);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
const qs = params.toString();
|
||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
|
|||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { ReportsToPicker } from "./ReportsToPicker";
|
||||
import { EnvVarEditor } from "./EnvVarEditor";
|
||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
|
|
@ -1082,269 +1083,6 @@ function AdapterTypeDropdown({
|
|||
);
|
||||
}
|
||||
|
||||
function EnvVarEditor({
|
||||
value,
|
||||
secrets,
|
||||
onCreateSecret,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, EnvBinding>;
|
||||
secrets: CompanySecret[];
|
||||
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
|
||||
onChange: (env: Record<string, EnvBinding> | undefined) => void;
|
||||
}) {
|
||||
type Row = {
|
||||
key: string;
|
||||
source: "plain" | "secret";
|
||||
plainValue: string;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") {
|
||||
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
}
|
||||
const entries = Object.entries(rec).map(([k, binding]) => {
|
||||
if (typeof binding === "string") {
|
||||
return {
|
||||
key: k,
|
||||
source: "plain" as const,
|
||||
plainValue: binding,
|
||||
secretId: "",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "secret_ref"
|
||||
) {
|
||||
const recBinding = binding as { secretId?: unknown };
|
||||
return {
|
||||
key: k,
|
||||
source: "secret" as const,
|
||||
plainValue: "",
|
||||
secretId: typeof recBinding.secretId === "string" ? recBinding.secretId : "",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "plain"
|
||||
) {
|
||||
const recBinding = binding as { value?: unknown };
|
||||
return {
|
||||
key: k,
|
||||
source: "plain" as const,
|
||||
plainValue: typeof recBinding.value === "string" ? recBinding.value : "",
|
||||
secretId: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: k,
|
||||
source: "plain" as const,
|
||||
plainValue: "",
|
||||
secretId: "",
|
||||
};
|
||||
});
|
||||
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
}
|
||||
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(value));
|
||||
const [sealError, setSealError] = useState<string | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
const emittingRef = useRef(false);
|
||||
|
||||
// Sync when value identity changes (overlay reset after save).
|
||||
// Skip re-sync when the change was triggered by our own emit() to avoid
|
||||
// reverting local row state (e.g. a secret-transition dropdown choice).
|
||||
useEffect(() => {
|
||||
if (emittingRef.current) {
|
||||
emittingRef.current = false;
|
||||
valueRef.current = value;
|
||||
return;
|
||||
}
|
||||
if (value !== valueRef.current) {
|
||||
valueRef.current = value;
|
||||
setRows(toRows(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function emit(nextRows: Row[]) {
|
||||
const rec: Record<string, EnvBinding> = {};
|
||||
for (const row of nextRows) {
|
||||
const k = row.key.trim();
|
||||
if (!k) continue;
|
||||
if (row.source === "secret") {
|
||||
if (row.secretId) {
|
||||
rec[k] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
|
||||
} else {
|
||||
// Row is transitioning to secret but user hasn't picked one yet.
|
||||
// Preserve the plain value so it isn't silently dropped.
|
||||
rec[k] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
} else {
|
||||
rec[k] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
}
|
||||
emittingRef.current = true;
|
||||
onChange(Object.keys(rec).length > 0 ? rec : undefined);
|
||||
}
|
||||
|
||||
function updateRow(i: number, patch: Partial<Row>) {
|
||||
const withPatch = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||
if (
|
||||
withPatch[withPatch.length - 1].key ||
|
||||
withPatch[withPatch.length - 1].plainValue ||
|
||||
withPatch[withPatch.length - 1].secretId
|
||||
) {
|
||||
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
}
|
||||
setRows(withPatch);
|
||||
emit(withPatch);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
const next = rows.filter((_, idx) => idx !== i);
|
||||
if (
|
||||
next.length === 0 ||
|
||||
next[next.length - 1].key ||
|
||||
next[next.length - 1].plainValue ||
|
||||
next[next.length - 1].secretId
|
||||
) {
|
||||
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
function defaultSecretName(key: string): string {
|
||||
return key
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
async function sealRow(i: number) {
|
||||
const row = rows[i];
|
||||
if (!row) return;
|
||||
const key = row.key.trim();
|
||||
const plain = row.plainValue;
|
||||
if (!key || plain.length === 0) return;
|
||||
|
||||
const suggested = defaultSecretName(key) || "secret";
|
||||
const name = window.prompt("Secret name", suggested)?.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
setSealError(null);
|
||||
const created = await onCreateSecret(name, plain);
|
||||
updateRow(i, {
|
||||
source: "secret",
|
||||
secretId: created.id,
|
||||
});
|
||||
} catch (err) {
|
||||
setSealError(err instanceof Error ? err.message : "Failed to create secret");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, i) => {
|
||||
const isTrailing =
|
||||
i === rows.length - 1 &&
|
||||
!row.key &&
|
||||
!row.plainValue &&
|
||||
!row.secretId;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className={cn(inputClass, "flex-[2]")}
|
||||
placeholder="KEY"
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[1] bg-background")}
|
||||
value={row.source}
|
||||
onChange={(e) =>
|
||||
updateRow(i, {
|
||||
source: e.target.value === "secret" ? "secret" : "plain",
|
||||
...(e.target.value === "plain" ? { secretId: "" } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="plain">Plain</option>
|
||||
<option value="secret">Secret</option>
|
||||
</select>
|
||||
{row.source === "secret" ? (
|
||||
<>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[3] bg-background")}
|
||||
value={row.secretId}
|
||||
onChange={(e) => updateRow(i, { secretId: e.target.value })}
|
||||
>
|
||||
<option value="">Select secret...</option>
|
||||
{secrets.map((secret) => (
|
||||
<option key={secret.id} value={secret.id}>
|
||||
{secret.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={() => sealRow(i)}
|
||||
disabled={!row.key.trim() || !row.plainValue}
|
||||
title="Create secret from current plain value"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="value"
|
||||
value={row.plainValue}
|
||||
onChange={(e) => updateRow(i, { plainValue: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={() => sealRow(i)}
|
||||
disabled={!row.key.trim() || !row.plainValue}
|
||||
title="Store value as secret and replace with reference"
|
||||
>
|
||||
Seal
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isTrailing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => removeRow(i)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[26px] shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
PAPERCLIP_* variables are injected automatically at runtime.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDropdown({
|
||||
models,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Identity } from "./Identity";
|
||||
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||
import {
|
||||
approvalSubject,
|
||||
typeIcon,
|
||||
defaultTypeIcon,
|
||||
ApprovalPayloadRenderer,
|
||||
typeLabel,
|
||||
} from "./ApprovalPayload";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import type { Approval, Agent } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function statusIcon(status: string) {
|
||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
|
||||
|
|
@ -21,86 +29,124 @@ export function ApprovalCard({
|
|||
onReject,
|
||||
onOpen,
|
||||
detailLink,
|
||||
isPending,
|
||||
isPending = false,
|
||||
pendingAction = null,
|
||||
}: {
|
||||
approval: Approval;
|
||||
requesterAgent: Agent | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onOpen?: () => void;
|
||||
detailLink?: string;
|
||||
isPending: boolean;
|
||||
isPending?: boolean;
|
||||
pendingAction?: "approve" | "reject" | null;
|
||||
}) {
|
||||
const payload = approval.payload as Record<string, unknown> | null;
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
|
||||
const kindLabel = typeLabel[approval.type] ?? approval.type;
|
||||
const subject = approvalSubject(payload);
|
||||
const showResolutionButtons =
|
||||
Boolean(onApprove && onReject) &&
|
||||
approval.type !== "budget_override_required" &&
|
||||
(approval.status === "pending" || approval.status === "revision_requested");
|
||||
const hasFooter = showResolutionButtons || Boolean(detailLink || onOpen);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
{requesterAgent && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
||||
</span>
|
||||
)}
|
||||
<div className="rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/80">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/70 bg-background/70 px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"
|
||||
>
|
||||
{kindLabel}
|
||||
</Badge>
|
||||
{requesterAgent && (
|
||||
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Requested by</span>
|
||||
<Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold leading-6 text-foreground">
|
||||
{subject ?? kindLabel}
|
||||
</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Approval request created {timeAgo(approval.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{statusIcon(approval.status)}
|
||||
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
||||
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
||||
<div className="shrink-0">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/80 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{statusIcon(approval.status)}
|
||||
<span className="capitalize">{approval.status.replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payload */}
|
||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
||||
<div className="mt-4 border-t border-border/60 pt-4">
|
||||
<ApprovalPayloadRenderer
|
||||
type={approval.type}
|
||||
payload={approval.payload}
|
||||
hidePrimaryTitle={Boolean(subject)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Decision note */}
|
||||
{approval.decisionNote && (
|
||||
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
||||
Note: {approval.decisionNote}
|
||||
<div className="mt-4 rounded-lg border border-border/60 bg-muted/30 px-3.5 py-3 text-xs leading-5 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Decision note.</span> {approval.decisionNote}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showResolutionButtons && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-700 hover:bg-green-600 text-white"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{hasFooter ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showResolutionButtons && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-700 hover:bg-green-600 text-white"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
{pendingAction === "approve" ? "Approving..." : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
{pendingAction === "reject" ? "Rejecting..." : "Reject"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(detailLink || onOpen) ? (
|
||||
detailLink ? (
|
||||
<Link
|
||||
to={detailLink}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-auto px-2 text-xs text-muted-foreground")}
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-auto px-2 text-xs text-muted-foreground" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
{detailLink ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
||||
<Link to={detailLink}>View details</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
budget_override_required: "Budget Override",
|
||||
request_board_approval: "Board Approval",
|
||||
};
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function approvalSubject(payload?: Record<string, unknown> | null): string | null {
|
||||
return firstNonEmptyString(
|
||||
payload?.title,
|
||||
payload?.name,
|
||||
payload?.summary,
|
||||
payload?.recommendedAction,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
|
||||
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
|
||||
const base = typeLabel[type] ?? type;
|
||||
if (type === "hire_agent" && payload?.name) {
|
||||
return `${base}: ${String(payload.name)}`;
|
||||
const subject = approvalSubject(payload);
|
||||
if (subject) {
|
||||
return `${base}: ${subject}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
@ -20,6 +40,7 @@ export const typeIcon: Record<string, typeof UserPlus> = {
|
|||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
budget_override_required: ShieldAlert,
|
||||
request_board_approval: ShieldCheck,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
|
|
@ -127,8 +148,100 @@ export function BudgetOverridePayload({ payload }: { payload: Record<string, unk
|
|||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
||||
export function BoardApprovalPayload({
|
||||
payload,
|
||||
hideTitle = false,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
const nextPayload = hideTitle ? { ...payload, title: undefined } : payload;
|
||||
return (
|
||||
<BoardApprovalPayloadContent payload={nextPayload} />
|
||||
);
|
||||
}
|
||||
|
||||
function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unknown> }) {
|
||||
const risks = Array.isArray(payload.risks)
|
||||
? payload.risks
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const title = firstNonEmptyString(payload.title);
|
||||
const summary = firstNonEmptyString(payload.summary);
|
||||
const recommendedAction = firstNonEmptyString(payload.recommendedAction);
|
||||
const nextActionOnApproval = firstNonEmptyString(payload.nextActionOnApproval);
|
||||
const proposedComment = firstNonEmptyString(payload.proposedComment);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3.5 text-sm">
|
||||
{title && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Title</p>
|
||||
<p className="font-medium leading-6 text-foreground">{title}</p>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Summary</p>
|
||||
<p className="leading-6 text-foreground/90">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
{recommendedAction && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-amber-700 dark:text-amber-300">
|
||||
Recommended action
|
||||
</p>
|
||||
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
|
||||
</div>
|
||||
)}
|
||||
{nextActionOnApproval && (
|
||||
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">On approval</p>
|
||||
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
|
||||
</div>
|
||||
)}
|
||||
{risks.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Risks</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{risks.map((risk) => (
|
||||
<li key={risk} className="flex items-start gap-2">
|
||||
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-muted-foreground/60" />
|
||||
<span className="leading-6">{risk}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{proposedComment && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
Proposed comment
|
||||
</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-lg border border-border/60 bg-muted/50 px-3.5 py-3 font-mono text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{proposedComment}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({
|
||||
type,
|
||||
payload,
|
||||
hidePrimaryTitle = false,
|
||||
}: {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
hidePrimaryTitle?: boolean;
|
||||
}) {
|
||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
|
||||
if (type === "request_board_approval") {
|
||||
return <BoardApprovalPayload payload={payload} hideTitle={hidePrimaryTitle} />;
|
||||
}
|
||||
return <CeoStrategyPayload payload={payload} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,12 +60,12 @@ export function CommandPalette() {
|
|||
const { data: issues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open,
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
|
||||
});
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }),
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { act } from "react";
|
|||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { Agent, Approval } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CommentThread } from "./CommentThread";
|
||||
|
||||
|
|
@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({
|
|||
InlineEntitySelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./ApprovalCard", () => ({
|
||||
ApprovalCard: ({
|
||||
approval,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
approval: Approval;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div>{approval.type}</div>
|
||||
<div>{String(approval.payload.title ?? "")}</div>
|
||||
{onApprove ? <button type="button" onClick={onApprove}>Approve</button> : null}
|
||||
{onReject ? <button type="button" onClick={onReject}>Reject</button> : null}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
}));
|
||||
|
|
@ -144,4 +163,75 @@ describe("CommentThread", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders linked approvals inline in the timeline", () => {
|
||||
const root = createRoot(container);
|
||||
const agent: Agent = {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
urlKey: "codexcoder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: "code",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
};
|
||||
const approval: Approval = {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {
|
||||
title: "Approve hosting spend",
|
||||
text: "Estimated monthly cost is $42.",
|
||||
},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-03-11T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T09:00:00.000Z"),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[]}
|
||||
linkedApprovals={[approval]}
|
||||
agentMap={new Map([["agent-1", agent]])}
|
||||
onAdd={async () => {}}
|
||||
onApproveApproval={async () => {}}
|
||||
onRejectApproval={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const approvalRow = container.querySelector("#approval-approval-1") as HTMLDivElement | null;
|
||||
expect(approvalRow).not.toBeNull();
|
||||
expect(container.textContent).toContain("request_board_approval");
|
||||
expect(container.textContent).toContain("Approve hosting spend");
|
||||
expect(container.textContent).toContain("Approve");
|
||||
expect(container.textContent).toContain("Reject");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
|
|||
import { Link, useLocation } from "react-router-dom";
|
||||
import type {
|
||||
Agent,
|
||||
Approval,
|
||||
FeedbackDataSharingPreference,
|
||||
FeedbackVote,
|
||||
FeedbackVoteValue,
|
||||
|
|
@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
|||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { ApprovalCard } from "./ApprovalCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
|
|
@ -50,6 +51,7 @@ interface CommentReassignment {
|
|||
interface CommentThreadProps {
|
||||
comments: CommentWithRunMeta[];
|
||||
queuedComments?: CommentWithRunMeta[];
|
||||
linkedApprovals?: Approval[];
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl?: string | null;
|
||||
|
|
@ -57,6 +59,12 @@ interface CommentThreadProps {
|
|||
timelineEvents?: IssueTimelineEvent[];
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||
pendingApprovalAction?: {
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
} | null;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
|
|
@ -375,6 +383,7 @@ function CommentCard({
|
|||
|
||||
type TimelineItem =
|
||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||
| { kind: "approval"; id: string; createdAtMs: number; approval: Approval }
|
||||
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
|
||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||
|
||||
|
|
@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({
|
|||
currentUserId,
|
||||
companyId,
|
||||
projectId,
|
||||
onApproveApproval,
|
||||
onRejectApproval,
|
||||
pendingApprovalAction,
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
|
|
@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({
|
|||
currentUserId?: string | null;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||
pendingApprovalAction?: {
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
} | null;
|
||||
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
|
||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl?: string | null;
|
||||
|
|
@ -488,6 +506,24 @@ const TimelineList = memo(function TimelineList({
|
|||
);
|
||||
}
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approval = item.approval;
|
||||
const isPending = pendingApprovalAction?.approvalId === approval.id;
|
||||
return (
|
||||
<div id={`approval-${approval.id}`} key={`approval:${approval.id}`} className="py-1.5">
|
||||
<ApprovalCard
|
||||
approval={approval}
|
||||
requesterAgent={approval.requestedByAgentId ? agentMap?.get(approval.requestedByAgentId) ?? null : null}
|
||||
onApprove={onApproveApproval ? () => void onApproveApproval(approval.id) : undefined}
|
||||
onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={isPending}
|
||||
pendingAction={isPending ? pendingApprovalAction?.action ?? null : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "run") {
|
||||
const run = item.run;
|
||||
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
|
|
@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({
|
|||
export function CommentThread({
|
||||
comments,
|
||||
queuedComments = [],
|
||||
linkedApprovals = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
|
|
@ -555,6 +592,9 @@ export function CommentThread({
|
|||
timelineEvents = [],
|
||||
companyId,
|
||||
projectId,
|
||||
onApproveApproval,
|
||||
onRejectApproval,
|
||||
pendingApprovalAction = null,
|
||||
onVote,
|
||||
onAdd,
|
||||
agentMap,
|
||||
|
|
@ -593,6 +633,12 @@ export function CommentThread({
|
|||
createdAtMs: new Date(comment.createdAt).getTime(),
|
||||
comment,
|
||||
}));
|
||||
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
|
||||
kind: "approval",
|
||||
id: approval.id,
|
||||
createdAtMs: new Date(approval.createdAt).getTime(),
|
||||
approval,
|
||||
}));
|
||||
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
|
||||
kind: "event",
|
||||
id: event.id,
|
||||
|
|
@ -605,17 +651,18 @@ export function CommentThread({
|
|||
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
||||
run,
|
||||
}));
|
||||
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
|
||||
return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => {
|
||||
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
||||
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
||||
const kindOrder = {
|
||||
event: 0,
|
||||
comment: 1,
|
||||
run: 2,
|
||||
approval: 1,
|
||||
comment: 2,
|
||||
run: 3,
|
||||
} as const;
|
||||
return kindOrder[a.kind] - kindOrder[b.kind];
|
||||
});
|
||||
}, [comments, timelineEvents, linkedRuns]);
|
||||
}, [comments, linkedApprovals, timelineEvents, linkedRuns]);
|
||||
|
||||
const feedbackVoteByTargetId = useMemo(() => {
|
||||
const map = new Map<string, FeedbackVoteValue>();
|
||||
|
|
@ -754,6 +801,9 @@ export function CommentThread({
|
|||
currentUserId={currentUserId}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
onApproveApproval={onApproveApproval}
|
||||
onRejectApproval={onRejectApproval}
|
||||
pendingApprovalAction={pendingApprovalAction}
|
||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
onVote={onVote ? handleFeedbackVote : undefined}
|
||||
|
|
|
|||
265
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>
|
||||
);
|
||||
}
|
||||
252
ui/src/components/EnvVarEditor.tsx
Normal file
252
ui/src/components/EnvVarEditor.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
type Row = {
|
||||
key: string;
|
||||
source: "plain" | "secret";
|
||||
plainValue: string;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") {
|
||||
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
}
|
||||
const entries = Object.entries(rec).map(([key, binding]) => {
|
||||
if (typeof binding === "string") {
|
||||
return { key, source: "plain" as const, plainValue: binding, secretId: "" };
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "secret_ref"
|
||||
) {
|
||||
const record = binding as { secretId?: unknown };
|
||||
return {
|
||||
key,
|
||||
source: "secret" as const,
|
||||
plainValue: "",
|
||||
secretId: typeof record.secretId === "string" ? record.secretId : "",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "plain"
|
||||
) {
|
||||
const record = binding as { value?: unknown };
|
||||
return {
|
||||
key,
|
||||
source: "plain" as const,
|
||||
plainValue: typeof record.value === "string" ? record.value : "",
|
||||
secretId: "",
|
||||
};
|
||||
}
|
||||
return { key, source: "plain" as const, plainValue: "", secretId: "" };
|
||||
});
|
||||
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
}
|
||||
|
||||
export function EnvVarEditor({
|
||||
value,
|
||||
secrets,
|
||||
onCreateSecret,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, EnvBinding>;
|
||||
secrets: CompanySecret[];
|
||||
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
|
||||
onChange: (env: Record<string, EnvBinding> | undefined) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(value));
|
||||
const [sealError, setSealError] = useState<string | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
const emittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (emittingRef.current) {
|
||||
emittingRef.current = false;
|
||||
valueRef.current = value;
|
||||
return;
|
||||
}
|
||||
if (value !== valueRef.current) {
|
||||
valueRef.current = value;
|
||||
setRows(toRows(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function emit(nextRows: Row[]) {
|
||||
const rec: Record<string, EnvBinding> = {};
|
||||
for (const row of nextRows) {
|
||||
const key = row.key.trim();
|
||||
if (!key) continue;
|
||||
if (row.source === "secret") {
|
||||
if (row.secretId) {
|
||||
rec[key] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
|
||||
} else {
|
||||
rec[key] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
} else {
|
||||
rec[key] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
}
|
||||
emittingRef.current = true;
|
||||
onChange(Object.keys(rec).length > 0 ? rec : undefined);
|
||||
}
|
||||
|
||||
function updateRow(index: number, patch: Partial<Row>) {
|
||||
const withPatch = rows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
|
||||
if (
|
||||
withPatch[withPatch.length - 1].key ||
|
||||
withPatch[withPatch.length - 1].plainValue ||
|
||||
withPatch[withPatch.length - 1].secretId
|
||||
) {
|
||||
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
}
|
||||
setRows(withPatch);
|
||||
emit(withPatch);
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
const next = rows.filter((_, rowIndex) => rowIndex !== index);
|
||||
if (
|
||||
next.length === 0 ||
|
||||
next[next.length - 1].key ||
|
||||
next[next.length - 1].plainValue ||
|
||||
next[next.length - 1].secretId
|
||||
) {
|
||||
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
function defaultSecretName(key: string) {
|
||||
return key
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
async function sealRow(index: number) {
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
const key = row.key.trim();
|
||||
const plain = row.plainValue;
|
||||
if (!key || plain.length === 0) return;
|
||||
|
||||
const suggested = defaultSecretName(key) || "secret";
|
||||
const name = window.prompt("Secret name", suggested)?.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
setSealError(null);
|
||||
const created = await onCreateSecret(name, plain);
|
||||
updateRow(index, { source: "secret", secretId: created.id });
|
||||
} catch (error) {
|
||||
setSealError(error instanceof Error ? error.message : "Failed to create secret");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, index) => {
|
||||
const isTrailing =
|
||||
index === rows.length - 1 &&
|
||||
!row.key &&
|
||||
!row.plainValue &&
|
||||
!row.secretId;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className={cn(inputClass, "flex-[2]")}
|
||||
placeholder="KEY"
|
||||
value={row.key}
|
||||
onChange={(event) => updateRow(index, { key: event.target.value })}
|
||||
/>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[1] bg-background")}
|
||||
value={row.source}
|
||||
onChange={(event) =>
|
||||
updateRow(index, {
|
||||
source: event.target.value === "secret" ? "secret" : "plain",
|
||||
...(event.target.value === "plain" ? { secretId: "" } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="plain">Plain</option>
|
||||
<option value="secret">Secret</option>
|
||||
</select>
|
||||
{row.source === "secret" ? (
|
||||
<>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[3] bg-background")}
|
||||
value={row.secretId}
|
||||
onChange={(event) => updateRow(index, { secretId: event.target.value })}
|
||||
>
|
||||
<option value="">Select secret...</option>
|
||||
{secrets.map((secret) => (
|
||||
<option key={secret.id} value={secret.id}>
|
||||
{secret.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={() => sealRow(index)}
|
||||
disabled={!row.key.trim() || !row.plainValue}
|
||||
title="Create secret from current plain value"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="value"
|
||||
value={row.plainValue}
|
||||
onChange={(event) => updateRow(index, { plainValue: event.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={() => sealRow(index)}
|
||||
disabled={!row.key.trim() || !row.plainValue}
|
||||
title="Store value as secret and replace with reference"
|
||||
>
|
||||
Seal
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isTrailing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => removeRow(index)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[26px] shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
PAPERCLIP_* variables are injected automatically at runtime.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ interface InlineEditorProps {
|
|||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
/** Called when a non-image file is dropped onto the editor. */
|
||||
onDropFile?: (file: File) => Promise<void>;
|
||||
mentions?: MentionOption[];
|
||||
nullable?: boolean;
|
||||
}
|
||||
|
|
@ -46,6 +48,7 @@ export function InlineEditor({
|
|||
multiline = false,
|
||||
nullable = false,
|
||||
imageUploadHandler,
|
||||
onDropFile,
|
||||
mentions,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
|
@ -228,6 +231,7 @@ export function InlineEditor({
|
|||
className="bg-transparent"
|
||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onDropFile={onDropFile}
|
||||
mentions={mentions}
|
||||
onSubmit={() => {
|
||||
finalizeMultilineBlurOrSubmit();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
import { DocumentDiffModal } from "./DocumentDiffModal";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
|
|
@ -162,6 +163,7 @@ export function IssueDocumentsSection({
|
|||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
|
|
@ -929,6 +931,12 @@ export function IssueDocumentsSection({
|
|||
<Download className="h-3.5 w-3.5" />
|
||||
Download document
|
||||
</DropdownMenuItem>
|
||||
{doc.latestRevisionNumber > 1 ? (
|
||||
<DropdownMenuItem onClick={() => setDiffViewKey(doc.key)}>
|
||||
<Diff className="h-3.5 w-3.5" />
|
||||
View diff
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||
{canDeleteDocuments ? (
|
||||
<DropdownMenuItem
|
||||
|
|
@ -1174,6 +1182,20 @@ export function IssueDocumentsSection({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{diffViewKey && (() => {
|
||||
const diffDoc = sortedDocuments.find((d) => d.key === diffViewKey);
|
||||
if (!diffDoc) return null;
|
||||
return (
|
||||
<DocumentDiffModal
|
||||
issueId={issue.id}
|
||||
documentKey={diffDoc.key}
|
||||
latestRevisionNumber={diffDoc.latestRevisionNumber}
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setDiffViewKey(null); }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
204
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 { Link } from "@/lib/router";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
|
@ -19,9 +19,40 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
|||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* noop */ }
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<span className="text-sm font-mono min-w-0 break-all">
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied!" : "Copy"}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultProjectWorkspaceIdForProject(project: {
|
||||
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
||||
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
||||
|
|
@ -42,6 +73,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
|||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
childIssues?: Issue[];
|
||||
onAddSubIssue?: () => void;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
|
@ -117,7 +150,13 @@ function PropertyPicker({
|
|||
);
|
||||
}
|
||||
|
||||
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
||||
export function IssueProperties({
|
||||
issue,
|
||||
childIssues = [],
|
||||
onAddSubIssue,
|
||||
onUpdate,
|
||||
inline,
|
||||
}: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
|
|
@ -683,6 +722,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
)}
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Sub-issues">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{childIssues.length > 0 ? (
|
||||
childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||
>
|
||||
{child.identifier ?? child.title}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
{onAddSubIssue ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={onAddSubIssue}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add sub-issue
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
|
|
@ -700,6 +767,30 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
)}
|
||||
</div>
|
||||
|
||||
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{issue.currentExecutionWorkspace?.branchName && (
|
||||
<PropertyRow label="Branch">
|
||||
<TruncatedCopyable
|
||||
value={issue.currentExecutionWorkspace.branchName}
|
||||
icon={GitBranch}
|
||||
/>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.currentExecutionWorkspace?.cwd && (
|
||||
<PropertyRow label="Folder">
|
||||
<TruncatedCopyable
|
||||
value={issue.currentExecutionWorkspace.cwd}
|
||||
icon={FolderOpen}
|
||||
/>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -128,9 +128,7 @@ describe("IssueRow", () => {
|
|||
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
|
||||
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
|
||||
);
|
||||
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toBe("/issues/PAP-1");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { X } from "lucide-react";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
|
|
@ -51,9 +51,10 @@ export function IssueRow({
|
|||
|
||||
return (
|
||||
<Link
|
||||
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
state={issueLinkState}
|
||||
data-inbox-issue-link
|
||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
|
|
|
|||
187
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;
|
||||
}
|
||||
|
||||
function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean {
|
||||
if (!normalizedSearch) return true;
|
||||
|
||||
return [
|
||||
issue.identifier,
|
||||
issue.title,
|
||||
issue.description,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
interface Agent {
|
||||
|
|
@ -278,12 +266,10 @@ export function IssuesList({
|
|||
}, [agents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = normalizedIssueSearch.length > 0
|
||||
? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch))
|
||||
: issues;
|
||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||
return sortIssues(filteredByControls, viewState);
|
||||
}, [issues, viewState, normalizedIssueSearch, currentUserId]);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
|
||||
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
|
||||
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyReset: false,
|
||||
|
|
@ -186,4 +186,31 @@ describe("MarkdownEditor", () => {
|
|||
left: 92,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a short mention menu on the same line when it fits below the caret", () => {
|
||||
expect(
|
||||
computeMentionMenuPosition(
|
||||
{ viewportTop: 160, viewportLeft: 120 },
|
||||
{ offsetLeft: 0, offsetTop: 0, width: 320, height: 220 },
|
||||
{ width: 188, height: 42 },
|
||||
),
|
||||
).toEqual({
|
||||
top: 164,
|
||||
left: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mention queries active across spaces", () => {
|
||||
expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({
|
||||
trigger: "mention",
|
||||
marker: "@",
|
||||
query: "Paperclip App",
|
||||
atPos: 5,
|
||||
endPos: "Ping @Paperclip App".length,
|
||||
});
|
||||
});
|
||||
|
||||
it("still rejects slash commands once spaces are typed", () => {
|
||||
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ interface MarkdownEditorProps {
|
|||
contentClassName?: string;
|
||||
onBlur?: () => void;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
|
||||
onDropFile?: (file: File) => Promise<void>;
|
||||
bordered?: boolean;
|
||||
/** List of mentionable entities. Enables @-mention autocomplete. */
|
||||
mentions?: MentionOption[];
|
||||
|
|
@ -108,9 +110,16 @@ interface MentionMenuViewport {
|
|||
height: number;
|
||||
}
|
||||
|
||||
interface MentionMenuSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const MENTION_MENU_WIDTH = 188;
|
||||
const MENTION_MENU_HEIGHT = 208;
|
||||
const MENTION_MENU_PADDING = 8;
|
||||
const MENTION_MENU_ROW_HEIGHT = 34;
|
||||
const MENTION_MENU_CHROME_HEIGHT = 8;
|
||||
|
||||
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||
txt: "Text",
|
||||
|
|
@ -140,19 +149,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
|
|||
Editor: CodeMirrorEditor,
|
||||
};
|
||||
|
||||
function detectMention(container: HTMLElement): MentionState | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const textNode = range.startContainer;
|
||||
if (textNode.nodeType !== Node.TEXT_NODE) return null;
|
||||
if (!container.contains(textNode)) return null;
|
||||
|
||||
const text = textNode.textContent ?? "";
|
||||
const offset = range.startOffset;
|
||||
|
||||
// Walk backwards from cursor to find an autocomplete trigger.
|
||||
export function findMentionMatch(
|
||||
text: string,
|
||||
offset: number,
|
||||
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | null {
|
||||
let atPos = -1;
|
||||
let trigger: MentionState["trigger"] | null = null;
|
||||
let marker: MentionState["marker"] | null = null;
|
||||
|
|
@ -166,31 +166,54 @@ function detectMention(container: HTMLElement): MentionState | null {
|
|||
}
|
||||
break;
|
||||
}
|
||||
if (/\s/.test(ch)) break;
|
||||
if (ch === "\n" || ch === "\r") break;
|
||||
}
|
||||
|
||||
if (atPos === -1) return null;
|
||||
|
||||
const query = text.slice(atPos + 1, offset);
|
||||
|
||||
// Get position relative to container
|
||||
const tempRange = document.createRange();
|
||||
tempRange.setStart(textNode, atPos);
|
||||
tempRange.setEnd(textNode, atPos + 1);
|
||||
const rect = tempRange.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
if (trigger === "skill" && /\s/.test(query)) return null;
|
||||
|
||||
return {
|
||||
trigger: trigger ?? "mention",
|
||||
marker: marker ?? "@",
|
||||
query,
|
||||
atPos,
|
||||
endPos: offset,
|
||||
};
|
||||
}
|
||||
|
||||
function detectMention(container: HTMLElement): MentionState | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const textNode = range.startContainer;
|
||||
if (textNode.nodeType !== Node.TEXT_NODE) return null;
|
||||
if (!container.contains(textNode)) return null;
|
||||
|
||||
const text = textNode.textContent ?? "";
|
||||
const offset = range.startOffset;
|
||||
const match = findMentionMatch(text, offset);
|
||||
if (!match) return null;
|
||||
|
||||
// Get position relative to container
|
||||
const tempRange = document.createRange();
|
||||
tempRange.setStart(textNode, match.atPos);
|
||||
tempRange.setEnd(textNode, match.atPos + 1);
|
||||
const rect = tempRange.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
trigger: match.trigger,
|
||||
marker: match.marker,
|
||||
query: match.query,
|
||||
top: rect.bottom - containerRect.top,
|
||||
left: rect.left - containerRect.left,
|
||||
viewportTop: rect.bottom,
|
||||
viewportLeft: rect.left,
|
||||
textNode: textNode as Text,
|
||||
atPos,
|
||||
endPos: offset,
|
||||
atPos: match.atPos,
|
||||
endPos: match.endPos,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -216,11 +239,12 @@ function getMentionMenuViewport(): MentionMenuViewport {
|
|||
export function computeMentionMenuPosition(
|
||||
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
|
||||
viewport: MentionMenuViewport,
|
||||
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
|
||||
) {
|
||||
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
|
||||
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH;
|
||||
const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width;
|
||||
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
|
||||
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
|
||||
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
|
||||
|
||||
return {
|
||||
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
|
||||
|
|
@ -228,6 +252,17 @@ export function computeMentionMenuPosition(
|
|||
};
|
||||
}
|
||||
|
||||
function getMentionMenuSize(optionCount: number): MentionMenuSize {
|
||||
const visibleRows = Math.max(1, Math.min(optionCount, 8));
|
||||
return {
|
||||
width: MENTION_MENU_WIDTH,
|
||||
height: Math.min(
|
||||
MENTION_MENU_HEIGHT,
|
||||
visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||
if (!node || !container.contains(node)) return false;
|
||||
const el = node.nodeType === Node.ELEMENT_NODE
|
||||
|
|
@ -281,6 +316,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
contentClassName,
|
||||
onBlur,
|
||||
imageUploadHandler,
|
||||
onDropFile,
|
||||
bordered = true,
|
||||
mentions,
|
||||
onSubmit,
|
||||
|
|
@ -635,6 +671,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
}
|
||||
|
||||
const canDropImage = Boolean(imageUploadHandler);
|
||||
const canDropFile = Boolean(imageUploadHandler || onDropFile);
|
||||
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || !ref.current) return;
|
||||
|
|
@ -650,7 +687,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
}, []);
|
||||
|
||||
const mentionMenuPosition = mentionState
|
||||
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
|
||||
? computeMentionMenuPosition(
|
||||
mentionState,
|
||||
getMentionMenuViewport(),
|
||||
getMentionMenuSize(filteredMentions.length),
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
|
@ -673,8 +714,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
|
||||
// Mention keyboard handling
|
||||
if (mentionActive) {
|
||||
// Space dismisses the popup (let the character be typed normally)
|
||||
if (e.key === " ") {
|
||||
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
|
||||
mentionStateRef.current = null;
|
||||
setMentionState(null);
|
||||
return;
|
||||
|
|
@ -711,23 +751,41 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
}
|
||||
}}
|
||||
onDragEnter={(evt) => {
|
||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
||||
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragOver={(evt) => {
|
||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
||||
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (!canDropImage) return;
|
||||
if (!canDropFile) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||
}}
|
||||
onDrop={() => {
|
||||
onDrop={(evt) => {
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
if (!onDropFile) return;
|
||||
const files = evt.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
const allFiles = Array.from(files);
|
||||
const nonImageFiles = allFiles.filter(
|
||||
(f) => !f.type.startsWith("image/"),
|
||||
);
|
||||
if (nonImageFiles.length === 0) return;
|
||||
// If all dropped files are non-image, prevent default so MDXEditor
|
||||
// doesn't try to handle them. If mixed, let images flow through to
|
||||
// the image plugin and only handle the non-image files ourselves.
|
||||
if (nonImageFiles.length === allFiles.length) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
for (const file of nonImageFiles) {
|
||||
void onDropFile(file);
|
||||
}
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
|
|
@ -818,14 +876,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
document.body,
|
||||
)}
|
||||
|
||||
{isDragOver && canDropImage && (
|
||||
{isDragOver && canDropFile && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
|
||||
!bordered && "inset-0 rounded-sm",
|
||||
)}
|
||||
>
|
||||
Drop image to upload
|
||||
Drop {onDropFile ? "file" : "image"} to upload
|
||||
</div>
|
||||
)}
|
||||
{uploadError && (
|
||||
|
|
|
|||
439
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,
|
||||
FileText,
|
||||
Loader2,
|
||||
ListTree,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -297,6 +298,11 @@ export function NewIssueDialog() {
|
|||
|
||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||
const isSubIssueMode = Boolean(newIssueDefaults.parentId);
|
||||
const parentIssueLabel = newIssueDefaults.parentIdentifier
|
||||
?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : "");
|
||||
const parentExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId ?? "";
|
||||
const parentExecutionWorkspaceLabel = newIssueDefaults.parentExecutionWorkspaceLabel ?? parentExecutionWorkspaceId;
|
||||
|
||||
// Popover states
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
|
|
@ -510,7 +516,28 @@ export function NewIssueDialog() {
|
|||
executionWorkspaceDefaultProjectId.current = null;
|
||||
|
||||
const draft = loadDraft();
|
||||
if (newIssueDefaults.title) {
|
||||
if (newIssueDefaults.parentId) {
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
||||
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
||||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setTitle(newIssueDefaults.title ?? "");
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceId);
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceMode);
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
} else if (newIssueDefaults.title) {
|
||||
setTitle(newIssueDefaults.title);
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
|
|
@ -616,6 +643,7 @@ export function NewIssueDialog() {
|
|||
}
|
||||
|
||||
function handleCompanyChange(companyId: string) {
|
||||
if (isSubIssueMode) return;
|
||||
if (companyId === effectiveCompanyId) return;
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeValue("");
|
||||
|
|
@ -666,6 +694,8 @@ export function NewIssueDialog() {
|
|||
priority: priority || "medium",
|
||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
||||
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
|
|
@ -774,6 +804,13 @@ export function NewIssueDialog() {
|
|||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||
);
|
||||
const isUsingParentExecutionWorkspace = isSubIssueMode && parentExecutionWorkspaceId
|
||||
? executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId === parentExecutionWorkspaceId
|
||||
: false;
|
||||
const showParentWorkspaceWarning = isSubIssueMode
|
||||
&& currentProjectSupportsExecutionWorkspace
|
||||
&& Boolean(parentExecutionWorkspaceId)
|
||||
&& !isUsingParentExecutionWorkspace;
|
||||
const assigneeOptionsTitle =
|
||||
assigneeAdapterType === "claude_local"
|
||||
? "Claude options"
|
||||
|
|
@ -908,6 +945,7 @@ export function NewIssueDialog() {
|
|||
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||
!dialogCompany?.brandColor && "bg-muted",
|
||||
)}
|
||||
disabled={isSubIssueMode}
|
||||
style={
|
||||
dialogCompany?.brandColor
|
||||
? {
|
||||
|
|
@ -955,7 +993,7 @@ export function NewIssueDialog() {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="text-muted-foreground/60">›</span>
|
||||
<span>New issue</span>
|
||||
<span>{isSubIssueMode ? "New sub-issue" : "New issue"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
|
@ -1119,6 +1157,23 @@ export function NewIssueDialog() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isSubIssueMode ? (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="shrink-0">Sub-issue of</span>
|
||||
<span className="font-medium text-foreground">{parentIssueLabel}</span>
|
||||
</div>
|
||||
{newIssueDefaults.parentTitle ? (
|
||||
<div className="pl-5 text-foreground/80 truncate">
|
||||
{newIssueDefaults.parentTitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -1161,6 +1216,11 @@ export function NewIssueDialog() {
|
|||
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
||||
</div>
|
||||
)}
|
||||
{showParentWorkspaceWarning ? (
|
||||
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
Warning: this sub-issue will no longer use the parent issue workspace{parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1455,7 +1515,7 @@ export function NewIssueDialog() {
|
|||
>
|
||||
<span className="inline-flex items-center justify-center gap-1.5">
|
||||
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
|
||||
<span>{createIssue.isPending ? "Creating..." : isSubIssueMode ? "Create Sub-Issue" : "Create Issue"}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { cn, formatDate } from "../lib/utils";
|
|||
import { goalsApi } from "../api/goals";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { secretsApi } from "../api/secrets";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||
|
|
@ -19,6 +20,7 @@ import { ChoosePathButton } from "./PathInstructionsModal";
|
|||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
import { EnvVarEditor } from "./EnvVarEditor";
|
||||
|
||||
const PROJECT_STATUSES = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
|
|
@ -43,6 +45,7 @@ export type ProjectConfigFieldKey =
|
|||
| "description"
|
||||
| "status"
|
||||
| "goals"
|
||||
| "env"
|
||||
| "execution_workspace_enabled"
|
||||
| "execution_workspace_default_mode"
|
||||
| "execution_workspace_base_ref"
|
||||
|
|
@ -245,6 +248,21 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const createSecret = useMutation({
|
||||
mutationFn: (input: { name: string; value: string }) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
|
||||
return secretsApi.create(selectedCompanyId, input);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const linkedGoalIds = project.goalIds.length > 0
|
||||
? project.goalIds
|
||||
|
|
@ -583,6 +601,26 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
</Popover>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow
|
||||
label={<FieldLabel label="Env" state={fieldState("env")} />}
|
||||
alignStart
|
||||
valueClassName="space-y-2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<EnvVarEditor
|
||||
value={project.env ?? {}}
|
||||
secrets={availableSecrets}
|
||||
onCreateSecret={async (name, value) => {
|
||||
const created = await createSecret.mutateAsync({ name, value });
|
||||
return created;
|
||||
}}
|
||||
onChange={(env) => commitField("env", { env: env ?? null })}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Applied to all runs for issues in this project. Project values override agent env on key conflicts.
|
||||
</p>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
|
||||
<span className="text-sm">{formatDate(project.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ function createProject(): Project {
|
|||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
archivedAt: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function resolveScrollTarget() {
|
||||
const mainContent = document.getElementById("main-content");
|
||||
|
|
@ -33,6 +35,7 @@ function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
|
|||
*/
|
||||
export function ScrollToBottom() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { panelVisible, panelContent } = usePanel();
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
|
|
@ -70,7 +73,10 @@ export function ScrollToBottom() {
|
|||
return (
|
||||
<button
|
||||
onClick={scroll}
|
||||
className="fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors md:bottom-6"
|
||||
className={cn(
|
||||
"fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-[background-color,right] duration-200 md:bottom-6",
|
||||
panelVisible && panelContent && "md:right-[calc(320px+1.5rem)]",
|
||||
)}
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ interface NewIssueDefaults {
|
|||
status?: string;
|
||||
priority?: string;
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
goalId?: string;
|
||||
parentId?: string;
|
||||
parentIdentifier?: string;
|
||||
parentTitle?: string;
|
||||
executionWorkspaceId?: string;
|
||||
executionWorkspaceMode?: string;
|
||||
parentExecutionWorkspaceLabel?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
title?: string;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function makeProject(id: string, name: string): Project {
|
|||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
|
|
|
|||
|
|
@ -20,4 +20,29 @@ describe("company routes", () => {
|
|||
"/execution-workspaces/workspace-123",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for https://github.com/paperclipai/paperclip/issues/2910
|
||||
*
|
||||
* The Export and Import links on the Company Settings page used plain
|
||||
* `<a href="/company/export">` anchors which bypass the router's Link
|
||||
* wrapper. Without the wrapper, the company prefix is never applied and
|
||||
* the links resolve to `/company/export` instead of `/:prefix/company/export`,
|
||||
* producing a "Company not found" error.
|
||||
*
|
||||
* The fix replaces the `<a>` elements with the prefix-aware `<Link>` from
|
||||
* `@/lib/router`. These tests assert that the underlying `applyCompanyPrefix`
|
||||
* utility (used by that Link) correctly rewrites the export/import paths.
|
||||
*/
|
||||
it("applies company prefix to /company/export", () => {
|
||||
expect(applyCompanyPrefix("/company/export", "PAP")).toBe("/PAP/company/export");
|
||||
});
|
||||
|
||||
it("applies company prefix to /company/import", () => {
|
||||
expect(applyCompanyPrefix("/company/import", "PAP")).toBe("/PAP/company/import");
|
||||
});
|
||||
|
||||
it("does not double-apply the prefix if already present", () => {
|
||||
expect(applyCompanyPrefix("/PAP/company/export", "PAP")).toBe("/PAP/company/export");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -515,13 +515,14 @@ describe("inbox helpers", () => {
|
|||
});
|
||||
|
||||
it("hides the workspace column option unless isolated workspaces are enabled", () => {
|
||||
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "labels", "updated"]);
|
||||
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "parent", "labels", "updated"]);
|
||||
expect(getAvailableInboxIssueColumns(true)).toEqual([
|
||||
"status",
|
||||
"id",
|
||||
"assignee",
|
||||
"project",
|
||||
"workspace",
|
||||
"parent",
|
||||
"labels",
|
||||
"updated",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
|||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "labels", "updated"] as const;
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||
export type InboxWorkItem =
|
||||
|
|
|
|||
|
|
@ -3,50 +3,80 @@ import {
|
|||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
hasLegacyIssueDetailQuery,
|
||||
readIssueDetailLocationState,
|
||||
readIssueDetailBreadcrumb,
|
||||
rememberIssueDetailLocationState,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
|
||||
const sessionStorageMock = (() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { sessionStorage: sessionStorageMock },
|
||||
});
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
it("returns clean issue detail paths", () => {
|
||||
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
|
||||
});
|
||||
|
||||
it("prefers the full breadcrumb from route state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
|
||||
expect(readIssueDetailBreadcrumb("PAP-465", state, "?from=issues")).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/inbox/mine",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the source query param when route state is unavailable", () => {
|
||||
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
|
||||
expect(readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox")).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/inbox",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the source query param when building an issue detail path", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe(
|
||||
"/issues/PAP-465?from=inbox&fromHref=%2Finbox%2Fmine",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the current source query param when state has been dropped", () => {
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
|
||||
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
|
||||
);
|
||||
it("can detect legacy query-based breadcrumb links", () => {
|
||||
expect(hasLegacyIssueDetailQuery("?from=inbox&fromHref=%2Finbox%2Fmine")).toBe(true);
|
||||
expect(hasLegacyIssueDetailQuery("?q=test")).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the exact breadcrumb href from the query fallback", () => {
|
||||
expect(
|
||||
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||
readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||
).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/PAP/inbox/unread",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads hidden breadcrumb context from session storage when route state is unavailable", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
sessionStorageMock.clear();
|
||||
rememberIssueDetailLocationState("PAP-465", state);
|
||||
|
||||
expect(
|
||||
readIssueDetailLocationState("PAP-465", null),
|
||||
).toEqual({
|
||||
issueDetailBreadcrumb: { label: "Inbox", href: "/inbox/mine" },
|
||||
issueDetailSource: "inbox",
|
||||
issueDetailInboxQuickArchiveArmed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type IssueDetailLocationState = {
|
|||
|
||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
|
||||
const ISSUE_DETAIL_STORAGE_KEY_PREFIX = "paperclip:issue-detail-breadcrumb:";
|
||||
|
||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
|
@ -44,6 +45,17 @@ function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null
|
|||
return href && href.startsWith("/") ? href : null;
|
||||
}
|
||||
|
||||
function inferIssueDetailSource(
|
||||
state: Partial<IssueDetailLocationState> | null,
|
||||
breadcrumb: IssueDetailBreadcrumb | null,
|
||||
): IssueDetailSource | null {
|
||||
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
|
||||
if (!breadcrumb) return null;
|
||||
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
|
||||
if (breadcrumb.label === "Issues" || breadcrumb.href.includes("/issues")) return "issues";
|
||||
return null;
|
||||
}
|
||||
|
||||
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||
return { label: "Issues", href: "/issues" };
|
||||
|
|
@ -71,34 +83,97 @@ export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLoca
|
|||
};
|
||||
}
|
||||
|
||||
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
|
||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
||||
const breadcrumb =
|
||||
(typeof state === "object" && state !== null
|
||||
? (state as IssueDetailLocationState).issueDetailBreadcrumb
|
||||
: null);
|
||||
const breadcrumbHref =
|
||||
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
|
||||
readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
if (!source) return `/issues/${issuePathId}`;
|
||||
const params = new URLSearchParams();
|
||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
||||
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
|
||||
return `/issues/${issuePathId}?${params.toString()}`;
|
||||
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) return null;
|
||||
|
||||
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
|
||||
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
|
||||
? parsed.issueDetailBreadcrumb
|
||||
: null;
|
||||
const source = inferIssueDetailSource(parsed, breadcrumb);
|
||||
if (!breadcrumb || !source) return null;
|
||||
return {
|
||||
issueDetailBreadcrumb: breadcrumb,
|
||||
issueDetailSource: source,
|
||||
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
|
||||
function normalizeIssueDetailLocationState(
|
||||
state: unknown,
|
||||
search?: string,
|
||||
): IssueDetailLocationState | null {
|
||||
if (typeof state === "object" && state !== null) {
|
||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||
if (isIssueDetailBreadcrumb(candidate)) return candidate;
|
||||
if (isIssueDetailBreadcrumb(candidate)) {
|
||||
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
|
||||
if (!source) return null;
|
||||
return {
|
||||
issueDetailBreadcrumb: candidate,
|
||||
issueDetailSource: source,
|
||||
issueDetailInboxQuickArchiveArmed:
|
||||
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const source = readIssueDetailSourceFromSearch(search);
|
||||
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
if (!source) return null;
|
||||
|
||||
const fallback = breadcrumbForSource(source);
|
||||
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
return href ? { ...fallback, href } : fallback;
|
||||
return {
|
||||
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
|
||||
issueDetailSource: source,
|
||||
issueDetailInboxQuickArchiveArmed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function rememberIssueDetailLocationState(issuePathId: string, state: unknown, search?: string): void {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) return;
|
||||
|
||||
const normalized = normalizeIssueDetailLocationState(state, search);
|
||||
if (!normalized) return;
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`,
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
export function createIssueDetailPath(issuePathId: string): string {
|
||||
return `/issues/${issuePathId}`;
|
||||
}
|
||||
|
||||
export function hasLegacyIssueDetailQuery(search?: string): boolean {
|
||||
if (!search) return false;
|
||||
const params = new URLSearchParams(search);
|
||||
return params.has(ISSUE_DETAIL_SOURCE_QUERY_PARAM) || params.has(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
|
||||
}
|
||||
|
||||
export function readIssueDetailLocationState(
|
||||
issuePathId: string | null | undefined,
|
||||
state: unknown,
|
||||
search?: string,
|
||||
): IssueDetailLocationState | null {
|
||||
const normalized = normalizeIssueDetailLocationState(state, search);
|
||||
if (normalized) return normalized;
|
||||
if (!issuePathId) return null;
|
||||
return readStoredIssueDetailLocationState(issuePathId);
|
||||
}
|
||||
|
||||
export function readIssueDetailBreadcrumb(
|
||||
issuePathId: string | null | undefined,
|
||||
state: unknown,
|
||||
search?: string,
|
||||
): IssueDetailBreadcrumb | null {
|
||||
return readIssueDetailLocationState(issuePathId, state, search)?.issueDetailBreadcrumb ?? null;
|
||||
}
|
||||
|
||||
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
|
||||
|
|
|
|||
|
|
@ -212,4 +212,18 @@ describe("optimistic issue comments", () => {
|
|||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark comments from the active run agent as queued", () => {
|
||||
expect(
|
||||
isQueuedIssueComment({
|
||||
comment: {
|
||||
createdAt: new Date("2026-03-28T16:20:05.000Z"),
|
||||
authorAgentId: "agent-1",
|
||||
},
|
||||
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
|
||||
activeRunAgentId: "agent-1",
|
||||
runId: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,13 +59,20 @@ export function createOptimisticIssueComment(params: {
|
|||
}
|
||||
|
||||
export function isQueuedIssueComment(params: {
|
||||
comment: Pick<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
|
||||
comment: Pick<IssueTimelineComment, "createdAt"> &
|
||||
Partial<Pick<OptimisticIssueComment, "clientStatus">> & {
|
||||
authorAgentId?: string | null;
|
||||
};
|
||||
activeRunStartedAt?: Date | string | null;
|
||||
activeRunAgentId?: string | null;
|
||||
runId?: string | null;
|
||||
interruptedRunId?: string | null;
|
||||
}) {
|
||||
if (params.runId) return false;
|
||||
if (params.interruptedRunId) return false;
|
||||
if (params.comment.authorAgentId && params.activeRunAgentId && params.comment.authorAgentId === params.activeRunAgentId) {
|
||||
return false;
|
||||
}
|
||||
if (params.comment.clientStatus === "queued") return true;
|
||||
if (!params.activeRunStartedAt) return false;
|
||||
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export const queryKeys = {
|
|||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
search: (companyId: string, q: string, projectId?: string) =>
|
||||
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
|
||||
search: (companyId: string, q: string, projectId?: string, limit?: number) =>
|
||||
["issues", companyId, "search", q, projectId ?? "__all-projects__", limit ?? "__no-limit__"] as const,
|
||||
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@ export function Approvals() {
|
|||
onReject={() => rejectMutation.mutate(approval.id)}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
pendingAction={
|
||||
approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
|
@ -548,16 +549,16 @@ export function CompanySettings() {
|
|||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href="/company/export">
|
||||
<Link to="/company/export">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href="/company/import">
|
||||
<Link to="/company/import">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="mx-auto max-w-5xl space-y-4 overflow-hidden sm:space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||
|
|
@ -393,19 +393,20 @@ export function ExecutionWorkspaceDetail() {
|
|||
</StatusPill>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Execution workspace
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay
|
||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
|
||||
<span className="hidden sm:inline"> These settings stay
|
||||
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
||||
and runtime-service behavior in sync with the actual workspace being reused.
|
||||
and runtime-service behavior in sync with the actual workspace being reused.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||
|
|
@ -482,7 +483,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
|
|
@ -490,7 +491,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
</Field>
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
|
|
@ -501,7 +502,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
<div className="mt-4 grid gap-4">
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
|
|
@ -546,14 +547,22 @@ export function ExecutionWorkspaceDetail() {
|
|||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setForm((current) => {
|
||||
if (!current) return current;
|
||||
// When unchecking "inherit" and the field is empty, copy inherited config as a starting point
|
||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||
}
|
||||
return { ...current, inheritRuntime: checked };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="min-h-32 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-48"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
|
|
@ -586,8 +595,8 @@ export function ExecutionWorkspaceDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||
|
|
@ -632,7 +641,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||
|
|
@ -676,7 +685,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
|
|
@ -755,7 +764,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||
|
|
@ -798,7 +807,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
||||
|
|
@ -819,12 +828,12 @@ export function ExecutionWorkspaceDetail() {
|
|||
: "Failed to load linked issues."}
|
||||
</p>
|
||||
) : linkedIssues.length > 0 ? (
|
||||
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
||||
<div className="-mx-1 flex flex-col gap-3 px-1 pb-1 sm:flex-row sm:overflow-x-auto">
|
||||
{linkedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={issueUrl(issue)}
|
||||
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
className="rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20 sm:min-w-72"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
|
|
|
|||
|
|
@ -205,6 +205,8 @@ describe("InboxIssueTrailingColumns", () => {
|
|||
workspaceName={null}
|
||||
assigneeName={null}
|
||||
currentUserId={null}
|
||||
parentIdentifier={null}
|
||||
parentTitle={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
|
@ -229,6 +231,8 @@ describe("InboxIssueTrailingColumns", () => {
|
|||
workspaceName={null}
|
||||
assigneeName={null}
|
||||
currentUserId={null}
|
||||
parentIdentifier={null}
|
||||
parentTitle={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
|
|
@ -140,13 +141,14 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
|||
|
||||
|
||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
||||
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
|
||||
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
|
||||
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||
status: "Status",
|
||||
id: "ID",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
workspace: "Workspace",
|
||||
parent: "Parent issue",
|
||||
labels: "Tags",
|
||||
updated: "Last updated",
|
||||
};
|
||||
|
|
@ -156,6 +158,7 @@ const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
|||
assignee: "Assigned agent or board user.",
|
||||
project: "Linked project pill with its color.",
|
||||
workspace: "Execution or project workspace used for the issue.",
|
||||
parent: "Parent issue identifier and title.",
|
||||
labels: "Issue labels and tags.",
|
||||
updated: "Latest visible activity time.",
|
||||
};
|
||||
|
|
@ -223,8 +226,9 @@ function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
|||
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||
return "minmax(6rem, 7rem)";
|
||||
return "minmax(4rem, 5.5rem)";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -237,6 +241,8 @@ export function InboxIssueTrailingColumns({
|
|||
workspaceName,
|
||||
assigneeName,
|
||||
currentUserId,
|
||||
parentIdentifier,
|
||||
parentTitle,
|
||||
}: {
|
||||
issue: Issue;
|
||||
columns: InboxIssueColumn[];
|
||||
|
|
@ -245,6 +251,8 @@ export function InboxIssueTrailingColumns({
|
|||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
currentUserId: string | null;
|
||||
parentIdentifier: string | null;
|
||||
parentTitle: string | null;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
|
@ -347,6 +355,22 @@ export function InboxIssueTrailingColumns({
|
|||
);
|
||||
}
|
||||
|
||||
if (column === "parent") {
|
||||
if (!issue.parentId) {
|
||||
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
|
||||
{parentIdentifier ? (
|
||||
<span className="font-mono">{parentIdentifier}</span>
|
||||
) : (
|
||||
<span className="italic">Sub-issue</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||
{activityText}
|
||||
|
|
@ -1245,30 +1269,53 @@ export function Inbox() {
|
|||
|
||||
const archiveIssueMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
||||
onMutate: (id) => {
|
||||
onMutate: async (id) => {
|
||||
setActionError(null);
|
||||
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
||||
|
||||
// Cancel in-flight refetches so they don't overwrite our optimistic update
|
||||
const queryKeys_ = [
|
||||
queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||
queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
||||
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
||||
];
|
||||
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
|
||||
|
||||
// Snapshot previous data for rollback
|
||||
const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const);
|
||||
|
||||
// Optimistically remove the issue from all inbox query caches
|
||||
for (const qk of queryKeys_) {
|
||||
queryClient.setQueryData(qk, (old: unknown) => {
|
||||
if (!Array.isArray(old)) return old;
|
||||
return old.filter((issue: { id: string }) => issue.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onError: (err, id) => {
|
||||
onError: (err, id, context) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
||||
setArchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
// Restore previous query data on failure
|
||||
if (context?.previousData) {
|
||||
for (const [qk, data] of context.previousData) {
|
||||
queryClient.setQueryData(qk, data);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, error, id) => {
|
||||
if (error) return;
|
||||
window.setTimeout(() => {
|
||||
setArchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, 500);
|
||||
// Clean up archiving state and refetch to sync with server
|
||||
setArchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1498,7 +1545,8 @@ export function Inbox() {
|
|||
if (item.kind === "issue") {
|
||||
const pathId = item.issue.identifier ?? item.issue.id;
|
||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item.kind === "approval") {
|
||||
act.navigate(`/approvals/${item.approval.id}`);
|
||||
} else if (item.kind === "failed_run") {
|
||||
|
|
@ -1566,7 +1614,19 @@ export function Inbox() {
|
|||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="space-y-2">
|
||||
{/* Search — full-width row on mobile, inline on desktop */}
|
||||
<div className="relative sm:hidden">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-full pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
|
|
@ -1585,14 +1645,14 @@ export function Inbox() {
|
|||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="relative hidden sm:block">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
|
||||
className="h-8 w-[220px] pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
|
|
@ -1601,7 +1661,7 @@ export function Inbox() {
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="hidden h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground sm:inline-flex"
|
||||
>
|
||||
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||
Show / hide columns
|
||||
|
|
@ -1685,6 +1745,7 @@ export function Inbox() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "all" && (
|
||||
|
|
@ -1941,6 +2002,8 @@ export function Inbox() {
|
|||
})}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
|
||||
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -10,6 +11,7 @@ import { agentsApi } from "../api/agents";
|
|||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
|
|
@ -17,8 +19,11 @@ import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../li
|
|||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
hasLegacyIssueDetailQuery,
|
||||
createIssueDetailPath,
|
||||
readIssueDetailLocationState,
|
||||
readIssueDetailBreadcrumb,
|
||||
rememberIssueDetailLocationState,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
||||
|
|
@ -33,6 +38,7 @@ import {
|
|||
} from "../lib/optimistic-issue-comments";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
|
|
@ -44,21 +50,18 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
|||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Activity as ActivityIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
EyeOff,
|
||||
|
|
@ -287,6 +290,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
|||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -297,9 +301,11 @@ export function IssueDetail() {
|
|||
const [copied, setCopied] = useState(false);
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
approvals: false,
|
||||
});
|
||||
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
|
|
@ -375,9 +381,13 @@ export function IssueDetail() {
|
|||
),
|
||||
[activeRun, liveRuns],
|
||||
);
|
||||
const resolvedIssueDetailState = useMemo(
|
||||
() => readIssueDetailLocationState(issueId, location.state, location.search),
|
||||
[issueId, location.state, location.search],
|
||||
);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state, location.search],
|
||||
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[issueId, location.state, location.search],
|
||||
);
|
||||
|
||||
// Filter out runs already shown by the live widget to avoid duplication
|
||||
|
|
@ -484,6 +494,45 @@ export function IssueDetail() {
|
|||
.filter((i) => i.parentId === issue.id)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [allIssues, issue]);
|
||||
const childIssuesPanelKey = useMemo(
|
||||
() => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"),
|
||||
[childIssues],
|
||||
);
|
||||
const issuePanelKey = issue
|
||||
? `${issue.id}:${String(issue.updatedAt)}:${childIssuesPanelKey}`
|
||||
: "";
|
||||
const openNewSubIssue = useCallback(() => {
|
||||
if (!issue) return;
|
||||
openNewIssue({
|
||||
parentId: issue.id,
|
||||
parentIdentifier: issue.identifier ?? undefined,
|
||||
parentTitle: issue.title,
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
goalId: issue.goalId ?? undefined,
|
||||
executionWorkspaceId: issue.executionWorkspaceId ?? undefined,
|
||||
executionWorkspaceMode: issue.executionWorkspaceId ? "reuse_existing" : issue.executionWorkspacePreference ?? undefined,
|
||||
parentExecutionWorkspaceLabel:
|
||||
issue.currentExecutionWorkspace?.name
|
||||
?? issue.currentExecutionWorkspace?.branchName
|
||||
?? issue.currentExecutionWorkspace?.cwd
|
||||
?? issue.executionWorkspaceId
|
||||
?? undefined,
|
||||
});
|
||||
}, [
|
||||
issue?.currentExecutionWorkspace?.branchName,
|
||||
issue?.currentExecutionWorkspace?.cwd,
|
||||
issue?.currentExecutionWorkspace?.name,
|
||||
issue?.executionWorkspaceId,
|
||||
issue?.executionWorkspacePreference,
|
||||
issue?.goalId,
|
||||
issue?.id,
|
||||
issue?.identifier,
|
||||
issue?.projectId,
|
||||
issue?.projectWorkspaceId,
|
||||
issue?.title,
|
||||
openNewIssue,
|
||||
]);
|
||||
|
||||
const commentReassignOptions = useMemo(() => {
|
||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||
|
|
@ -546,6 +595,7 @@ export function IssueDetail() {
|
|||
isQueuedIssueComment({
|
||||
comment: nextComment,
|
||||
activeRunStartedAt,
|
||||
activeRunAgentId: runningIssueRun?.agentId ?? null,
|
||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||
})
|
||||
|
|
@ -650,6 +700,42 @@ export function IssueDetail() {
|
|||
invalidateIssue();
|
||||
},
|
||||
});
|
||||
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||
updateIssue.mutate(data);
|
||||
}, [updateIssue.mutate]);
|
||||
|
||||
const approvalDecision = useMutation({
|
||||
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
|
||||
if (action === "approve") {
|
||||
return approvalsApi.approve(approvalId);
|
||||
}
|
||||
return approvalsApi.reject(approvalId);
|
||||
},
|
||||
onMutate: ({ approvalId, action }) => {
|
||||
setPendingApprovalAction({ approvalId, action });
|
||||
},
|
||||
onSuccess: (_approval, variables) => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({
|
||||
title: variables.action === "approve" ? "Approval approved" : "Approval rejected",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err, variables) => {
|
||||
pushToast({
|
||||
title: variables.action === "approve" ? "Approval failed" : "Rejection failed",
|
||||
body: err instanceof Error ? err.message : "Unable to update approval",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setPendingApprovalAction(null);
|
||||
},
|
||||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||
|
|
@ -967,13 +1053,24 @@ export function IssueDetail() {
|
|||
|
||||
// Redirect to identifier-based URL if navigated via UUID
|
||||
useEffect(() => {
|
||||
const nextState = resolvedIssueDetailState ?? location.state;
|
||||
if (issue?.identifier && issueId !== issue.identifier) {
|
||||
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
|
||||
rememberIssueDetailLocationState(issue.identifier, nextState, location.search);
|
||||
navigate(createIssueDetailPath(issue.identifier), {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
state: nextState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (issueId && hasLegacyIssueDetailQuery(location.search)) {
|
||||
rememberIssueDetailLocationState(issueId, nextState, location.search);
|
||||
navigate(createIssueDetailPath(issueId), {
|
||||
replace: true,
|
||||
state: nextState,
|
||||
});
|
||||
}
|
||||
}, [issue, issueId, navigate, location.state, location.search]);
|
||||
}, [issue, issueId, navigate, location.state, location.search, resolvedIssueDetailState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id) return;
|
||||
|
|
@ -983,13 +1080,20 @@ export function IssueDetail() {
|
|||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
openPanel(
|
||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
||||
);
|
||||
if (!issue) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
openPanel(
|
||||
<IssueProperties
|
||||
issue={issue}
|
||||
childIssues={childIssues}
|
||||
onAddSubIssue={openNewSubIssue}
|
||||
onUpdate={handleIssuePropertiesUpdate}
|
||||
/>
|
||||
);
|
||||
return () => closePanel();
|
||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
|
||||
|
||||
const inboxQuickArchiveArmedRef = useRef(false);
|
||||
const canQuickArchiveFromInbox =
|
||||
|
|
@ -1115,13 +1219,13 @@ export function IssueDetail() {
|
|||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||
const attachmentList = attachments ?? [];
|
||||
const imageAttachments = attachmentList.filter(isImageAttachment);
|
||||
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
|
||||
const hasAttachments = attachmentList.length > 0;
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
multiple
|
||||
|
|
@ -1156,8 +1260,14 @@ export function IssueDetail() {
|
|||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
|
||||
state={resolvedIssueDetailState ?? location.state}
|
||||
onClickCapture={() =>
|
||||
rememberIssueDetailLocationState(
|
||||
ancestor.identifier ?? ancestor.id,
|
||||
resolvedIssueDetailState ?? location.state,
|
||||
location.search,
|
||||
)}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
>
|
||||
|
|
@ -1330,6 +1440,9 @@ export function IssueDetail() {
|
|||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
onDropFile={async (file) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1374,6 +1487,50 @@ export function IssueDetail() {
|
|||
missingBehavior="placeholder"
|
||||
/>
|
||||
|
||||
{childIssues.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
|
||||
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
|
||||
<ListTree className="h-3.5 w-3.5 mr-1.5" />
|
||||
<span className="hidden sm:inline">Add sub-issue</span>
|
||||
<span className="sm:hidden">Sub-issue</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={createIssueDetailPath(child.identifier ?? child.id)}
|
||||
state={resolvedIssueDetailState ?? location.state}
|
||||
onClickCapture={() =>
|
||||
rememberIssueDetailLocationState(
|
||||
child.identifier ?? child.id,
|
||||
resolvedIssueDetailState ?? location.state,
|
||||
location.search,
|
||||
)}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusIcon status={child.status} />
|
||||
<PriorityIcon priority={child.priority} />
|
||||
<span className="font-mono text-muted-foreground shrink-0">
|
||||
{child.identifier ?? child.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate">{child.title}</span>
|
||||
</div>
|
||||
{child.assigneeAgentId && (() => {
|
||||
const name = agentMap.get(child.assigneeAgentId)?.name;
|
||||
return name
|
||||
? <Identity name={name} size="sm" />
|
||||
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||
|
|
@ -1395,7 +1552,18 @@ export function IssueDetail() {
|
|||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
||||
});
|
||||
}}
|
||||
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
|
||||
extraActions={
|
||||
<>
|
||||
{!hasAttachments && attachmentUploadButton}
|
||||
{childIssues.length === 0 && (
|
||||
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
|
||||
<ListTree className="h-3.5 w-3.5 mr-1.5" />
|
||||
<span className="hidden sm:inline">Add sub-issue</span>
|
||||
<span className="sm:hidden">Sub-issue</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{hasAttachments ? (
|
||||
|
|
@ -1426,53 +1594,105 @@ export function IssueDetail() {
|
|||
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{attachmentList.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{imageAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="group relative aspect-square rounded-lg overflow-hidden border border-border bg-accent/10 cursor-pointer"
|
||||
onClick={() => {
|
||||
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
|
||||
setGalleryIndex(idx >= 0 ? idx : 0);
|
||||
setGalleryOpen(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors" />
|
||||
{confirmDeleteId === attachment.id ? (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-xs text-white font-medium">Delete?</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteAttachment.mutate(attachment.id);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
disabled={deleteAttachment.isPending}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1.5 right-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteId(attachment.id);
|
||||
}}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left"
|
||||
onClick={() => {
|
||||
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
|
||||
setGalleryIndex(idx >= 0 ? idx : 0);
|
||||
setGalleryOpen(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nonImageAttachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{nonImageAttachments.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1497,10 +1717,6 @@ export function IssueDetail() {
|
|||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Comments
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subissues" className="gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Sub-issues
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
|
|
@ -1516,6 +1732,7 @@ export function IssueDetail() {
|
|||
<CommentThread
|
||||
comments={timelineComments}
|
||||
queuedComments={queuedComments}
|
||||
linkedApprovals={linkedApprovals}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||
|
|
@ -1523,6 +1740,13 @@ export function IssueDetail() {
|
|||
timelineEvents={timelineEvents}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
onApproveApproval={async (approvalId) => {
|
||||
await approvalDecision.mutateAsync({ approvalId, action: "approve" });
|
||||
}}
|
||||
onRejectApproval={async (approvalId) => {
|
||||
await approvalDecision.mutateAsync({ approvalId, action: "reject" });
|
||||
}}
|
||||
pendingApprovalAction={pendingApprovalAction}
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
|
|
@ -1565,39 +1789,27 @@ export function IssueDetail() {
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subissues">
|
||||
{childIssues.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusIcon status={child.status} />
|
||||
<PriorityIcon priority={child.priority} />
|
||||
<span className="font-mono text-muted-foreground shrink-0">
|
||||
{child.identifier ?? child.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate">{child.title}</span>
|
||||
</div>
|
||||
{child.assigneeAgentId && (() => {
|
||||
const name = agentMap.get(child.assigneeAgentId)?.name;
|
||||
return name
|
||||
? <Identity name={name} size="sm" />
|
||||
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
</Link>
|
||||
<TabsContent value="activity">
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<div className="mb-3 space-y-3">
|
||||
{linkedApprovals.map((approval) => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
|
||||
onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
|
||||
onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={pendingApprovalAction?.approvalId === approval.id}
|
||||
pendingAction={
|
||||
pendingApprovalAction?.approvalId === approval.id
|
||||
? pendingApprovalAction.action
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
||||
|
|
@ -1653,43 +1865,6 @@ export function IssueDetail() {
|
|||
)}
|
||||
</Tabs>
|
||||
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<Collapsible
|
||||
open={secondaryOpen.approvals}
|
||||
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, approvals: open }))}
|
||||
className="rounded-lg border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Linked Approvals ({linkedApprovals.length})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.approvals && "rotate-180")}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{linkedApprovals.map((approval) => (
|
||||
<Link
|
||||
key={approval.id}
|
||||
to={`/approvals/${approval.id}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={approval.status} />
|
||||
<span className="font-medium">
|
||||
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
|
||||
{/* Mobile properties drawer */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
|
|
@ -1699,7 +1874,13 @@ export function IssueDetail() {
|
|||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 pb-4">
|
||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
|
||||
<IssueProperties
|
||||
issue={issue}
|
||||
childIssues={childIssues}
|
||||
onAddSubIssue={openNewSubIssue}
|
||||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue