mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 20:40:38 +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,
|
status: null,
|
||||||
executionWorkspacePolicy: null,
|
executionWorkspacePolicy: null,
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
|
env: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -250,6 +251,7 @@ describe("renderCompanyImportPreview", () => {
|
||||||
key: "OPENAI_API_KEY",
|
key: "OPENAI_API_KEY",
|
||||||
description: null,
|
description: null,
|
||||||
agentSlug: "ceo",
|
agentSlug: "ceo",
|
||||||
|
projectSlug: null,
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
requirement: "required",
|
requirement: "required",
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
|
|
@ -265,6 +267,7 @@ describe("renderCompanyImportPreview", () => {
|
||||||
key: "OPENAI_API_KEY",
|
key: "OPENAI_API_KEY",
|
||||||
description: null,
|
description: null,
|
||||||
agentSlug: "ceo",
|
agentSlug: "ceo",
|
||||||
|
projectSlug: null,
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
requirement: "required",
|
requirement: "required",
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
|
|
@ -432,6 +435,7 @@ describe("import selection catalog", () => {
|
||||||
status: null,
|
status: null,
|
||||||
executionWorkspacePolicy: null,
|
executionWorkspacePolicy: null,
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
|
env: null,
|
||||||
metadata: 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`
|
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
|
||||||
- `lead_agent_id` uuid fk `agents.id` null
|
- `lead_agent_id` uuid fk `agents.id` null
|
||||||
- `target_date` date 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)
|
## 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,
|
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 { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
|
||||||
export type RunDatabaseBackupOptions = {
|
export type RunDatabaseBackupOptions = {
|
||||||
|
|
@ -45,6 +45,11 @@ type TableDefinition = {
|
||||||
tablename: string;
|
tablename: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExtensionDefinition = {
|
||||||
|
extension_name: string;
|
||||||
|
schema_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DRIZZLE_SCHEMA = "drizzle";
|
const DRIZZLE_SCHEMA = "drizzle";
|
||||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||||
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
||||||
|
|
@ -142,6 +147,42 @@ function tableKey(schemaName: string, tableName: string): string {
|
||||||
return `${schemaName}.${tableName}`;
|
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) {
|
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
|
||||||
const stream = createWriteStream(filePath, { encoding: "utf8" });
|
const stream = createWriteStream(filePath, { encoding: "utf8" });
|
||||||
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
|
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
|
||||||
|
|
@ -340,6 +381,25 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||||
emit("");
|
emit("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extensions = await sql<ExtensionDefinition[]>`
|
||||||
|
SELECT
|
||||||
|
e.extname AS extension_name,
|
||||||
|
n.nspname AS schema_name
|
||||||
|
FROM pg_extension e
|
||||||
|
JOIN pg_namespace n ON n.oid = e.extnamespace
|
||||||
|
WHERE e.extname <> 'plpgsql'
|
||||||
|
ORDER BY e.extname
|
||||||
|
`;
|
||||||
|
if (extensions.length > 0) {
|
||||||
|
emit("-- Extensions");
|
||||||
|
for (const extension of extensions) {
|
||||||
|
emitStatement(
|
||||||
|
`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(extension.extension_name)} WITH SCHEMA ${quoteIdentifier(extension.schema_name)};`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emit("");
|
||||||
|
}
|
||||||
|
|
||||||
if (sequences.length > 0) {
|
if (sequences.length > 0) {
|
||||||
emit("-- Sequences");
|
emit("-- Sequences");
|
||||||
for (const seq of sequences) {
|
for (const seq of sequences) {
|
||||||
|
|
@ -626,13 +686,7 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sql`SELECT 1`;
|
await sql`SELECT 1`;
|
||||||
const contents = await readFile(opts.backupFile, "utf8");
|
for await (const statement of readRestoreStatements(opts.backupFile)) {
|
||||||
const statements = contents
|
|
||||||
.split(STATEMENT_BREAKPOINT)
|
|
||||||
.map((statement) => statement.trim())
|
|
||||||
.filter((statement) => statement.length > 0);
|
|
||||||
|
|
||||||
for (const statement of statements) {
|
|
||||||
await sql.unsafe(statement).execute();
|
await sql.unsafe(statement).execute();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -401,4 +401,70 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
||||||
},
|
},
|
||||||
20_000,
|
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,
|
"when": 1775349863293,
|
||||||
"tag": "0049_flawless_abomination",
|
"tag": "0049_flawless_abomination",
|
||||||
"breakpoints": true
|
"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.issueId,
|
||||||
table.createdAt,
|
table.createdAt,
|
||||||
),
|
),
|
||||||
|
bodySearchIdx: index("issue_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ export const issues = pgTable(
|
||||||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||||
|
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
|
||||||
|
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
|
||||||
|
descriptionSearchIdx: index("issues_description_search_idx").using("gin", table.description.op("gin_trgm_ops")),
|
||||||
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
||||||
.on(table.companyId, table.originKind, table.originId)
|
.on(table.companyId, table.originKind, table.originId)
|
||||||
.where(
|
.where(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
|
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 { companies } from "./companies.js";
|
||||||
import { goals } from "./goals.js";
|
import { goals } from "./goals.js";
|
||||||
import { agents } from "./agents.js";
|
import { agents } from "./agents.js";
|
||||||
|
|
@ -15,6 +16,7 @@ export const projects = pgTable(
|
||||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||||
targetDate: date("target_date"),
|
targetDate: date("target_date"),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
|
env: jsonb("env").$type<AgentEnvConfig>(),
|
||||||
pauseReason: text("pause_reason"),
|
pauseReason: text("pause_reason"),
|
||||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,12 @@ export const PROJECT_COLORS = [
|
||||||
"#3b82f6", // blue
|
"#3b82f6", // blue
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const;
|
export const APPROVAL_TYPES = [
|
||||||
|
"hire_agent",
|
||||||
|
"approve_ceo_strategy",
|
||||||
|
"budget_override_required",
|
||||||
|
"request_board_approval",
|
||||||
|
] as const;
|
||||||
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
||||||
|
|
||||||
export const APPROVAL_STATUSES = [
|
export const APPROVAL_STATUSES = [
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import type { AgentEnvConfig } from "./secrets.js";
|
||||||
|
import type { RoutineVariable } from "./routine.js";
|
||||||
|
|
||||||
export interface CompanyPortabilityInclude {
|
export interface CompanyPortabilityInclude {
|
||||||
company: boolean;
|
company: boolean;
|
||||||
agents: boolean;
|
agents: boolean;
|
||||||
|
|
@ -10,6 +13,7 @@ export interface CompanyPortabilityEnvInput {
|
||||||
key: string;
|
key: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
agentSlug: string | null;
|
agentSlug: string | null;
|
||||||
|
projectSlug: string | null;
|
||||||
kind: "secret" | "plain";
|
kind: "secret" | "plain";
|
||||||
requirement: "required" | "optional";
|
requirement: "required" | "optional";
|
||||||
defaultValue: string | null;
|
defaultValue: string | null;
|
||||||
|
|
@ -52,13 +56,12 @@ export interface CompanyPortabilityProjectManifestEntry {
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
|
env: AgentEnvConfig | null;
|
||||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||||
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
|
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { RoutineVariable } from "./routine.js";
|
|
||||||
|
|
||||||
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||||
|
import type { AgentEnvConfig } from "./secrets.js";
|
||||||
import type {
|
import type {
|
||||||
ProjectExecutionWorkspacePolicy,
|
ProjectExecutionWorkspacePolicy,
|
||||||
ProjectWorkspaceRuntimeConfig,
|
ProjectWorkspaceRuntimeConfig,
|
||||||
|
|
@ -65,6 +66,7 @@ export interface Project {
|
||||||
leadAgentId: string | null;
|
leadAgentId: string | null;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
env: AgentEnvConfig | null;
|
||||||
pauseReason: PauseReason | null;
|
pauseReason: PauseReason | null;
|
||||||
pausedAt: Date | null;
|
pausedAt: Date | null;
|
||||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
agentSlug: z.string().min(1).nullable(),
|
agentSlug: z.string().min(1).nullable(),
|
||||||
|
projectSlug: z.string().min(1).nullable(),
|
||||||
kind: z.enum(["secret", "plain"]),
|
kind: z.enum(["secret", "plain"]),
|
||||||
requirement: z.enum(["required", "optional"]),
|
requirement: z.enum(["required", "optional"]),
|
||||||
defaultValue: z.string().nullable(),
|
defaultValue: z.string().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PROJECT_STATUSES } from "../constants.js";
|
import { PROJECT_STATUSES } from "../constants.js";
|
||||||
|
import { envConfigSchema } from "./secret.js";
|
||||||
|
|
||||||
const executionWorkspaceStrategySchema = z
|
const executionWorkspaceStrategySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -102,6 +103,7 @@ const projectFields = {
|
||||||
leadAgentId: z.string().uuid().optional().nullable(),
|
leadAgentId: z.string().uuid().optional().nullable(),
|
||||||
targetDate: z.string().optional().nullable(),
|
targetDate: z.string().optional().nullable(),
|
||||||
color: z.string().optional().nullable(),
|
color: z.string().optional().nullable(),
|
||||||
|
env: envConfigSchema.optional().nullable(),
|
||||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||||
archivedAt: z.string().datetime().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 { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
|
|
||||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||||
|
|
@ -250,30 +251,33 @@ async function runPnpm(args, options = {}) {
|
||||||
const spawned = spawn(pnpmBin, args, {
|
const spawned = spawn(pnpmBin, args, {
|
||||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||||
env: options.env ?? process.env,
|
env: options.env ?? process.env,
|
||||||
|
cwd: options.cwd,
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdoutBuffer = "";
|
const stdoutBuffer = createCapturedOutputBuffer();
|
||||||
let stderrBuffer = "";
|
const stderrBuffer = createCapturedOutputBuffer();
|
||||||
|
|
||||||
if (spawned.stdout) {
|
if (spawned.stdout) {
|
||||||
spawned.stdout.on("data", (chunk) => {
|
spawned.stdout.on("data", (chunk) => {
|
||||||
stdoutBuffer += String(chunk);
|
stdoutBuffer.append(chunk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (spawned.stderr) {
|
if (spawned.stderr) {
|
||||||
spawned.stderr.on("data", (chunk) => {
|
spawned.stderr.on("data", (chunk) => {
|
||||||
stderrBuffer += String(chunk);
|
stderrBuffer.append(chunk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
spawned.on("error", reject);
|
spawned.on("error", reject);
|
||||||
spawned.on("exit", (code, signal) => {
|
spawned.on("exit", (code, signal) => {
|
||||||
|
const stdout = stdoutBuffer.finish();
|
||||||
|
const stderr = stderrBuffer.finish();
|
||||||
resolve({
|
resolve({
|
||||||
code: code ?? 0,
|
code: code ?? 0,
|
||||||
signal,
|
signal,
|
||||||
stdout: stdoutBuffer,
|
stdout: stdout.text,
|
||||||
stderr: stderrBuffer,
|
stderr: stderr.text,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -426,7 +430,7 @@ async function getDevHealthPayload() {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Health request failed (${response.status})`);
|
throw new Error(`Health request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await parseJsonResponseWithLimit(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForChildExit() {
|
async function waitForChildExit() {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
|
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -315,27 +316,29 @@ async function runPnpm(args: string[], options: {
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdoutBuffer = "";
|
const stdoutBuffer = createCapturedOutputBuffer();
|
||||||
let stderrBuffer = "";
|
const stderrBuffer = createCapturedOutputBuffer();
|
||||||
|
|
||||||
if (spawned.stdout) {
|
if (spawned.stdout) {
|
||||||
spawned.stdout.on("data", (chunk) => {
|
spawned.stdout.on("data", (chunk) => {
|
||||||
stdoutBuffer += String(chunk);
|
stdoutBuffer.append(chunk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (spawned.stderr) {
|
if (spawned.stderr) {
|
||||||
spawned.stderr.on("data", (chunk) => {
|
spawned.stderr.on("data", (chunk) => {
|
||||||
stderrBuffer += String(chunk);
|
stderrBuffer.append(chunk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
spawned.on("error", reject);
|
spawned.on("error", reject);
|
||||||
spawned.on("exit", (code, signal) => {
|
spawned.on("exit", (code, signal) => {
|
||||||
|
const stdout = stdoutBuffer.finish();
|
||||||
|
const stderr = stderrBuffer.finish();
|
||||||
resolve({
|
resolve({
|
||||||
code: code ?? 0,
|
code: code ?? 0,
|
||||||
signal,
|
signal,
|
||||||
stdout: stdoutBuffer,
|
stdout: stdout.text,
|
||||||
stderr: stderrBuffer,
|
stderr: stderr.text,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -484,7 +487,7 @@ async function getDevHealthPayload() {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Health request failed (${response.status})`);
|
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() {
|
async function waitForChildExit() {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
#!/usr/bin/env -S node --import tsx
|
#!/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 { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { repoRoot } from "./dev-service-profile.ts";
|
import { repoRoot } from "./dev-service-profile.ts";
|
||||||
|
|
||||||
type WorkspaceLinkMismatch = {
|
type WorkspaceLinkMismatch = {
|
||||||
|
workspaceDir: string;
|
||||||
packageName: string;
|
packageName: string;
|
||||||
expectedPath: string;
|
expectedPath: string;
|
||||||
actualPath: string | null;
|
actualPath: string | null;
|
||||||
|
|
@ -44,11 +45,11 @@ function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||||
|
|
||||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
||||||
|
|
||||||
function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
||||||
const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json"));
|
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
||||||
const dependencies = {
|
const dependencies = {
|
||||||
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
|
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
||||||
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
|
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
||||||
};
|
};
|
||||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
const mismatches: WorkspaceLinkMismatch[] = [];
|
||||||
|
|
||||||
|
|
@ -58,11 +59,12 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
||||||
const expectedPath = workspacePackagePaths.get(packageName);
|
const expectedPath = workspacePackagePaths.get(packageName);
|
||||||
if (!expectedPath) continue;
|
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;
|
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
||||||
if (actualPath === path.resolve(expectedPath)) continue;
|
if (actualPath === path.resolve(expectedPath)) continue;
|
||||||
|
|
||||||
mismatches.push({
|
mismatches.push({
|
||||||
|
workspaceDir,
|
||||||
packageName,
|
packageName,
|
||||||
expectedPath: path.resolve(expectedPath),
|
expectedPath: path.resolve(expectedPath),
|
||||||
actualPath,
|
actualPath,
|
||||||
|
|
@ -72,53 +74,32 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
|
||||||
return mismatches;
|
return mismatches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command: string, args: string[], cwd: string) {
|
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||||
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();
|
|
||||||
if (mismatches.length === 0) return;
|
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) {
|
for (const mismatch of mismatches) {
|
||||||
console.log(
|
console.log(
|
||||||
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
for (const mismatch of mismatches) {
|
||||||
await runCommand(
|
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
||||||
pnpmBin,
|
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
||||||
["install", "--force", "--config.confirmModulesPurge=false"],
|
await fs.rm(linkPath, { recursive: true, force: true });
|
||||||
repoRoot,
|
await fs.symlink(mismatch.expectedPath, linkPath);
|
||||||
);
|
}
|
||||||
|
|
||||||
const remainingMismatches = findServerWorkspaceLinkMismatches();
|
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
||||||
if (remainingMismatches.length === 0) return;
|
if (remainingMismatches.length === 0) return;
|
||||||
|
|
||||||
throw new Error(
|
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)
|
done < <(list_base_node_modules_paths)
|
||||||
|
|
||||||
if [[ "$needs_install" -eq 1 ]]; then
|
if [[ "$needs_install" -eq 1 ]]; then
|
||||||
backup_suffix=".paperclip-backup-$BASHPID"
|
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
|
||||||
moved_symlink_paths=()
|
moved_symlink_paths=()
|
||||||
|
|
||||||
while IFS= read -r relative_path; do
|
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() {
|
restore_moved_symlinks() {
|
||||||
local relative_path target_path backup_path
|
local relative_path target_path backup_path
|
||||||
|
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||||
target_path="$worktree_cwd/$relative_path"
|
target_path="$worktree_cwd/$relative_path"
|
||||||
backup_path="${target_path}${backup_suffix}"
|
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() {
|
cleanup_moved_symlinks() {
|
||||||
local relative_path target_path backup_path
|
local relative_path target_path backup_path
|
||||||
|
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
|
||||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||||
target_path="$worktree_cwd/$relative_path"
|
target_path="$worktree_cwd/$relative_path"
|
||||||
backup_path="${target_path}${backup_suffix}"
|
backup_path="${target_path}${backup_suffix}"
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,24 @@ function createApp() {
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAgentApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = {
|
||||||
|
type: "agent",
|
||||||
|
agentId: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
source: "api_key",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", approvalRoutes({} as any));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
describe("approval routes idempotent retries", () => {
|
describe("approval routes idempotent retries", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -107,4 +125,56 @@ describe("approval routes idempotent retries", () => {
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets agents create generic issue-linked board approval requests", async () => {
|
||||||
|
mockApprovalService.create.mockResolvedValue({
|
||||||
|
id: "approval-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
type: "request_board_approval",
|
||||||
|
requestedByAgentId: "agent-1",
|
||||||
|
requestedByUserId: null,
|
||||||
|
status: "pending",
|
||||||
|
payload: { title: "Approve hosting spend" },
|
||||||
|
decisionNote: null,
|
||||||
|
decidedByUserId: null,
|
||||||
|
decidedAt: null,
|
||||||
|
createdAt: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(createAgentApp())
|
||||||
|
.post("/api/companies/company-1/approvals")
|
||||||
|
.send({
|
||||||
|
type: "request_board_approval",
|
||||||
|
issueIds: ["00000000-0000-0000-0000-000000000001"],
|
||||||
|
payload: { title: "Approve hosting spend" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "request_board_approval",
|
||||||
|
requestedByAgentId: "agent-1",
|
||||||
|
requestedByUserId: null,
|
||||||
|
status: "pending",
|
||||||
|
decisionNote: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled();
|
||||||
|
expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith(
|
||||||
|
"approval-1",
|
||||||
|
["00000000-0000-0000-0000-000000000001"],
|
||||||
|
{ agentId: "agent-1", userId: null },
|
||||||
|
);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-1",
|
||||||
|
actorType: "agent",
|
||||||
|
actorId: "agent-1",
|
||||||
|
action: "approval.created",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
parseAllowedTypes,
|
|
||||||
matchesContentType,
|
|
||||||
DEFAULT_ALLOWED_TYPES,
|
DEFAULT_ALLOWED_TYPES,
|
||||||
|
INLINE_ATTACHMENT_TYPES,
|
||||||
|
isInlineAttachmentContentType,
|
||||||
|
matchesContentType,
|
||||||
|
normalizeContentType,
|
||||||
|
parseAllowedTypes,
|
||||||
} from "../attachment-types.js";
|
} from "../attachment-types.js";
|
||||||
|
|
||||||
describe("parseAllowedTypes", () => {
|
describe("parseAllowedTypes", () => {
|
||||||
|
|
@ -95,3 +98,28 @@ describe("matchesContentType", () => {
|
||||||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizeContentType", () => {
|
||||||
|
it("lowercases and trims explicit types", () => {
|
||||||
|
expect(normalizeContentType(" Application/Zip ")).toBe("application/zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to octet-stream when the type is missing", () => {
|
||||||
|
expect(normalizeContentType(undefined)).toBe("application/octet-stream");
|
||||||
|
expect(normalizeContentType("")).toBe("application/octet-stream");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isInlineAttachmentContentType", () => {
|
||||||
|
it("allows the configured inline-safe types", () => {
|
||||||
|
for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain"]) {
|
||||||
|
expect(isInlineAttachmentContentType(contentType)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects potentially unsafe or binary download types", () => {
|
||||||
|
expect(INLINE_ATTACHMENT_TYPES).not.toContain("text/html");
|
||||||
|
expect(isInlineAttachmentContentType("text/html")).toBe(false);
|
||||||
|
expect(isInlineAttachmentContentType("application/zip")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1149,6 +1149,7 @@ describe("company portability", () => {
|
||||||
key: "ANTHROPIC_API_KEY",
|
key: "ANTHROPIC_API_KEY",
|
||||||
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
|
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
|
||||||
agentSlug: "claudecoder",
|
agentSlug: "claudecoder",
|
||||||
|
projectSlug: null,
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
requirement: "optional",
|
requirement: "optional",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
|
|
@ -1158,6 +1159,7 @@ describe("company portability", () => {
|
||||||
key: "GH_TOKEN",
|
key: "GH_TOKEN",
|
||||||
description: "Provide GH_TOKEN for agent claudecoder",
|
description: "Provide GH_TOKEN for agent claudecoder",
|
||||||
agentSlug: "claudecoder",
|
agentSlug: "claudecoder",
|
||||||
|
projectSlug: null,
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
requirement: "optional",
|
requirement: "optional",
|
||||||
defaultValue: "",
|
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 () => {
|
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
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,
|
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]);
|
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies result limits to issue search", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exactIdentifierId = randomUUID();
|
||||||
|
const titleMatchId = randomUUID();
|
||||||
|
const descriptionMatchId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: exactIdentifierId,
|
||||||
|
companyId,
|
||||||
|
issueNumber: 42,
|
||||||
|
identifier: "PAP-42",
|
||||||
|
title: "Completely unrelated",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: titleMatchId,
|
||||||
|
companyId,
|
||||||
|
title: "Search ranking issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: descriptionMatchId,
|
||||||
|
companyId,
|
||||||
|
title: "Another item",
|
||||||
|
description: "Contains the search keyword",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await svc.list(companyId, {
|
||||||
|
q: "search",
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts issue identifiers through getById", async () => {
|
it("accepts issue identifiers through getById", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const issueId = randomUUID();
|
const issueId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||||
|
|
||||||
|
const unknownHostname = "blocked-host.invalid";
|
||||||
|
|
||||||
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
|
|
@ -42,15 +44,15 @@ describe("privateHostnameGuard", () => {
|
||||||
|
|
||||||
it("blocks unknown hostnames with remediation command", async () => {
|
it("blocks unknown hostnames with remediation command", async () => {
|
||||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||||
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
|
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body?.error).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
||||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||||
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
|
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ const mockGoalService = vi.hoisted(() => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||||
|
const mockSecretService = vi.hoisted(() => ({
|
||||||
|
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||||
|
}));
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
||||||
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
|
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -46,6 +49,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
goalService: () => mockGoalService,
|
goalService: () => mockGoalService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
|
secretService: () => mockSecretService,
|
||||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -77,6 +81,7 @@ describe("project and goal telemetry routes", () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||||
|
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||||
mockProjectService.create.mockResolvedValue({
|
mockProjectService.create.mockResolvedValue({
|
||||||
id: "project-1",
|
id: "project-1",
|
||||||
companyId: "company-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 { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
cleanupExecutionWorkspaceArtifacts,
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
|
ensureServerWorkspaceLinksCurrent,
|
||||||
ensureRuntimeServicesForRun,
|
ensureRuntimeServicesForRun,
|
||||||
normalizeAdapterManagedRuntimeServices,
|
normalizeAdapterManagedRuntimeServices,
|
||||||
reconcilePersistedRuntimeServicesOnStartup,
|
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", () => {
|
describe("realizeExecutionWorkspace", () => {
|
||||||
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
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");
|
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 () => {
|
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
const previousCwd = process.cwd();
|
const previousCwd = process.cwd();
|
||||||
|
|
@ -663,9 +823,82 @@ describe("realizeExecutionWorkspace", () => {
|
||||||
await fs.realpath(path.join(repoRoot, "packages", "shared")),
|
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 () => {
|
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||||
|
|
@ -724,6 +957,57 @@ describe("realizeExecutionWorkspace", () => {
|
||||||
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
|
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 () => {
|
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
const branchName = "PAP-450-recreate-missing-worktree";
|
const branchName = "PAP-450-recreate-missing-worktree";
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Shared attachment content-type configuration.
|
* Shared attachment content-type configuration.
|
||||||
*
|
*
|
||||||
* By default only image types are allowed. Set the
|
* By default a curated set of image/document/text types are allowed. Set the
|
||||||
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||||
* comma-separated list of MIME types or wildcard patterns to expand the
|
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||||
* allowed set.
|
* allowed set for routes that use this allowlist.
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
||||||
|
|
@ -29,6 +29,17 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||||
"text/html",
|
"text/html",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream";
|
||||||
|
export const SVG_CONTENT_TYPE = "image/svg+xml";
|
||||||
|
export const INLINE_ATTACHMENT_TYPES: readonly string[] = [
|
||||||
|
"image/*",
|
||||||
|
"application/pdf",
|
||||||
|
"text/plain",
|
||||||
|
"text/markdown",
|
||||||
|
"application/json",
|
||||||
|
"text/csv",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a comma-separated list of MIME type patterns into a normalised array.
|
* Parse a comma-separated list of MIME type patterns into a normalised array.
|
||||||
* Returns the default image-only list when the input is empty or undefined.
|
* Returns the default image-only list when the input is empty or undefined.
|
||||||
|
|
@ -59,6 +70,15 @@ export function matchesContentType(contentType: string, allowedPatterns: string[
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeContentType(contentType: string | null | undefined): string {
|
||||||
|
const normalized = (contentType ?? "").trim().toLowerCase();
|
||||||
|
return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInlineAttachmentContentType(contentType: string): boolean {
|
||||||
|
return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Module-level singletons read once at startup ----------
|
// ---------- Module-level singletons read once at startup ----------
|
||||||
|
|
||||||
const allowedPatterns: string[] = parseAllowedTypes(
|
const allowedPatterns: string[] = parseAllowedTypes(
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export type PersistedDevServerStatus = {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
|
|
@ -44,6 +46,9 @@ export function readPersistedDevServerStatus(
|
||||||
if (!filePath || !existsSync(filePath)) return null;
|
if (!filePath || !existsSync(filePath)) return null;
|
||||||
|
|
||||||
try {
|
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 raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||||
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
|
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
|
||||||
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
|
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,12 @@ import { logger } from "../middleware/logger.js";
|
||||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
import {
|
||||||
|
isInlineAttachmentContentType,
|
||||||
|
MAX_ATTACHMENT_BYTES,
|
||||||
|
normalizeContentType,
|
||||||
|
SVG_CONTENT_TYPE,
|
||||||
|
} from "../attachment-types.js";
|
||||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
|
|
||||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||||
|
|
@ -341,6 +346,9 @@ export function issueRoutes(
|
||||||
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
||||||
? req.actor.userId
|
? req.actor.userId
|
||||||
: unreadForUserFilterRaw;
|
: unreadForUserFilterRaw;
|
||||||
|
const rawLimit = req.query.limit as string | undefined;
|
||||||
|
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : null;
|
||||||
|
const limit = parsedLimit ?? undefined;
|
||||||
|
|
||||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||||
|
|
@ -358,6 +366,10 @@ export function issueRoutes(
|
||||||
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
|
||||||
|
res.status(400).json({ error: "limit must be a positive integer" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await svc.list(companyId, {
|
const result = await svc.list(companyId, {
|
||||||
status: req.query.status as string | undefined,
|
status: req.query.status as string | undefined,
|
||||||
|
|
@ -376,6 +388,7 @@ export function issueRoutes(
|
||||||
includeRoutineExecutions:
|
includeRoutineExecutions:
|
||||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
|
limit,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
@ -2108,11 +2121,7 @@ export function issueRoutes(
|
||||||
res.status(400).json({ error: "Missing file field 'file'" });
|
res.status(400).json({ error: "Missing file field 'file'" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
const contentType = normalizeContentType(file.mimetype);
|
||||||
if (!isAllowedContentType(contentType)) {
|
|
||||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.buffer.length <= 0) {
|
if (file.buffer.length <= 0) {
|
||||||
res.status(422).json({ error: "Attachment is empty" });
|
res.status(422).json({ error: "Attachment is empty" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -2176,11 +2185,17 @@ export function issueRoutes(
|
||||||
assertCompanyAccess(req, attachment.companyId);
|
assertCompanyAccess(req, attachment.companyId);
|
||||||
|
|
||||||
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
||||||
res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
|
const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
|
||||||
|
res.setHeader("Content-Type", responseContentType);
|
||||||
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
||||||
res.setHeader("Cache-Control", "private, max-age=60");
|
res.setHeader("Cache-Control", "private, max-age=60");
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
if (responseContentType === SVG_CONTENT_TYPE) {
|
||||||
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||||
|
}
|
||||||
const filename = attachment.originalFilename ?? "attachment";
|
const filename = attachment.originalFilename ?? "attachment";
|
||||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment";
|
||||||
|
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||||
|
|
||||||
object.stream.on("error", (err) => {
|
object.stream.on("error", (err) => {
|
||||||
next(err);
|
next(err);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||||
import { validate } from "../middleware/validate.js";
|
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 { conflict } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
|
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
|
||||||
|
|
@ -18,7 +18,9 @@ import { getTelemetryClient } from "../telemetry.js";
|
||||||
export function projectRoutes(db: Db) {
|
export function projectRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = projectService(db);
|
const svc = projectService(db);
|
||||||
|
const secretsSvc = secretService(db);
|
||||||
const workspaceOperations = workspaceOperationService(db);
|
const workspaceOperations = workspaceOperationService(db);
|
||||||
|
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||||
|
|
||||||
async function resolveCompanyIdForProjectReference(req: Request) {
|
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||||
const companyIdQuery = req.query.companyId;
|
const companyIdQuery = req.query.companyId;
|
||||||
|
|
@ -82,6 +84,13 @@ export function projectRoutes(db: Db) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
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);
|
const project = await svc.create(companyId, projectData);
|
||||||
let createdWorkspaceId: string | null = null;
|
let createdWorkspaceId: string | null = null;
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
|
|
@ -107,6 +116,7 @@ export function projectRoutes(db: Db) {
|
||||||
details: {
|
details: {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
workspaceId: createdWorkspaceId,
|
workspaceId: createdWorkspaceId,
|
||||||
|
envKeys: project.env ? Object.keys(project.env).sort() : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const telemetryClient = getTelemetryClient();
|
const telemetryClient = getTelemetryClient();
|
||||||
|
|
@ -128,6 +138,12 @@ export function projectRoutes(db: Db) {
|
||||||
if (typeof body.archivedAt === "string") {
|
if (typeof body.archivedAt === "string") {
|
||||||
body.archivedAt = new Date(body.archivedAt);
|
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);
|
const project = await svc.update(id, body);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
res.status(404).json({ error: "Project not found" });
|
res.status(404).json({ error: "Project not found" });
|
||||||
|
|
@ -143,7 +159,13 @@ export function projectRoutes(db: Db) {
|
||||||
action: "project.updated",
|
action: "project.updated",
|
||||||
entityType: "project",
|
entityType: "project",
|
||||||
entityId: project.id,
|
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);
|
res.json(project);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type {
|
||||||
CompanyPortabilitySidebarOrder,
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanySkill,
|
CompanySkill,
|
||||||
|
AgentEnvConfig,
|
||||||
RoutineVariable,
|
RoutineVariable,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
ROUTINE_TRIGGER_KINDS,
|
ROUTINE_TRIGGER_KINDS,
|
||||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||||
deriveProjectUrlKey,
|
deriveProjectUrlKey,
|
||||||
|
envConfigSchema,
|
||||||
normalizeAgentUrlKey,
|
normalizeAgentUrlKey,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
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 = {
|
type ResolvedSource = {
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, CompanyPortabilityFileEntry>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
|
|
@ -419,6 +503,7 @@ type ProjectLike = {
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
env: Record<string, unknown> | null;
|
||||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||||
workspaces?: Array<{
|
workspaces?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -1528,68 +1613,33 @@ function extractPortableEnvInputs(
|
||||||
envValue: unknown,
|
envValue: unknown,
|
||||||
warnings: string[],
|
warnings: string[],
|
||||||
): CompanyPortabilityEnvInput[] {
|
): CompanyPortabilityEnvInput[] {
|
||||||
if (!isPlainRecord(envValue)) return [];
|
return extractPortableScopedEnvInputs(
|
||||||
const env = envValue as Record<string, unknown>;
|
{
|
||||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
label: `agent ${agentSlug}`,
|
||||||
|
warningPrefix: `Agent ${agentSlug}`,
|
||||||
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,
|
agentSlug,
|
||||||
kind: "secret",
|
projectSlug: null,
|
||||||
requirement: "optional",
|
},
|
||||||
defaultValue: "",
|
envValue,
|
||||||
portability: "portable",
|
warnings,
|
||||||
});
|
);
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
function extractPortableProjectEnvInputs(
|
||||||
const defaultValue = asString(binding.value);
|
projectSlug: string,
|
||||||
const isSensitive = isSensitiveEnvKey(key);
|
envValue: unknown,
|
||||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
warnings: string[],
|
||||||
? "system_dependent"
|
): CompanyPortabilityEnvInput[] {
|
||||||
: "portable";
|
return extractPortableScopedEnvInputs(
|
||||||
if (portability === "system_dependent") {
|
{
|
||||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
label: `project ${projectSlug}`,
|
||||||
}
|
warningPrefix: `Project ${projectSlug}`,
|
||||||
inputs.push({
|
agentSlug: null,
|
||||||
key,
|
projectSlug,
|
||||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
},
|
||||||
agentSlug,
|
envValue,
|
||||||
kind: isSensitive ? "secret" : "plain",
|
warnings,
|
||||||
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 jsonEqual(left: unknown, right: unknown): boolean {
|
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||||
|
|
@ -2175,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: CompanyPortabilityManifest["envInputs"] = [];
|
const out: CompanyPortabilityManifest["envInputs"] = [];
|
||||||
for (const value of values) {
|
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;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
out.push(value);
|
out.push(value);
|
||||||
|
|
@ -2232,6 +2282,31 @@ function readAgentEnvInputs(
|
||||||
key,
|
key,
|
||||||
description: asString(record.description) ?? null,
|
description: asString(record.description) ?? null,
|
||||||
agentSlug,
|
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",
|
kind: record.kind === "plain" ? "plain" : "secret",
|
||||||
requirement: record.requirement === "required" ? "required" : "optional",
|
requirement: record.requirement === "required" ? "required" : "optional",
|
||||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||||
|
|
@ -2531,12 +2606,14 @@ function buildManifestFromPackageFiles(
|
||||||
targetDate: asString(extension.targetDate),
|
targetDate: asString(extension.targetDate),
|
||||||
color: asString(extension.color),
|
color: asString(extension.color),
|
||||||
status: asString(extension.status),
|
status: asString(extension.status),
|
||||||
|
env: normalizePortableProjectEnv(extension.env),
|
||||||
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
||||||
? extension.executionWorkspacePolicy
|
? extension.executionWorkspacePolicy
|
||||||
: null,
|
: null,
|
||||||
workspaces,
|
workspaces,
|
||||||
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
||||||
});
|
});
|
||||||
|
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
|
||||||
if (frontmatter.kind && frontmatter.kind !== "project") {
|
if (frontmatter.kind && frontmatter.kind !== "project") {
|
||||||
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
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) {
|
for (const project of selectedProjectRows) {
|
||||||
const slug = projectSlugById.get(project.id)!;
|
const slug = projectSlugById.get(project.id)!;
|
||||||
const projectPath = `projects/${slug}/PROJECT.md`;
|
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);
|
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
|
||||||
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
||||||
files[projectPath] = buildMarkdown(
|
files[projectPath] = buildMarkdown(
|
||||||
|
|
@ -3167,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
) ?? undefined,
|
) ?? undefined,
|
||||||
workspaces: portableWorkspaces.extension,
|
workspaces: portableWorkspaces.extension,
|
||||||
});
|
});
|
||||||
|
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
|
||||||
|
extension.inputs = {
|
||||||
|
env: buildEnvInputMap(projectEnvInputs),
|
||||||
|
};
|
||||||
|
}
|
||||||
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3506,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
for (const envInput of manifest.envInputs) {
|
for (const envInput of manifest.envInputs) {
|
||||||
if (envInput.portability === "system_dependent") {
|
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)
|
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
||||||
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
||||||
: "backlog",
|
: "backlog",
|
||||||
|
env: manifestProject.env,
|
||||||
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
|
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
||||||
"pi_local",
|
"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: {
|
export function applyPersistedExecutionWorkspaceConfig(input: {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
workspaceConfig: ExecutionWorkspaceConfig | null;
|
workspaceConfig: ExecutionWorkspaceConfig | null;
|
||||||
|
|
@ -2309,17 +2339,20 @@ export function heartbeatService(db: Db) {
|
||||||
: null;
|
: null;
|
||||||
const contextProjectId = readNonEmptyString(context.projectId);
|
const contextProjectId = readNonEmptyString(context.projectId);
|
||||||
const executionProjectId = issueContext?.projectId ?? contextProjectId;
|
const executionProjectId = issueContext?.projectId ?? contextProjectId;
|
||||||
const projectExecutionWorkspacePolicy = executionProjectId
|
const projectContext = executionProjectId
|
||||||
? await db
|
? await db
|
||||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
.select({
|
||||||
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||||
|
env: projects.env,
|
||||||
|
})
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
||||||
.then((rows) =>
|
.then((rows) => rows[0] ?? null)
|
||||||
gateProjectExecutionWorkspacePolicy(
|
|
||||||
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
|
|
||||||
isolatedWorkspacesEnabled,
|
|
||||||
))
|
|
||||||
: null;
|
: null;
|
||||||
|
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
|
||||||
|
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
|
||||||
|
isolatedWorkspacesEnabled,
|
||||||
|
);
|
||||||
const taskSession = taskKey
|
const taskSession = taskKey
|
||||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -2416,10 +2449,12 @@ export function heartbeatService(db: Db) {
|
||||||
: persistedWorkspaceManagedConfig;
|
: persistedWorkspaceManagedConfig;
|
||||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
|
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
|
||||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||||
agent.companyId,
|
companyId: agent.companyId,
|
||||||
executionRunConfig,
|
executionRunConfig,
|
||||||
);
|
projectEnv: projectContext?.env ?? null,
|
||||||
|
secretsSvc,
|
||||||
|
});
|
||||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||||
const runtimeConfig = {
|
const runtimeConfig = {
|
||||||
...resolvedConfig,
|
...resolvedConfig,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export interface IssueFilters {
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssueRow = typeof issues.$inferSelect;
|
type IssueRow = typeof issues.$inferSelect;
|
||||||
|
|
@ -911,6 +912,9 @@ export function issueService(db: Db) {
|
||||||
return {
|
return {
|
||||||
list: async (companyId: string, filters?: IssueFilters) => {
|
list: async (companyId: string, filters?: IssueFilters) => {
|
||||||
const conditions = [eq(issues.companyId, companyId)];
|
const conditions = [eq(issues.companyId, companyId)];
|
||||||
|
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
||||||
|
? Math.max(1, Math.floor(filters.limit))
|
||||||
|
: undefined;
|
||||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||||
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
||||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||||
|
|
@ -999,7 +1003,7 @@ export function issueService(db: Db) {
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||||
const rows = await db
|
const baseQuery = db
|
||||||
.select()
|
.select()
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
|
|
@ -1009,6 +1013,7 @@ export function issueService(db: Db) {
|
||||||
desc(canonicalLastActivityAt),
|
desc(canonicalLastActivityAt),
|
||||||
desc(issues.updatedAt),
|
desc(issues.updatedAt),
|
||||||
);
|
);
|
||||||
|
const rows = limit === undefined ? await baseQuery : await baseQuery.limit(limit);
|
||||||
const withLabels = await withIssueLabels(db, rows);
|
const withLabels = await withIssueLabels(db, rows);
|
||||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||||
const withRuns = withActiveRuns(withLabels, runMap);
|
const withRuns = withActiveRuns(withLabels, runMap);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function secretService(db: Db) {
|
export function secretService(db: Db) {
|
||||||
|
type NormalizeEnvOptions = {
|
||||||
|
strictMode?: boolean;
|
||||||
|
fieldPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function getById(id: string) {
|
async function getById(id: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -94,10 +99,10 @@ export function secretService(db: Db) {
|
||||||
async function normalizeEnvConfig(
|
async function normalizeEnvConfig(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
envValue: unknown,
|
envValue: unknown,
|
||||||
opts?: { strictMode?: boolean },
|
opts?: NormalizeEnvOptions,
|
||||||
): Promise<AgentEnvConfig> {
|
): Promise<AgentEnvConfig> {
|
||||||
const record = asRecord(envValue);
|
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 = {};
|
const normalized: AgentEnvConfig = {};
|
||||||
for (const [key, rawBinding] of Object.entries(record)) {
|
for (const [key, rawBinding] of Object.entries(record)) {
|
||||||
|
|
@ -292,6 +297,12 @@ export function secretService(db: Db) {
|
||||||
opts?: { strictMode?: boolean },
|
opts?: { strictMode?: boolean },
|
||||||
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
||||||
|
|
||||||
|
normalizeEnvBindingsForPersistence: async (
|
||||||
|
companyId: string,
|
||||||
|
envValue: unknown,
|
||||||
|
opts?: NormalizeEnvOptions,
|
||||||
|
) => normalizeEnvConfig(companyId, envValue, opts),
|
||||||
|
|
||||||
normalizeHireApprovalPayloadForPersistence: async (
|
normalizeHireApprovalPayloadForPersistence: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
@ -101,6 +102,18 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
|
||||||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||||
const runtimeServiceLeasesByRun = 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() {
|
export async function resetRuntimeServicesForTests() {
|
||||||
for (const record of runtimeServicesById.values()) {
|
for (const record of runtimeServicesById.values()) {
|
||||||
|
|
@ -122,6 +135,128 @@ function stableStringify(value: unknown): string {
|
||||||
return JSON.stringify(value);
|
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 {
|
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||||
for (const key of Object.keys(env)) {
|
for (const key of Object.keys(env)) {
|
||||||
|
|
@ -258,30 +393,96 @@ function formatCommandForDisplay(command: string, args: string[]) {
|
||||||
.join(" ");
|
.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: {
|
async function executeProcess(input: {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
maxStdoutBytes?: number;
|
||||||
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
|
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, {
|
const child = spawn(input.command, input.args, {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: input.env ?? process.env,
|
env: input.env ?? process.env,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
const stdout = createProcessOutputCapture(input.maxStdoutBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
|
||||||
let stderr = "";
|
const stderr = createProcessOutputCapture(input.maxStderrBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
|
||||||
child.stdout?.on("data", (chunk) => {
|
child.stdout?.on("data", (chunk) => {
|
||||||
stdout += String(chunk);
|
stdout.append(String(chunk));
|
||||||
});
|
});
|
||||||
child.stderr?.on("data", (chunk) => {
|
child.stderr?.on("data", (chunk) => {
|
||||||
stderr += String(chunk);
|
stderr.append(String(chunk));
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
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> {
|
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||||
|
|
@ -377,8 +578,35 @@ function buildWorkspaceCommandEnv(input: {
|
||||||
return env;
|
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: {
|
async function runWorkspaceCommand(input: {
|
||||||
command: string;
|
command: string;
|
||||||
|
resolvedCommand?: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -386,7 +614,7 @@ async function runWorkspaceCommand(input: {
|
||||||
const shell = resolveShell();
|
const shell = resolveShell();
|
||||||
const proc = await executeProcess({
|
const proc = await executeProcess({
|
||||||
command: shell,
|
command: shell,
|
||||||
args: ["-c", input.command],
|
args: ["-c", input.resolvedCommand ?? input.command],
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
env: input.env,
|
env: input.env,
|
||||||
});
|
});
|
||||||
|
|
@ -438,6 +666,15 @@ async function recordGitOperation(
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
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: {
|
input: {
|
||||||
phase: "workspace_provision" | "workspace_teardown";
|
phase: "workspace_provision" | "workspace_teardown";
|
||||||
command: string;
|
command: string;
|
||||||
|
resolvedCommand?: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -482,7 +720,7 @@ async function recordWorkspaceCommandOperation(
|
||||||
const shell = resolveShell();
|
const shell = resolveShell();
|
||||||
const result = await executeProcess({
|
const result = await executeProcess({
|
||||||
command: shell,
|
command: shell,
|
||||||
args: ["-c", input.command],
|
args: ["-c", input.resolvedCommand ?? input.command],
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
env: input.env,
|
env: input.env,
|
||||||
});
|
});
|
||||||
|
|
@ -495,6 +733,15 @@ async function recordWorkspaceCommandOperation(
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
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();
|
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
|
||||||
if (!provisionCommand) return;
|
if (!provisionCommand) return;
|
||||||
|
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
|
||||||
|
|
||||||
await recordWorkspaceCommandOperation(input.recorder, {
|
await recordWorkspaceCommandOperation(input.recorder, {
|
||||||
phase: "workspace_provision",
|
phase: "workspace_provision",
|
||||||
command: provisionCommand,
|
command: provisionCommand,
|
||||||
|
resolvedCommand: resolvedProvisionCommand,
|
||||||
cwd: input.worktreePath,
|
cwd: input.worktreePath,
|
||||||
env: buildWorkspaceCommandEnv({
|
env: buildWorkspaceCommandEnv({
|
||||||
base: input.base,
|
base: input.base,
|
||||||
|
|
@ -542,6 +791,7 @@ async function provisionExecutionWorktree(input: {
|
||||||
worktreePath: input.worktreePath,
|
worktreePath: input.worktreePath,
|
||||||
branchName: input.branchName,
|
branchName: input.branchName,
|
||||||
created: input.created,
|
created: input.created,
|
||||||
|
resolvedCommand: resolvedProvisionCommand === provisionCommand ? null : resolvedProvisionCommand,
|
||||||
},
|
},
|
||||||
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
|
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
|
||||||
});
|
});
|
||||||
|
|
@ -769,6 +1019,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||||
}) {
|
}) {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
|
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({
|
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
|
||||||
workspace: input.workspace,
|
workspace: input.workspace,
|
||||||
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
|
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
|
||||||
|
|
@ -784,9 +1040,13 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||||
|
|
||||||
for (const command of cleanupCommands) {
|
for (const command of cleanupCommands) {
|
||||||
try {
|
try {
|
||||||
|
const resolvedCommand = repoRoot
|
||||||
|
? resolveRepoManagedWorkspaceCommand(command, repoRoot)
|
||||||
|
: command;
|
||||||
await recordWorkspaceCommandOperation(input.recorder, {
|
await recordWorkspaceCommandOperation(input.recorder, {
|
||||||
phase: "workspace_teardown",
|
phase: "workspace_teardown",
|
||||||
command,
|
command,
|
||||||
|
resolvedCommand,
|
||||||
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
|
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
|
||||||
env: cleanupEnv,
|
env: cleanupEnv,
|
||||||
label: `Execution workspace cleanup command "${command}"`,
|
label: `Execution workspace cleanup command "${command}"`,
|
||||||
|
|
@ -795,6 +1055,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||||
workspacePath,
|
workspacePath,
|
||||||
branchName: input.workspace.branchName,
|
branchName: input.workspace.branchName,
|
||||||
providerType: input.workspace.providerType,
|
providerType: input.workspace.providerType,
|
||||||
|
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
|
||||||
},
|
},
|
||||||
successMessage: `Completed cleanup command "${command}"\n`,
|
successMessage: `Completed cleanup command "${command}"\n`,
|
||||||
});
|
});
|
||||||
|
|
@ -804,10 +1065,6 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
||||||
const repoRoot = await resolveGitRepoRootForWorkspaceCleanup(
|
|
||||||
workspacePath,
|
|
||||||
input.projectWorkspace?.cwd ?? null,
|
|
||||||
);
|
|
||||||
const worktreeExists = await directoryExists(workspacePath);
|
const worktreeExists = await directoryExists(workspacePath);
|
||||||
if (worktreeExists) {
|
if (worktreeExists) {
|
||||||
if (!repoRoot) {
|
if (!repoRoot) {
|
||||||
|
|
@ -1375,6 +1632,10 @@ async function startLocalRuntimeService(input: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureServerWorkspaceLinksCurrent(serviceCwd, {
|
||||||
|
onLog: input.onLog,
|
||||||
|
});
|
||||||
|
|
||||||
const shell = resolveShell();
|
const shell = resolveShell();
|
||||||
const child = spawn(shell, ["-lc", command], {
|
const child = spawn(shell, ["-lc", command], {
|
||||||
cwd: serviceCwd,
|
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.
|
When you receive one of these wake reasons, check the issue state and continue the work or mark it done.
|
||||||
|
|
||||||
|
## Requesting Board Approval
|
||||||
|
|
||||||
|
Agents can create approval requests for arbitrary issue-linked work. Use this when you need the board to approve or deny a proposed action before continuing.
|
||||||
|
|
||||||
|
Recommended generic type:
|
||||||
|
|
||||||
|
- `request_board_approval` for open-ended approval requests like spend approval, vendor approval, launch approval, or other board decisions
|
||||||
|
|
||||||
|
Create the approval and link it to the relevant issue in one call:
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/companies/{companyId}/approvals
|
||||||
|
{
|
||||||
|
"type": "request_board_approval",
|
||||||
|
"requestedByAgentId": "{your-agent-id}",
|
||||||
|
"issueIds": ["{issue-id}"],
|
||||||
|
"payload": {
|
||||||
|
"title": "Approve monthly hosting spend",
|
||||||
|
"summary": "Estimated cost is $42/month for provider X.",
|
||||||
|
"recommendedAction": "Approve provider X and continue setup.",
|
||||||
|
"risks": ["Costs may increase with usage."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `issueIds` links the approval into the issue thread/UI.
|
||||||
|
- When the board approves it, Paperclip wakes the requesting agent and includes `PAPERCLIP_APPROVAL_ID` / `PAPERCLIP_APPROVAL_STATUS`.
|
||||||
|
- Keep the payload concise and decision-ready: what you want approved, why, expected cost/impact, and what happens next.
|
||||||
|
|
||||||
## Project Setup Workflow (CEO/Manager Common Path)
|
## Project Setup Workflow (CEO/Manager Common Path)
|
||||||
|
|
||||||
When asked to set up a new project with workspace config (local folder and/or GitHub repo), use:
|
When asked to set up a new project with workspace config (local folder and/or GitHub repo), use:
|
||||||
|
|
@ -335,6 +366,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||||
| Release task | `POST /api/issues/:issueId/release` |
|
| Release task | `POST /api/issues/:issueId/release` |
|
||||||
| List agents | `GET /api/companies/:companyId/agents` |
|
| List agents | `GET /api/companies/:companyId/agents` |
|
||||||
|
| Create approval | `POST /api/companies/:companyId/approvals` |
|
||||||
| List company skills | `GET /api/companies/:companyId/skills` |
|
| List company skills | `GET /api/companies/:companyId/skills` |
|
||||||
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
||||||
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { defineConfig } from "@playwright/test";
|
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}`;
|
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|
@ -29,6 +31,11 @@ export default defineConfig({
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(PORT),
|
||||||
|
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
outputDir: "./test-results",
|
outputDir: "./test-results",
|
||||||
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
|
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/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lexical/link": "0.35.0",
|
"@lexical/link": "0.35.0",
|
||||||
"lexical": "0.35.0",
|
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
|
@ -41,13 +40,14 @@
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
"hermes-paperclip-adapter": "^0.2.0",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"mermaid": "^11.12.0",
|
"mermaid": "^11.12.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export const issuesApi = {
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -53,6 +54,7 @@ export const issuesApi = {
|
||||||
if (filters?.originId) params.set("originId", filters.originId);
|
if (filters?.originId) params.set("originId", filters.originId);
|
||||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
if (filters?.q) params.set("q", filters.q);
|
if (filters?.q) params.set("q", filters.q);
|
||||||
|
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
import { ReportsToPicker } from "./ReportsToPicker";
|
import { ReportsToPicker } from "./ReportsToPicker";
|
||||||
|
import { EnvVarEditor } from "./EnvVarEditor";
|
||||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||||
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
||||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
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({
|
function ModelDropdown({
|
||||||
models,
|
models,
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
import {
|
||||||
|
approvalSubject,
|
||||||
|
typeIcon,
|
||||||
|
defaultTypeIcon,
|
||||||
|
ApprovalPayloadRenderer,
|
||||||
|
typeLabel,
|
||||||
|
} from "./ApprovalPayload";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import type { Approval, Agent } from "@paperclipai/shared";
|
import type { Approval, Agent } from "@paperclipai/shared";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function statusIcon(status: string) {
|
function statusIcon(status: string) {
|
||||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
|
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
|
||||||
|
|
@ -21,64 +29,96 @@ export function ApprovalCard({
|
||||||
onReject,
|
onReject,
|
||||||
onOpen,
|
onOpen,
|
||||||
detailLink,
|
detailLink,
|
||||||
isPending,
|
isPending = false,
|
||||||
|
pendingAction = null,
|
||||||
}: {
|
}: {
|
||||||
approval: Approval;
|
approval: Approval;
|
||||||
requesterAgent: Agent | null;
|
requesterAgent: Agent | null;
|
||||||
onApprove: () => void;
|
onApprove?: () => void;
|
||||||
onReject: () => void;
|
onReject?: () => void;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
detailLink?: string;
|
detailLink?: string;
|
||||||
isPending: boolean;
|
isPending?: boolean;
|
||||||
|
pendingAction?: "approve" | "reject" | null;
|
||||||
}) {
|
}) {
|
||||||
|
const payload = approval.payload as Record<string, unknown> | null;
|
||||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||||
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
|
const kindLabel = typeLabel[approval.type] ?? approval.type;
|
||||||
|
const subject = approvalSubject(payload);
|
||||||
const showResolutionButtons =
|
const showResolutionButtons =
|
||||||
|
Boolean(onApprove && onReject) &&
|
||||||
approval.type !== "budget_override_required" &&
|
approval.type !== "budget_override_required" &&
|
||||||
(approval.status === "pending" || approval.status === "revision_requested");
|
(approval.status === "pending" || approval.status === "revision_requested");
|
||||||
|
const hasFooter = showResolutionButtons || Boolean(detailLink || onOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
<div className="rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||||
{/* Header */}
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/80">
|
||||||
<div className="flex items-center gap-2">
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="font-medium text-sm">{label}</span>
|
</div>
|
||||||
|
<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 && (
|
{requesterAgent && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
<span>Requested by</span>
|
||||||
</span>
|
<Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 className="flex items-center gap-1.5 shrink-0">
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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)}
|
{statusIcon(approval.status)}
|
||||||
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
<span className="capitalize">{approval.status.replace(/_/g, " ")}</span>
|
||||||
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payload */}
|
<div className="mt-4 border-t border-border/60 pt-4">
|
||||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
<ApprovalPayloadRenderer
|
||||||
|
type={approval.type}
|
||||||
|
payload={approval.payload}
|
||||||
|
hidePrimaryTitle={Boolean(subject)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Decision note */}
|
|
||||||
{approval.decisionNote && (
|
{approval.decisionNote && (
|
||||||
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
<div className="mt-4 rounded-lg border border-border/60 bg-muted/30 px-3.5 py-3 text-xs leading-5 text-muted-foreground">
|
||||||
Note: {approval.decisionNote}
|
<span className="font-medium text-foreground">Decision note.</span> {approval.decisionNote}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{hasFooter ? (
|
||||||
|
<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 && (
|
{showResolutionButtons && (
|
||||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-green-700 hover:bg-green-600 text-white"
|
className="bg-green-700 hover:bg-green-600 text-white"
|
||||||
onClick={onApprove}
|
onClick={onApprove}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Approve
|
{pendingAction === "approve" ? "Approving..." : "Approve"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|
@ -86,21 +126,27 @@ export function ApprovalCard({
|
||||||
onClick={onReject}
|
onClick={onReject}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Reject
|
{pendingAction === "reject" ? "Rejecting..." : "Reject"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3">
|
</div>
|
||||||
{detailLink ? (
|
{(detailLink || onOpen) ? (
|
||||||
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
detailLink ? (
|
||||||
<Link to={detailLink}>View details</Link>
|
<Link
|
||||||
</Button>
|
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="text-xs px-0" onClick={onOpen}>
|
<Button variant="ghost" size="sm" className="h-auto px-2 text-xs text-muted-foreground" onClick={onOpen}>
|
||||||
View details
|
View details
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
ui/src/components/ApprovalPayload.test.tsx
Normal file
88
ui/src/components/ApprovalPayload.test.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("approvalLabel", () => {
|
||||||
|
it("uses payload titles for generic board approvals", () => {
|
||||||
|
expect(
|
||||||
|
approvalLabel("request_board_approval", {
|
||||||
|
title: "Reply with an ASCII frog",
|
||||||
|
}),
|
||||||
|
).toBe("Board Approval: Reply with an ASCII frog");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ApprovalPayloadRenderer", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders request_board_approval payload fields without falling back to raw JSON", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ApprovalPayloadRenderer
|
||||||
|
type="request_board_approval"
|
||||||
|
payload={{
|
||||||
|
title: "Reply with an ASCII frog",
|
||||||
|
summary: "Board asked for approval before posting the frog.",
|
||||||
|
recommendedAction: "Approve the frog reply.",
|
||||||
|
nextActionOnApproval: "Post the frog comment on the issue.",
|
||||||
|
risks: ["The frog might be too powerful."],
|
||||||
|
proposedComment: "(o)<",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Reply with an ASCII frog");
|
||||||
|
expect(container.textContent).toContain("Board asked for approval before posting the frog.");
|
||||||
|
expect(container.textContent).toContain("Approve the frog reply.");
|
||||||
|
expect(container.textContent).toContain("Post the frog comment on the issue.");
|
||||||
|
expect(container.textContent).toContain("The frog might be too powerful.");
|
||||||
|
expect(container.textContent).toContain("(o)<");
|
||||||
|
expect(container.textContent).not.toContain("\"recommendedAction\"");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can hide the repeated title when the card header already shows it", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ApprovalPayloadRenderer
|
||||||
|
type="request_board_approval"
|
||||||
|
hidePrimaryTitle
|
||||||
|
payload={{
|
||||||
|
title: "Reply with an ASCII frog",
|
||||||
|
summary: "Board asked for approval before posting the frog.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Board asked for approval before posting the frog.");
|
||||||
|
expect(container.textContent).not.toContain("TitleReply with an ASCII frog");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,13 +5,33 @@ export const typeLabel: Record<string, string> = {
|
||||||
hire_agent: "Hire Agent",
|
hire_agent: "Hire Agent",
|
||||||
approve_ceo_strategy: "CEO Strategy",
|
approve_ceo_strategy: "CEO Strategy",
|
||||||
budget_override_required: "Budget Override",
|
budget_override_required: "Budget Override",
|
||||||
|
request_board_approval: "Board Approval",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function firstNonEmptyString(...values: unknown[]): string | null {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approvalSubject(payload?: Record<string, unknown> | null): string | null {
|
||||||
|
return firstNonEmptyString(
|
||||||
|
payload?.title,
|
||||||
|
payload?.name,
|
||||||
|
payload?.summary,
|
||||||
|
payload?.recommendedAction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
|
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
|
||||||
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
|
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
|
||||||
const base = typeLabel[type] ?? type;
|
const base = typeLabel[type] ?? type;
|
||||||
if (type === "hire_agent" && payload?.name) {
|
const subject = approvalSubject(payload);
|
||||||
return `${base}: ${String(payload.name)}`;
|
if (subject) {
|
||||||
|
return `${base}: ${subject}`;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +40,7 @@ export const typeIcon: Record<string, typeof UserPlus> = {
|
||||||
hire_agent: UserPlus,
|
hire_agent: UserPlus,
|
||||||
approve_ceo_strategy: Lightbulb,
|
approve_ceo_strategy: Lightbulb,
|
||||||
budget_override_required: ShieldAlert,
|
budget_override_required: ShieldAlert,
|
||||||
|
request_board_approval: ShieldCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultTypeIcon = ShieldCheck;
|
export const defaultTypeIcon = ShieldCheck;
|
||||||
|
|
@ -127,8 +148,100 @@ export function BudgetOverridePayload({ payload }: { payload: Record<string, unk
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
export function BoardApprovalPayload({
|
||||||
|
payload,
|
||||||
|
hideTitle = false,
|
||||||
|
}: {
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
}) {
|
||||||
|
const nextPayload = hideTitle ? { ...payload, title: undefined } : payload;
|
||||||
|
return (
|
||||||
|
<BoardApprovalPayloadContent payload={nextPayload} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unknown> }) {
|
||||||
|
const risks = Array.isArray(payload.risks)
|
||||||
|
? payload.risks
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const title = firstNonEmptyString(payload.title);
|
||||||
|
const summary = firstNonEmptyString(payload.summary);
|
||||||
|
const recommendedAction = firstNonEmptyString(payload.recommendedAction);
|
||||||
|
const nextActionOnApproval = firstNonEmptyString(payload.nextActionOnApproval);
|
||||||
|
const proposedComment = firstNonEmptyString(payload.proposedComment);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-3.5 text-sm">
|
||||||
|
{title && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Title</p>
|
||||||
|
<p className="font-medium leading-6 text-foreground">{title}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Summary</p>
|
||||||
|
<p className="leading-6 text-foreground/90">{summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recommendedAction && (
|
||||||
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3.5 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-amber-700 dark:text-amber-300">
|
||||||
|
Recommended action
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nextActionOnApproval && (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">On approval</p>
|
||||||
|
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{risks.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Risks</p>
|
||||||
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{risks.map((risk) => (
|
||||||
|
<li key={risk} className="flex items-start gap-2">
|
||||||
|
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-muted-foreground/60" />
|
||||||
|
<span className="leading-6">{risk}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{proposedComment && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||||
|
Proposed comment
|
||||||
|
</p>
|
||||||
|
<pre className="max-h-48 overflow-auto rounded-lg border border-border/60 bg-muted/50 px-3.5 py-3 font-mono text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{proposedComment}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalPayloadRenderer({
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
hidePrimaryTitle = false,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
hidePrimaryTitle?: boolean;
|
||||||
|
}) {
|
||||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||||
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
|
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
|
||||||
|
if (type === "request_board_approval") {
|
||||||
|
return <BoardApprovalPayload payload={payload} hideTitle={hidePrimaryTitle} />;
|
||||||
|
}
|
||||||
return <CeoStrategyPayload payload={payload} />;
|
return <CeoStrategyPayload payload={payload} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ export function CommandPalette() {
|
||||||
const { data: issues = [] } = useQuery({
|
const { data: issues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId && open,
|
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: searchedIssues = [] } = useQuery({
|
const { data: searchedIssues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery),
|
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }),
|
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }),
|
||||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { act } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
import type { Agent, Approval } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { CommentThread } from "./CommentThread";
|
import { CommentThread } from "./CommentThread";
|
||||||
|
|
||||||
|
|
@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({
|
||||||
InlineEntitySelector: () => null,
|
InlineEntitySelector: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ApprovalCard", () => ({
|
||||||
|
ApprovalCard: ({
|
||||||
|
approval,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
approval: Approval;
|
||||||
|
onApprove?: () => void;
|
||||||
|
onReject?: () => void;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<div>{approval.type}</div>
|
||||||
|
<div>{String(approval.payload.title ?? "")}</div>
|
||||||
|
{onApprove ? <button type="button" onClick={onApprove}>Approve</button> : null}
|
||||||
|
{onReject ? <button type="button" onClick={onReject}>Reject</button> : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/plugins/slots", () => ({
|
vi.mock("@/plugins/slots", () => ({
|
||||||
PluginSlotOutlet: () => null,
|
PluginSlotOutlet: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -144,4 +163,75 @@ describe("CommentThread", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders linked approvals inline in the timeline", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const agent: Agent = {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "CodexCoder",
|
||||||
|
urlKey: "codexcoder",
|
||||||
|
role: "engineer",
|
||||||
|
title: null,
|
||||||
|
icon: "code",
|
||||||
|
status: "active",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: null,
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: false },
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
const approval: Approval = {
|
||||||
|
id: "approval-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
type: "request_board_approval",
|
||||||
|
requestedByAgentId: "agent-1",
|
||||||
|
requestedByUserId: null,
|
||||||
|
status: "pending",
|
||||||
|
payload: {
|
||||||
|
title: "Approve hosting spend",
|
||||||
|
text: "Estimated monthly cost is $42.",
|
||||||
|
},
|
||||||
|
decisionNote: null,
|
||||||
|
decidedByUserId: null,
|
||||||
|
decidedAt: null,
|
||||||
|
createdAt: new Date("2026-03-11T09:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T09:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentThread
|
||||||
|
comments={[]}
|
||||||
|
linkedApprovals={[approval]}
|
||||||
|
agentMap={new Map([["agent-1", agent]])}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
onApproveApproval={async () => {}}
|
||||||
|
onRejectApproval={async () => {}}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvalRow = container.querySelector("#approval-approval-1") as HTMLDivElement | null;
|
||||||
|
expect(approvalRow).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("request_board_approval");
|
||||||
|
expect(container.textContent).toContain("Approve hosting spend");
|
||||||
|
expect(container.textContent).toContain("Approve");
|
||||||
|
expect(container.textContent).toContain("Reject");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
Approval,
|
||||||
FeedbackDataSharingPreference,
|
FeedbackDataSharingPreference,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
FeedbackVoteValue,
|
FeedbackVoteValue,
|
||||||
|
|
@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { ApprovalCard } from "./ApprovalCard";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||||
|
|
@ -50,6 +51,7 @@ interface CommentReassignment {
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comments: CommentWithRunMeta[];
|
comments: CommentWithRunMeta[];
|
||||||
queuedComments?: CommentWithRunMeta[];
|
queuedComments?: CommentWithRunMeta[];
|
||||||
|
linkedApprovals?: Approval[];
|
||||||
feedbackVotes?: FeedbackVote[];
|
feedbackVotes?: FeedbackVote[];
|
||||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||||
feedbackTermsUrl?: string | null;
|
feedbackTermsUrl?: string | null;
|
||||||
|
|
@ -57,6 +59,12 @@ interface CommentThreadProps {
|
||||||
timelineEvents?: IssueTimelineEvent[];
|
timelineEvents?: IssueTimelineEvent[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||||
|
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||||
|
pendingApprovalAction?: {
|
||||||
|
approvalId: string;
|
||||||
|
action: "approve" | "reject";
|
||||||
|
} | null;
|
||||||
onVote?: (
|
onVote?: (
|
||||||
commentId: string,
|
commentId: string,
|
||||||
vote: FeedbackVoteValue,
|
vote: FeedbackVoteValue,
|
||||||
|
|
@ -375,6 +383,7 @@ function CommentCard({
|
||||||
|
|
||||||
type TimelineItem =
|
type TimelineItem =
|
||||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||||
|
| { kind: "approval"; id: string; createdAtMs: number; approval: Approval }
|
||||||
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
|
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
|
||||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||||
|
|
||||||
|
|
@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
|
onApproveApproval,
|
||||||
|
onRejectApproval,
|
||||||
|
pendingApprovalAction,
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
feedbackDataSharingPreference = "prompt",
|
feedbackDataSharingPreference = "prompt",
|
||||||
feedbackTermsUrl = null,
|
feedbackTermsUrl = null,
|
||||||
|
|
@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({
|
||||||
currentUserId?: string | null;
|
currentUserId?: string | null;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||||
|
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||||
|
pendingApprovalAction?: {
|
||||||
|
approvalId: string;
|
||||||
|
action: "approve" | "reject";
|
||||||
|
} | null;
|
||||||
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
|
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
|
||||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||||
feedbackTermsUrl?: string | null;
|
feedbackTermsUrl?: string | null;
|
||||||
|
|
@ -488,6 +506,24 @@ const TimelineList = memo(function TimelineList({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.kind === "approval") {
|
||||||
|
const approval = item.approval;
|
||||||
|
const isPending = pendingApprovalAction?.approvalId === approval.id;
|
||||||
|
return (
|
||||||
|
<div id={`approval-${approval.id}`} key={`approval:${approval.id}`} className="py-1.5">
|
||||||
|
<ApprovalCard
|
||||||
|
approval={approval}
|
||||||
|
requesterAgent={approval.requestedByAgentId ? agentMap?.get(approval.requestedByAgentId) ?? null : null}
|
||||||
|
onApprove={onApproveApproval ? () => void onApproveApproval(approval.id) : undefined}
|
||||||
|
onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined}
|
||||||
|
detailLink={`/approvals/${approval.id}`}
|
||||||
|
isPending={isPending}
|
||||||
|
pendingAction={isPending ? pendingApprovalAction?.action ?? null : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (item.kind === "run") {
|
if (item.kind === "run") {
|
||||||
const run = item.run;
|
const run = item.run;
|
||||||
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||||
|
|
@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({
|
||||||
export function CommentThread({
|
export function CommentThread({
|
||||||
comments,
|
comments,
|
||||||
queuedComments = [],
|
queuedComments = [],
|
||||||
|
linkedApprovals = [],
|
||||||
feedbackVotes = [],
|
feedbackVotes = [],
|
||||||
feedbackDataSharingPreference = "prompt",
|
feedbackDataSharingPreference = "prompt",
|
||||||
feedbackTermsUrl = null,
|
feedbackTermsUrl = null,
|
||||||
|
|
@ -555,6 +592,9 @@ export function CommentThread({
|
||||||
timelineEvents = [],
|
timelineEvents = [],
|
||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
|
onApproveApproval,
|
||||||
|
onRejectApproval,
|
||||||
|
pendingApprovalAction = null,
|
||||||
onVote,
|
onVote,
|
||||||
onAdd,
|
onAdd,
|
||||||
agentMap,
|
agentMap,
|
||||||
|
|
@ -593,6 +633,12 @@ export function CommentThread({
|
||||||
createdAtMs: new Date(comment.createdAt).getTime(),
|
createdAtMs: new Date(comment.createdAt).getTime(),
|
||||||
comment,
|
comment,
|
||||||
}));
|
}));
|
||||||
|
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
|
||||||
|
kind: "approval",
|
||||||
|
id: approval.id,
|
||||||
|
createdAtMs: new Date(approval.createdAt).getTime(),
|
||||||
|
approval,
|
||||||
|
}));
|
||||||
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
|
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
|
||||||
kind: "event",
|
kind: "event",
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -605,17 +651,18 @@ export function CommentThread({
|
||||||
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
||||||
run,
|
run,
|
||||||
}));
|
}));
|
||||||
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
|
return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => {
|
||||||
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
||||||
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
||||||
const kindOrder = {
|
const kindOrder = {
|
||||||
event: 0,
|
event: 0,
|
||||||
comment: 1,
|
approval: 1,
|
||||||
run: 2,
|
comment: 2,
|
||||||
|
run: 3,
|
||||||
} as const;
|
} as const;
|
||||||
return kindOrder[a.kind] - kindOrder[b.kind];
|
return kindOrder[a.kind] - kindOrder[b.kind];
|
||||||
});
|
});
|
||||||
}, [comments, timelineEvents, linkedRuns]);
|
}, [comments, linkedApprovals, timelineEvents, linkedRuns]);
|
||||||
|
|
||||||
const feedbackVoteByTargetId = useMemo(() => {
|
const feedbackVoteByTargetId = useMemo(() => {
|
||||||
const map = new Map<string, FeedbackVoteValue>();
|
const map = new Map<string, FeedbackVoteValue>();
|
||||||
|
|
@ -754,6 +801,9 @@ export function CommentThread({
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
onApproveApproval={onApproveApproval}
|
||||||
|
onRejectApproval={onRejectApproval}
|
||||||
|
pendingApprovalAction={pendingApprovalAction}
|
||||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||||
onVote={onVote ? handleFeedbackVote : undefined}
|
onVote={onVote ? handleFeedbackVote : undefined}
|
||||||
|
|
|
||||||
265
ui/src/components/DocumentDiffModal.tsx
Normal file
265
ui/src/components/DocumentDiffModal.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { DocumentRevision } from "@paperclipai/shared";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { relativeTime } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
function getRevisionLabel(revision: DocumentRevision) {
|
||||||
|
const actor = revision.createdByUserId
|
||||||
|
? "board"
|
||||||
|
: revision.createdByAgentId
|
||||||
|
? "agent"
|
||||||
|
: "system";
|
||||||
|
return `rev ${revision.revisionNumber} — ${relativeTime(revision.createdAt)} • ${actor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffRow = {
|
||||||
|
kind: "context" | "removed" | "added";
|
||||||
|
oldLineNumber: number | null;
|
||||||
|
newLineNumber: number | null;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildLineDiff(oldText: string, newText: string): DiffRow[] {
|
||||||
|
const oldLines = oldText.split("\n");
|
||||||
|
const newLines = newText.split("\n");
|
||||||
|
const oldCount = oldLines.length;
|
||||||
|
const newCount = newLines.length;
|
||||||
|
const dp = Array.from({ length: oldCount + 1 }, () => Array<number>(newCount + 1).fill(0));
|
||||||
|
|
||||||
|
for (let i = oldCount - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = newCount - 1; j >= 0; j -= 1) {
|
||||||
|
dp[i][j] = oldLines[i] === newLines[j]
|
||||||
|
? dp[i + 1][j + 1] + 1
|
||||||
|
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: DiffRow[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
let oldLineNumber = 1;
|
||||||
|
let newLineNumber = 1;
|
||||||
|
|
||||||
|
while (i < oldCount && j < newCount) {
|
||||||
|
if (oldLines[i] === newLines[j]) {
|
||||||
|
rows.push({
|
||||||
|
kind: "context",
|
||||||
|
oldLineNumber,
|
||||||
|
newLineNumber,
|
||||||
|
text: oldLines[i],
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
oldLineNumber += 1;
|
||||||
|
newLineNumber += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
rows.push({
|
||||||
|
kind: "removed",
|
||||||
|
oldLineNumber,
|
||||||
|
newLineNumber: null,
|
||||||
|
text: oldLines[i],
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
oldLineNumber += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
kind: "added",
|
||||||
|
oldLineNumber: null,
|
||||||
|
newLineNumber,
|
||||||
|
text: newLines[j],
|
||||||
|
});
|
||||||
|
j += 1;
|
||||||
|
newLineNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < oldCount) {
|
||||||
|
rows.push({
|
||||||
|
kind: "removed",
|
||||||
|
oldLineNumber,
|
||||||
|
newLineNumber: null,
|
||||||
|
text: oldLines[i],
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
oldLineNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j < newCount) {
|
||||||
|
rows.push({
|
||||||
|
kind: "added",
|
||||||
|
oldLineNumber: null,
|
||||||
|
newLineNumber,
|
||||||
|
text: newLines[j],
|
||||||
|
});
|
||||||
|
j += 1;
|
||||||
|
newLineNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentDiffModal({
|
||||||
|
issueId,
|
||||||
|
documentKey,
|
||||||
|
latestRevisionNumber,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
issueId: string;
|
||||||
|
documentKey: string;
|
||||||
|
latestRevisionNumber: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: revisions } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.documentRevisions(issueId, documentKey),
|
||||||
|
queryFn: () => issuesApi.listDocumentRevisions(issueId, documentKey),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedRevisions = useMemo(() => {
|
||||||
|
if (!revisions) return [];
|
||||||
|
return [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber);
|
||||||
|
}, [revisions]);
|
||||||
|
|
||||||
|
// Default: compare previous (latestRevisionNumber - 1) with current (latestRevisionNumber)
|
||||||
|
const [leftRevisionId, setLeftRevisionId] = useState<string | null>(null);
|
||||||
|
const [rightRevisionId, setRightRevisionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveLeftId = leftRevisionId ?? sortedRevisions.find(
|
||||||
|
(r) => r.revisionNumber === latestRevisionNumber - 1,
|
||||||
|
)?.id ?? null;
|
||||||
|
|
||||||
|
const effectiveRightId = rightRevisionId ?? sortedRevisions.find(
|
||||||
|
(r) => r.revisionNumber === latestRevisionNumber,
|
||||||
|
)?.id ?? null;
|
||||||
|
|
||||||
|
const leftRevision = sortedRevisions.find((r) => r.id === effectiveLeftId) ?? null;
|
||||||
|
const rightRevision = sortedRevisions.find((r) => r.id === effectiveRightId) ?? null;
|
||||||
|
|
||||||
|
const leftBody = leftRevision?.body ?? "";
|
||||||
|
const rightBody = rightRevision?.body ?? "";
|
||||||
|
const diffRows = useMemo(() => buildLineDiff(leftBody, rightBody), [leftBody, rightBody]);
|
||||||
|
|
||||||
|
const lineClassesByKind: Record<DiffRow["kind"], string> = {
|
||||||
|
context: "bg-transparent",
|
||||||
|
removed: "bg-red-500/10 text-red-100",
|
||||||
|
added: "bg-green-500/10 text-green-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
const markerByKind: Record<DiffRow["kind"], string> = {
|
||||||
|
context: " ",
|
||||||
|
removed: "-",
|
||||||
|
added: "+",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="!max-w-[90%] w-full max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle>
|
||||||
|
Diff — <span className="font-mono text-sm">{documentKey}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-red-400">Old</span>
|
||||||
|
<Select
|
||||||
|
value={effectiveLeftId ?? ""}
|
||||||
|
onValueChange={(value) => setLeftRevisionId(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
|
||||||
|
<SelectValue placeholder="Select revision" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortedRevisions.map((revision) => (
|
||||||
|
<SelectItem key={revision.id} value={revision.id} className="text-xs">
|
||||||
|
{getRevisionLabel(revision)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-green-400">New</span>
|
||||||
|
<Select
|
||||||
|
value={effectiveRightId ?? ""}
|
||||||
|
onValueChange={(value) => setRightRevisionId(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
|
||||||
|
<SelectValue placeholder="Select revision" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortedRevisions.map((revision) => (
|
||||||
|
<SelectItem key={revision.id} value={revision.id} className="text-xs">
|
||||||
|
{getRevisionLabel(revision)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto flex-1 rounded-md border border-border text-xs">
|
||||||
|
{!revisions ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground text-sm">Loading revisions...</div>
|
||||||
|
) : !leftRevision || !rightRevision ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground text-sm">Select two revisions to compare.</div>
|
||||||
|
) : leftRevision.id === rightRevision.id ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground text-sm">Both sides are the same revision.</div>
|
||||||
|
) : (
|
||||||
|
<div className="font-mono text-[12px] leading-6">
|
||||||
|
<div className="grid grid-cols-[56px_56px_24px_minmax(0,1fr)] border-b border-border/60 bg-muted/30 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span>Old</span>
|
||||||
|
<span>New</span>
|
||||||
|
<span />
|
||||||
|
<span>Content</span>
|
||||||
|
</div>
|
||||||
|
{diffRows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={`${row.kind}-${index}-${row.oldLineNumber ?? "x"}-${row.newLineNumber ?? "x"}`}
|
||||||
|
className={`grid grid-cols-[56px_56px_24px_minmax(0,1fr)] gap-0 border-b border-border/30 px-3 ${lineClassesByKind[row.kind]}`}
|
||||||
|
>
|
||||||
|
<span className="select-none border-r border-border/30 pr-3 text-right text-muted-foreground">
|
||||||
|
{row.oldLineNumber ?? ""}
|
||||||
|
</span>
|
||||||
|
<span className="select-none border-r border-border/30 px-3 text-right text-muted-foreground">
|
||||||
|
{row.newLineNumber ?? ""}
|
||||||
|
</span>
|
||||||
|
<span className="select-none px-3 text-center text-muted-foreground">
|
||||||
|
{markerByKind[row.kind]}
|
||||||
|
</span>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-0 text-inherit">
|
||||||
|
{row.text.length > 0 ? row.text : " "}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
placeholder?: string;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
|
/** Called when a non-image file is dropped onto the editor. */
|
||||||
|
onDropFile?: (file: File) => Promise<void>;
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +48,7 @@ export function InlineEditor({
|
||||||
multiline = false,
|
multiline = false,
|
||||||
nullable = false,
|
nullable = false,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
|
onDropFile,
|
||||||
mentions,
|
mentions,
|
||||||
}: InlineEditorProps) {
|
}: InlineEditorProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
@ -228,6 +231,7 @@ export function InlineEditor({
|
||||||
className="bg-transparent"
|
className="bg-transparent"
|
||||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||||
imageUploadHandler={imageUploadHandler}
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
onDropFile={onDropFile}
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
finalizeMultilineBlurOrSubmit();
|
finalizeMultilineBlurOrSubmit();
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { DocumentDiffModal } from "./DocumentDiffModal";
|
||||||
|
|
||||||
type DraftState = {
|
type DraftState = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -162,6 +163,7 @@ export function IssueDocumentsSection({
|
||||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||||
|
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasScrolledToHashRef = useRef(false);
|
const hasScrolledToHashRef = useRef(false);
|
||||||
|
|
@ -929,6 +931,12 @@ export function IssueDocumentsSection({
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Download document
|
Download document
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{doc.latestRevisionNumber > 1 ? (
|
||||||
|
<DropdownMenuItem onClick={() => setDiffViewKey(doc.key)}>
|
||||||
|
<Diff className="h-3.5 w-3.5" />
|
||||||
|
View diff
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||||
{canDeleteDocuments ? (
|
{canDeleteDocuments ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -1174,6 +1182,20 @@ export function IssueDocumentsSection({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{diffViewKey && (() => {
|
||||||
|
const diffDoc = sortedDocuments.find((d) => d.key === diffViewKey);
|
||||||
|
if (!diffDoc) return null;
|
||||||
|
return (
|
||||||
|
<DocumentDiffModal
|
||||||
|
issueId={issue.id}
|
||||||
|
documentKey={diffDoc.key}
|
||||||
|
latestRevisionNumber={diffDoc.latestRevisionNumber}
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => { if (!open) setDiffViewKey(null); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
ui/src/components/IssueProperties.test.tsx
Normal file
204
ui/src/components/IssueProperties.test.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { IssueProperties } from "./IssueProperties";
|
||||||
|
|
||||||
|
const mockAgentsApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProjectsApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIssuesApi = vi.hoisted(() => ({
|
||||||
|
listLabels: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuthApi = vi.hoisted(() => ({
|
||||||
|
getSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/agents", () => ({
|
||||||
|
agentsApi: mockAgentsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/projects", () => ({
|
||||||
|
projectsApi: mockProjectsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/issues", () => ({
|
||||||
|
issuesApi: mockIssuesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/auth", () => ({
|
||||||
|
authApi: mockAuthApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useProjectOrder", () => ({
|
||||||
|
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
|
||||||
|
orderedProjects: projects,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/recent-assignees", () => ({
|
||||||
|
getRecentAssigneeIds: () => [],
|
||||||
|
sortAgentsByRecency: (agents: unknown[]) => agents,
|
||||||
|
trackRecentAssignee: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/assignees", () => ({
|
||||||
|
formatAssigneeUserLabel: () => "Me",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./StatusIcon", () => ({
|
||||||
|
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./PriorityIcon", () => ({
|
||||||
|
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./Identity", () => ({
|
||||||
|
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./AgentIconPicker", () => ({
|
||||||
|
AgentIcon: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/separator", () => ({
|
||||||
|
Separator: () => <hr />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/popover", () => ({
|
||||||
|
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Parent issue",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
issueNumber: 1,
|
||||||
|
identifier: "PAP-1",
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
blockedBy: [],
|
||||||
|
blocks: [],
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueProperties {...props} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IssueProperties", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockAgentsApi.list.mockResolvedValue([]);
|
||||||
|
mockProjectsApi.list.mockResolvedValue([]);
|
||||||
|
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||||
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always exposes the add sub-issue action", async () => {
|
||||||
|
const onAddSubIssue = vi.fn();
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue(),
|
||||||
|
childIssues: [],
|
||||||
|
onAddSubIssue,
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Sub-issues");
|
||||||
|
expect(container.textContent).toContain("Add sub-issue");
|
||||||
|
|
||||||
|
const addButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent?.includes("Add sub-issue"));
|
||||||
|
expect(addButton).not.toBeUndefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAddSubIssue).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
@ -19,9 +19,40 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
|
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
|
||||||
|
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm font-mono min-w-0 break-all">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={copied ? "Copied!" : "Copy"}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function defaultProjectWorkspaceIdForProject(project: {
|
function defaultProjectWorkspaceIdForProject(project: {
|
||||||
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
||||||
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
||||||
|
|
@ -42,6 +73,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||||
|
|
||||||
interface IssuePropertiesProps {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
|
childIssues?: Issue[];
|
||||||
|
onAddSubIssue?: () => void;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +150,13 @@ function PropertyPicker({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
export function IssueProperties({
|
||||||
|
issue,
|
||||||
|
childIssues = [],
|
||||||
|
onAddSubIssue,
|
||||||
|
onUpdate,
|
||||||
|
inline,
|
||||||
|
}: IssuePropertiesProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
|
|
@ -683,6 +722,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
)}
|
)}
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
||||||
|
<PropertyRow label="Sub-issues">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{childIssues.length > 0 ? (
|
||||||
|
childIssues.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.id}
|
||||||
|
to={`/issues/${child.identifier ?? child.id}`}
|
||||||
|
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{child.identifier ?? child.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
|
{onAddSubIssue ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
onClick={onAddSubIssue}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
Add sub-issue
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PropertyRow>
|
||||||
|
|
||||||
{issue.parentId && (
|
{issue.parentId && (
|
||||||
<PropertyRow label="Parent">
|
<PropertyRow label="Parent">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -700,6 +767,30 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{issue.currentExecutionWorkspace?.branchName && (
|
||||||
|
<PropertyRow label="Branch">
|
||||||
|
<TruncatedCopyable
|
||||||
|
value={issue.currentExecutionWorkspace.branchName}
|
||||||
|
icon={GitBranch}
|
||||||
|
/>
|
||||||
|
</PropertyRow>
|
||||||
|
)}
|
||||||
|
{issue.currentExecutionWorkspace?.cwd && (
|
||||||
|
<PropertyRow label="Folder">
|
||||||
|
<TruncatedCopyable
|
||||||
|
value={issue.currentExecutionWorkspace.cwd}
|
||||||
|
icon={FolderOpen}
|
||||||
|
/>
|
||||||
|
</PropertyRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,7 @@ describe("IssueRow", () => {
|
||||||
|
|
||||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
|
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toBe("/issues/PAP-1");
|
||||||
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
|
@ -51,9 +51,10 @@ export function IssueRow({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
to={createIssueDetailPath(issuePathId)}
|
||||||
state={issueLinkState}
|
state={issueLinkState}
|
||||||
data-inbox-issue-link
|
data-inbox-issue-link
|
||||||
|
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||||
|
|
|
||||||
187
ui/src/components/IssuesList.test.tsx
Normal file
187
ui/src/components/IssuesList.test.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { IssuesList } from "./IssuesList";
|
||||||
|
|
||||||
|
const companyState = vi.hoisted(() => ({
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dialogState = vi.hoisted(() => ({
|
||||||
|
openNewIssue: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIssuesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
listLabels: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuthApi = vi.hoisted(() => ({
|
||||||
|
getSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => companyState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/DialogContext", () => ({
|
||||||
|
useDialog: () => dialogState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/issues", () => ({
|
||||||
|
issuesApi: mockIssuesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/auth", () => ({
|
||||||
|
authApi: mockAuthApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./IssueRow", () => ({
|
||||||
|
IssueRow: ({ issue }: { issue: Issue }) => <div data-testid="issue-row">{issue.title}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./KanbanBoard", () => ({
|
||||||
|
KanbanBoard: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Issue title",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 1,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-07T00:00:00.000Z"),
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
myLastTouchAt: null,
|
||||||
|
lastExternalCommentAt: null,
|
||||||
|
isUnreadForMe: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAssertion(assertion: () => void, attempts = 20) {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
assertion();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{node}
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { root, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IssuesList", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
dialogState.openNewIssue.mockReset();
|
||||||
|
mockIssuesApi.list.mockReset();
|
||||||
|
mockIssuesApi.listLabels.mockReset();
|
||||||
|
mockAuthApi.getSession.mockReset();
|
||||||
|
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||||
|
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders server search results instead of filtering the full issue list locally", async () => {
|
||||||
|
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||||
|
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||||
|
|
||||||
|
mockIssuesApi.list.mockResolvedValue([serverIssue]);
|
||||||
|
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={[localIssue]}
|
||||||
|
agents={[]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
initialSearch="server"
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", projectId: undefined });
|
||||||
|
expect(container.textContent).toContain("Server result");
|
||||||
|
expect(container.textContent).not.toContain("Local issue");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -145,18 +145,6 @@ function countActiveFilters(state: IssueViewState): number {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean {
|
|
||||||
if (!normalizedSearch) return true;
|
|
||||||
|
|
||||||
return [
|
|
||||||
issue.identifier,
|
|
||||||
issue.title,
|
|
||||||
issue.description,
|
|
||||||
]
|
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
.some((value) => value.toLowerCase().includes(normalizedSearch));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Component ── */
|
/* ── Component ── */
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
|
|
@ -278,12 +266,10 @@ export function IssuesList({
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const sourceIssues = normalizedIssueSearch.length > 0
|
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||||
? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch))
|
|
||||||
: issues;
|
|
||||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||||
return sortIssues(filteredByControls, viewState);
|
return sortIssues(filteredByControls, viewState);
|
||||||
}, [issues, viewState, normalizedIssueSearch, currentUserId]);
|
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||||
|
|
||||||
const { data: labels } = useQuery({
|
const { data: labels } = useQuery({
|
||||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
|
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
|
||||||
const mdxEditorMockState = vi.hoisted(() => ({
|
const mdxEditorMockState = vi.hoisted(() => ({
|
||||||
emitMountEmptyReset: false,
|
emitMountEmptyReset: false,
|
||||||
|
|
@ -186,4 +186,31 @@ describe("MarkdownEditor", () => {
|
||||||
left: 92,
|
left: 92,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a short mention menu on the same line when it fits below the caret", () => {
|
||||||
|
expect(
|
||||||
|
computeMentionMenuPosition(
|
||||||
|
{ viewportTop: 160, viewportLeft: 120 },
|
||||||
|
{ offsetLeft: 0, offsetTop: 0, width: 320, height: 220 },
|
||||||
|
{ width: 188, height: 42 },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
top: 164,
|
||||||
|
left: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps mention queries active across spaces", () => {
|
||||||
|
expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({
|
||||||
|
trigger: "mention",
|
||||||
|
marker: "@",
|
||||||
|
query: "Paperclip App",
|
||||||
|
atPos: 5,
|
||||||
|
endPos: "Ping @Paperclip App".length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects slash commands once spaces are typed", () => {
|
||||||
|
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ interface MarkdownEditorProps {
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
|
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
|
||||||
|
onDropFile?: (file: File) => Promise<void>;
|
||||||
bordered?: boolean;
|
bordered?: boolean;
|
||||||
/** List of mentionable entities. Enables @-mention autocomplete. */
|
/** List of mentionable entities. Enables @-mention autocomplete. */
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
|
|
@ -108,9 +110,16 @@ interface MentionMenuViewport {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MentionMenuSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
const MENTION_MENU_WIDTH = 188;
|
const MENTION_MENU_WIDTH = 188;
|
||||||
const MENTION_MENU_HEIGHT = 208;
|
const MENTION_MENU_HEIGHT = 208;
|
||||||
const MENTION_MENU_PADDING = 8;
|
const MENTION_MENU_PADDING = 8;
|
||||||
|
const MENTION_MENU_ROW_HEIGHT = 34;
|
||||||
|
const MENTION_MENU_CHROME_HEIGHT = 8;
|
||||||
|
|
||||||
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||||
txt: "Text",
|
txt: "Text",
|
||||||
|
|
@ -140,19 +149,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
|
||||||
Editor: CodeMirrorEditor,
|
Editor: CodeMirrorEditor,
|
||||||
};
|
};
|
||||||
|
|
||||||
function detectMention(container: HTMLElement): MentionState | null {
|
export function findMentionMatch(
|
||||||
const sel = window.getSelection();
|
text: string,
|
||||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
offset: number,
|
||||||
|
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | null {
|
||||||
const range = sel.getRangeAt(0);
|
|
||||||
const textNode = range.startContainer;
|
|
||||||
if (textNode.nodeType !== Node.TEXT_NODE) return null;
|
|
||||||
if (!container.contains(textNode)) return null;
|
|
||||||
|
|
||||||
const text = textNode.textContent ?? "";
|
|
||||||
const offset = range.startOffset;
|
|
||||||
|
|
||||||
// Walk backwards from cursor to find an autocomplete trigger.
|
|
||||||
let atPos = -1;
|
let atPos = -1;
|
||||||
let trigger: MentionState["trigger"] | null = null;
|
let trigger: MentionState["trigger"] | null = null;
|
||||||
let marker: MentionState["marker"] | null = null;
|
let marker: MentionState["marker"] | null = null;
|
||||||
|
|
@ -166,31 +166,54 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (/\s/.test(ch)) break;
|
if (ch === "\n" || ch === "\r") break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atPos === -1) return null;
|
if (atPos === -1) return null;
|
||||||
|
|
||||||
const query = text.slice(atPos + 1, offset);
|
const query = text.slice(atPos + 1, offset);
|
||||||
|
if (trigger === "skill" && /\s/.test(query)) return null;
|
||||||
// Get position relative to container
|
|
||||||
const tempRange = document.createRange();
|
|
||||||
tempRange.setStart(textNode, atPos);
|
|
||||||
tempRange.setEnd(textNode, atPos + 1);
|
|
||||||
const rect = tempRange.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trigger: trigger ?? "mention",
|
trigger: trigger ?? "mention",
|
||||||
marker: marker ?? "@",
|
marker: marker ?? "@",
|
||||||
query,
|
query,
|
||||||
|
atPos,
|
||||||
|
endPos: offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMention(container: HTMLElement): MentionState | null {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||||
|
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const textNode = range.startContainer;
|
||||||
|
if (textNode.nodeType !== Node.TEXT_NODE) return null;
|
||||||
|
if (!container.contains(textNode)) return null;
|
||||||
|
|
||||||
|
const text = textNode.textContent ?? "";
|
||||||
|
const offset = range.startOffset;
|
||||||
|
const match = findMentionMatch(text, offset);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
// Get position relative to container
|
||||||
|
const tempRange = document.createRange();
|
||||||
|
tempRange.setStart(textNode, match.atPos);
|
||||||
|
tempRange.setEnd(textNode, match.atPos + 1);
|
||||||
|
const rect = tempRange.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
trigger: match.trigger,
|
||||||
|
marker: match.marker,
|
||||||
|
query: match.query,
|
||||||
top: rect.bottom - containerRect.top,
|
top: rect.bottom - containerRect.top,
|
||||||
left: rect.left - containerRect.left,
|
left: rect.left - containerRect.left,
|
||||||
viewportTop: rect.bottom,
|
viewportTop: rect.bottom,
|
||||||
viewportLeft: rect.left,
|
viewportLeft: rect.left,
|
||||||
textNode: textNode as Text,
|
textNode: textNode as Text,
|
||||||
atPos,
|
atPos: match.atPos,
|
||||||
endPos: offset,
|
endPos: match.endPos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,11 +239,12 @@ function getMentionMenuViewport(): MentionMenuViewport {
|
||||||
export function computeMentionMenuPosition(
|
export function computeMentionMenuPosition(
|
||||||
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
|
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
|
||||||
viewport: MentionMenuViewport,
|
viewport: MentionMenuViewport,
|
||||||
|
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
|
||||||
) {
|
) {
|
||||||
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
|
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
|
||||||
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH;
|
const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width;
|
||||||
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
|
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
|
||||||
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
|
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
|
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
|
||||||
|
|
@ -228,6 +252,17 @@ export function computeMentionMenuPosition(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMentionMenuSize(optionCount: number): MentionMenuSize {
|
||||||
|
const visibleRows = Math.max(1, Math.min(optionCount, 8));
|
||||||
|
return {
|
||||||
|
width: MENTION_MENU_WIDTH,
|
||||||
|
height: Math.min(
|
||||||
|
MENTION_MENU_HEIGHT,
|
||||||
|
visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||||
if (!node || !container.contains(node)) return false;
|
if (!node || !container.contains(node)) return false;
|
||||||
const el = node.nodeType === Node.ELEMENT_NODE
|
const el = node.nodeType === Node.ELEMENT_NODE
|
||||||
|
|
@ -281,6 +316,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
contentClassName,
|
contentClassName,
|
||||||
onBlur,
|
onBlur,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
|
onDropFile,
|
||||||
bordered = true,
|
bordered = true,
|
||||||
mentions,
|
mentions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -635,6 +671,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDropImage = Boolean(imageUploadHandler);
|
const canDropImage = Boolean(imageUploadHandler);
|
||||||
|
const canDropFile = Boolean(imageUploadHandler || onDropFile);
|
||||||
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const clipboard = event.clipboardData;
|
const clipboard = event.clipboardData;
|
||||||
if (!clipboard || !ref.current) return;
|
if (!clipboard || !ref.current) return;
|
||||||
|
|
@ -650,7 +687,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mentionMenuPosition = mentionState
|
const mentionMenuPosition = mentionState
|
||||||
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
|
? computeMentionMenuPosition(
|
||||||
|
mentionState,
|
||||||
|
getMentionMenuViewport(),
|
||||||
|
getMentionMenuSize(filteredMentions.length),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -673,8 +714,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
|
|
||||||
// Mention keyboard handling
|
// Mention keyboard handling
|
||||||
if (mentionActive) {
|
if (mentionActive) {
|
||||||
// Space dismisses the popup (let the character be typed normally)
|
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
|
||||||
if (e.key === " ") {
|
|
||||||
mentionStateRef.current = null;
|
mentionStateRef.current = null;
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -711,23 +751,41 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnter={(evt) => {
|
onDragEnter={(evt) => {
|
||||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||||
dragDepthRef.current += 1;
|
dragDepthRef.current += 1;
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}}
|
}}
|
||||||
onDragOver={(evt) => {
|
onDragOver={(evt) => {
|
||||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.dataTransfer.dropEffect = "copy";
|
evt.dataTransfer.dropEffect = "copy";
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => {
|
onDragLeave={() => {
|
||||||
if (!canDropImage) return;
|
if (!canDropFile) return;
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||||
}}
|
}}
|
||||||
onDrop={() => {
|
onDrop={(evt) => {
|
||||||
dragDepthRef.current = 0;
|
dragDepthRef.current = 0;
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
if (!onDropFile) return;
|
||||||
|
const files = evt.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const allFiles = Array.from(files);
|
||||||
|
const nonImageFiles = allFiles.filter(
|
||||||
|
(f) => !f.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
if (nonImageFiles.length === 0) return;
|
||||||
|
// If all dropped files are non-image, prevent default so MDXEditor
|
||||||
|
// doesn't try to handle them. If mixed, let images flow through to
|
||||||
|
// the image plugin and only handle the non-image files ourselves.
|
||||||
|
if (nonImageFiles.length === allFiles.length) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
}
|
||||||
|
for (const file of nonImageFiles) {
|
||||||
|
void onDropFile(file);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onPasteCapture={handlePasteCapture}
|
onPasteCapture={handlePasteCapture}
|
||||||
>
|
>
|
||||||
|
|
@ -818,14 +876,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDragOver && canDropImage && (
|
{isDragOver && canDropFile && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
|
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
|
||||||
!bordered && "inset-0 rounded-sm",
|
!bordered && "inset-0 rounded-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Drop image to upload
|
Drop {onDropFile ? "file" : "image"} to upload
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
|
|
|
||||||
439
ui/src/components/NewIssueDialog.test.tsx
Normal file
439
ui/src/components/NewIssueDialog.test.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { NewIssueDialog } from "./NewIssueDialog";
|
||||||
|
|
||||||
|
const dialogState = vi.hoisted(() => ({
|
||||||
|
newIssueOpen: true,
|
||||||
|
newIssueDefaults: {} as Record<string, unknown>,
|
||||||
|
closeNewIssue: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const companyState = vi.hoisted(() => ({
|
||||||
|
companies: [
|
||||||
|
{
|
||||||
|
id: "company-1",
|
||||||
|
name: "Paperclip",
|
||||||
|
status: "active",
|
||||||
|
brandColor: "#123456",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
selectedCompany: {
|
||||||
|
id: "company-1",
|
||||||
|
name: "Paperclip",
|
||||||
|
status: "active",
|
||||||
|
brandColor: "#123456",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toastState = vi.hoisted(() => ({
|
||||||
|
pushToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIssuesApi = vi.hoisted(() => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
upsertDocument: vi.fn(),
|
||||||
|
uploadAttachment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProjectsApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAgentsApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
adapterModels: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuthApi = vi.hoisted(() => ({
|
||||||
|
getSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAssetsApi = vi.hoisted(() => ({
|
||||||
|
uploadImage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
getExperimental: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/DialogContext", () => ({
|
||||||
|
useDialog: () => dialogState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => companyState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/ToastContext", () => ({
|
||||||
|
useToast: () => toastState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/issues", () => ({
|
||||||
|
issuesApi: mockIssuesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/execution-workspaces", () => ({
|
||||||
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/projects", () => ({
|
||||||
|
projectsApi: mockProjectsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/agents", () => ({
|
||||||
|
agentsApi: mockAgentsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/auth", () => ({
|
||||||
|
authApi: mockAuthApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/assets", () => ({
|
||||||
|
assetsApi: mockAssetsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: mockInstanceSettingsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useProjectOrder", () => ({
|
||||||
|
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
|
||||||
|
orderedProjects: projects,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/recent-assignees", () => ({
|
||||||
|
getRecentAssigneeIds: () => [],
|
||||||
|
sortAgentsByRecency: (agents: unknown[]) => agents,
|
||||||
|
trackRecentAssignee: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/assignees", () => ({
|
||||||
|
assigneeValueFromSelection: ({
|
||||||
|
assigneeAgentId,
|
||||||
|
assigneeUserId,
|
||||||
|
}: {
|
||||||
|
assigneeAgentId?: string;
|
||||||
|
assigneeUserId?: string;
|
||||||
|
}) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "",
|
||||||
|
currentUserAssigneeOption: () => [],
|
||||||
|
parseAssigneeValue: (value: string) => ({
|
||||||
|
assigneeAgentId: value.startsWith("agent:") ? value.slice("agent:".length) : null,
|
||||||
|
assigneeUserId: value.startsWith("user:") ? value.slice("user:".length) : null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./MarkdownEditor", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
return {
|
||||||
|
MarkdownEditor: React.forwardRef<
|
||||||
|
{ focus: () => void },
|
||||||
|
{ value: string; onChange?: (value: string) => void; placeholder?: string }
|
||||||
|
>(function MarkdownEditorMock({ value, onChange, placeholder }, ref) {
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => undefined,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
aria-label={placeholder ?? "Description"}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./InlineEntitySelector", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
return {
|
||||||
|
InlineEntitySelector: React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
{
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
|
||||||
|
}
|
||||||
|
>(function InlineEntitySelectorMock({ value, placeholder, renderTriggerValue }, ref) {
|
||||||
|
return (
|
||||||
|
<button ref={ref} type="button">
|
||||||
|
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./AgentIconPicker", () => ({
|
||||||
|
AgentIcon: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dialog", () => ({
|
||||||
|
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
||||||
|
DialogContent: ({
|
||||||
|
children,
|
||||||
|
showCloseButton: _showCloseButton,
|
||||||
|
onEscapeKeyDown: _onEscapeKeyDown,
|
||||||
|
onPointerDownOutside: _onPointerDownOutside,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
onEscapeKeyDown?: (event: unknown) => void;
|
||||||
|
onPointerDownOutside?: (event: unknown) => void;
|
||||||
|
}) => <div {...props}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/button", () => ({
|
||||||
|
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
||||||
|
<button type={type} onClick={onClick} {...props}>{children}</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/toggle-switch", () => ({
|
||||||
|
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
|
||||||
|
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/popover", () => ({
|
||||||
|
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialog(container: HTMLDivElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<NewIssueDialog />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { root, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NewIssueDialog", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
dialogState.newIssueOpen = true;
|
||||||
|
dialogState.newIssueDefaults = {};
|
||||||
|
dialogState.closeNewIssue.mockReset();
|
||||||
|
toastState.pushToast.mockReset();
|
||||||
|
mockIssuesApi.create.mockReset();
|
||||||
|
mockIssuesApi.upsertDocument.mockReset();
|
||||||
|
mockIssuesApi.uploadAttachment.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
mockProjectsApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Alpha",
|
||||||
|
description: null,
|
||||||
|
archivedAt: null,
|
||||||
|
color: "#445566",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockAgentsApi.list.mockResolvedValue([]);
|
||||||
|
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||||
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||||
|
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/uploads/asset.png" });
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
|
mockIssuesApi.create.mockResolvedValue({
|
||||||
|
id: "issue-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows sub-issue context only when opened from a sub-issue action", async () => {
|
||||||
|
dialogState.newIssueDefaults = {
|
||||||
|
parentId: "issue-1",
|
||||||
|
parentIdentifier: "PAP-1",
|
||||||
|
parentTitle: "Parent issue",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { root } = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("New sub-issue");
|
||||||
|
expect(container.textContent).toContain("Sub-issue of");
|
||||||
|
expect(container.textContent).toContain("PAP-1");
|
||||||
|
expect(container.textContent).toContain("Parent issue");
|
||||||
|
expect(container.textContent).toContain("Create Sub-Issue");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
|
||||||
|
dialogState.newIssueDefaults = {};
|
||||||
|
const rerendered = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("New issue");
|
||||||
|
expect(container.textContent).toContain("Create Issue");
|
||||||
|
expect(container.textContent).not.toContain("Sub-issue of");
|
||||||
|
|
||||||
|
act(() => rerendered.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits parent and goal context for sub-issues", async () => {
|
||||||
|
mockProjectsApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Alpha",
|
||||||
|
description: null,
|
||||||
|
archivedAt: null,
|
||||||
|
color: "#445566",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
name: "Parent workspace",
|
||||||
|
status: "active",
|
||||||
|
branchName: "feature/pap-1",
|
||||||
|
cwd: "/tmp/workspace-1",
|
||||||
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||||
|
dialogState.newIssueDefaults = {
|
||||||
|
parentId: "issue-1",
|
||||||
|
parentIdentifier: "PAP-1",
|
||||||
|
parentTitle: "Parent issue",
|
||||||
|
title: "Child issue",
|
||||||
|
projectId: "project-1",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { root } = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent?.includes("Create Sub-Issue"));
|
||||||
|
expect(submitButton).not.toBeUndefined();
|
||||||
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Child issue",
|
||||||
|
parentId: "issue-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when a sub-issue stops matching the parent workspace", async () => {
|
||||||
|
mockProjectsApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Alpha",
|
||||||
|
description: null,
|
||||||
|
archivedAt: null,
|
||||||
|
color: "#445566",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
name: "Parent workspace",
|
||||||
|
status: "active",
|
||||||
|
branchName: "feature/pap-1",
|
||||||
|
cwd: "/tmp/workspace-1",
|
||||||
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-2",
|
||||||
|
name: "Other workspace",
|
||||||
|
status: "active",
|
||||||
|
branchName: "feature/pap-2",
|
||||||
|
cwd: "/tmp/workspace-2",
|
||||||
|
lastUsedAt: new Date("2026-04-06T16:01:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||||
|
dialogState.newIssueDefaults = {
|
||||||
|
parentId: "issue-1",
|
||||||
|
parentIdentifier: "PAP-1",
|
||||||
|
parentTitle: "Parent issue",
|
||||||
|
title: "Child issue",
|
||||||
|
projectId: "project-1",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
parentExecutionWorkspaceLabel: "Parent workspace",
|
||||||
|
goalId: "goal-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { root } = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
|
||||||
|
|
||||||
|
const selects = Array.from(container.querySelectorAll("select"));
|
||||||
|
const modeSelect = selects[0] as HTMLSelectElement | undefined;
|
||||||
|
expect(modeSelect).not.toBeUndefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
modeSelect!.value = "shared_workspace";
|
||||||
|
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("will no longer use the parent issue workspace");
|
||||||
|
expect(container.textContent).toContain("Parent workspace");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
Paperclip,
|
Paperclip,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ListTree,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -297,6 +298,11 @@ export function NewIssueDialog() {
|
||||||
|
|
||||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||||
|
const isSubIssueMode = Boolean(newIssueDefaults.parentId);
|
||||||
|
const parentIssueLabel = newIssueDefaults.parentIdentifier
|
||||||
|
?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : "");
|
||||||
|
const parentExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId ?? "";
|
||||||
|
const parentExecutionWorkspaceLabel = newIssueDefaults.parentExecutionWorkspaceLabel ?? parentExecutionWorkspaceId;
|
||||||
|
|
||||||
// Popover states
|
// Popover states
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
|
@ -510,7 +516,28 @@ export function NewIssueDialog() {
|
||||||
executionWorkspaceDefaultProjectId.current = null;
|
executionWorkspaceDefaultProjectId.current = null;
|
||||||
|
|
||||||
const draft = loadDraft();
|
const draft = loadDraft();
|
||||||
if (newIssueDefaults.title) {
|
if (newIssueDefaults.parentId) {
|
||||||
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||||
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||||
|
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
||||||
|
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
||||||
|
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||||
|
? "reuse_existing"
|
||||||
|
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||||
|
setTitle(newIssueDefaults.title ?? "");
|
||||||
|
setDescription(newIssueDefaults.description ?? "");
|
||||||
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
|
setProjectId(defaultProjectId);
|
||||||
|
setProjectWorkspaceId(defaultProjectWorkspaceId);
|
||||||
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
|
setAssigneeModelOverride("");
|
||||||
|
setAssigneeThinkingEffort("");
|
||||||
|
setAssigneeChrome(false);
|
||||||
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceMode);
|
||||||
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||||
|
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||||
|
} else if (newIssueDefaults.title) {
|
||||||
setTitle(newIssueDefaults.title);
|
setTitle(newIssueDefaults.title);
|
||||||
setDescription(newIssueDefaults.description ?? "");
|
setDescription(newIssueDefaults.description ?? "");
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
|
|
@ -616,6 +643,7 @@ export function NewIssueDialog() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCompanyChange(companyId: string) {
|
function handleCompanyChange(companyId: string) {
|
||||||
|
if (isSubIssueMode) return;
|
||||||
if (companyId === effectiveCompanyId) return;
|
if (companyId === effectiveCompanyId) return;
|
||||||
setDialogCompanyId(companyId);
|
setDialogCompanyId(companyId);
|
||||||
setAssigneeValue("");
|
setAssigneeValue("");
|
||||||
|
|
@ -666,6 +694,8 @@ export function NewIssueDialog() {
|
||||||
priority: priority || "medium",
|
priority: priority || "medium",
|
||||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||||
|
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
||||||
|
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
|
||||||
...(projectId ? { projectId } : {}),
|
...(projectId ? { projectId } : {}),
|
||||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||||
|
|
@ -774,6 +804,13 @@ export function NewIssueDialog() {
|
||||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||||
);
|
);
|
||||||
|
const isUsingParentExecutionWorkspace = isSubIssueMode && parentExecutionWorkspaceId
|
||||||
|
? executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId === parentExecutionWorkspaceId
|
||||||
|
: false;
|
||||||
|
const showParentWorkspaceWarning = isSubIssueMode
|
||||||
|
&& currentProjectSupportsExecutionWorkspace
|
||||||
|
&& Boolean(parentExecutionWorkspaceId)
|
||||||
|
&& !isUsingParentExecutionWorkspace;
|
||||||
const assigneeOptionsTitle =
|
const assigneeOptionsTitle =
|
||||||
assigneeAdapterType === "claude_local"
|
assigneeAdapterType === "claude_local"
|
||||||
? "Claude options"
|
? "Claude options"
|
||||||
|
|
@ -908,6 +945,7 @@ export function NewIssueDialog() {
|
||||||
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||||
!dialogCompany?.brandColor && "bg-muted",
|
!dialogCompany?.brandColor && "bg-muted",
|
||||||
)}
|
)}
|
||||||
|
disabled={isSubIssueMode}
|
||||||
style={
|
style={
|
||||||
dialogCompany?.brandColor
|
dialogCompany?.brandColor
|
||||||
? {
|
? {
|
||||||
|
|
@ -955,7 +993,7 @@ export function NewIssueDialog() {
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<span className="text-muted-foreground/60">›</span>
|
<span className="text-muted-foreground/60">›</span>
|
||||||
<span>New issue</span>
|
<span>{isSubIssueMode ? "New sub-issue" : "New issue"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1119,6 +1157,23 @@ export function NewIssueDialog() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSubIssueMode ? (
|
||||||
|
<div className="px-4 pb-2 shrink-0">
|
||||||
|
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ListTree className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="shrink-0">Sub-issue of</span>
|
||||||
|
<span className="font-medium text-foreground">{parentIssueLabel}</span>
|
||||||
|
</div>
|
||||||
|
{newIssueDefaults.parentTitle ? (
|
||||||
|
<div className="pl-5 text-foreground/80 truncate">
|
||||||
|
{newIssueDefaults.parentTitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||||
<div className="px-4 py-3 shrink-0 space-y-2">
|
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -1161,6 +1216,11 @@ export function NewIssueDialog() {
|
||||||
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showParentWorkspaceWarning ? (
|
||||||
|
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
|
Warning: this sub-issue will no longer use the parent issue workspace{parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1455,7 +1515,7 @@ export function NewIssueDialog() {
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1.5">
|
<span className="inline-flex items-center justify-center gap-1.5">
|
||||||
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
|
<span>{createIssue.isPending ? "Creating..." : isSubIssueMode ? "Create Sub-Issue" : "Create Issue"}</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { cn, formatDate } from "../lib/utils";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { secretsApi } from "../api/secrets";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||||
|
|
@ -19,6 +20,7 @@ import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { DraftInput } from "./agent-config-primitives";
|
import { DraftInput } from "./agent-config-primitives";
|
||||||
import { InlineEditor } from "./InlineEditor";
|
import { InlineEditor } from "./InlineEditor";
|
||||||
|
import { EnvVarEditor } from "./EnvVarEditor";
|
||||||
|
|
||||||
const PROJECT_STATUSES = [
|
const PROJECT_STATUSES = [
|
||||||
{ value: "backlog", label: "Backlog" },
|
{ value: "backlog", label: "Backlog" },
|
||||||
|
|
@ -43,6 +45,7 @@ export type ProjectConfigFieldKey =
|
||||||
| "description"
|
| "description"
|
||||||
| "status"
|
| "status"
|
||||||
| "goals"
|
| "goals"
|
||||||
|
| "env"
|
||||||
| "execution_workspace_enabled"
|
| "execution_workspace_enabled"
|
||||||
| "execution_workspace_default_mode"
|
| "execution_workspace_default_mode"
|
||||||
| "execution_workspace_base_ref"
|
| "execution_workspace_base_ref"
|
||||||
|
|
@ -245,6 +248,21 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
retry: false,
|
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
|
const linkedGoalIds = project.goalIds.length > 0
|
||||||
? project.goalIds
|
? project.goalIds
|
||||||
|
|
@ -583,6 +601,26 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</PropertyRow>
|
</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" />}>
|
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
|
||||||
<span className="text-sm">{formatDate(project.createdAt)}</span>
|
<span className="text-sm">{formatDate(project.createdAt)}</span>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ function createProject(): Project {
|
||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#22c55e",
|
color: "#22c55e",
|
||||||
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ArrowDown } from "lucide-react";
|
import { ArrowDown } from "lucide-react";
|
||||||
|
import { usePanel } from "../context/PanelContext";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
function resolveScrollTarget() {
|
function resolveScrollTarget() {
|
||||||
const mainContent = document.getElementById("main-content");
|
const mainContent = document.getElementById("main-content");
|
||||||
|
|
@ -33,6 +35,7 @@ function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
|
||||||
*/
|
*/
|
||||||
export function ScrollToBottom() {
|
export function ScrollToBottom() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { panelVisible, panelContent } = usePanel();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => {
|
const check = () => {
|
||||||
|
|
@ -70,7 +73,10 @@ export function ScrollToBottom() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={scroll}
|
onClick={scroll}
|
||||||
className="fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors md:bottom-6"
|
className={cn(
|
||||||
|
"fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-[background-color,right] duration-200 md:bottom-6",
|
||||||
|
panelVisible && panelContent && "md:right-[calc(320px+1.5rem)]",
|
||||||
|
)}
|
||||||
aria-label="Scroll to bottom"
|
aria-label="Scroll to bottom"
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ interface NewIssueDefaults {
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
projectWorkspaceId?: string;
|
||||||
|
goalId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
parentIdentifier?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
executionWorkspaceId?: string;
|
||||||
|
executionWorkspaceMode?: string;
|
||||||
|
parentExecutionWorkspaceLabel?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ function makeProject(id: string, name: string): Project {
|
||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: null,
|
color: null,
|
||||||
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
executionWorkspacePolicy: null,
|
executionWorkspacePolicy: null,
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,29 @@ describe("company routes", () => {
|
||||||
"/execution-workspaces/workspace-123",
|
"/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", () => {
|
it("hides the workspace column option unless isolated workspaces are enabled", () => {
|
||||||
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "labels", "updated"]);
|
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "parent", "labels", "updated"]);
|
||||||
expect(getAvailableInboxIssueColumns(true)).toEqual([
|
expect(getAvailableInboxIssueColumns(true)).toEqual([
|
||||||
"status",
|
"status",
|
||||||
"id",
|
"id",
|
||||||
"assignee",
|
"assignee",
|
||||||
"project",
|
"project",
|
||||||
"workspace",
|
"workspace",
|
||||||
|
"parent",
|
||||||
"labels",
|
"labels",
|
||||||
"updated",
|
"updated",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "labels", "updated"] as const;
|
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||||
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||||
export type InboxWorkItem =
|
export type InboxWorkItem =
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,80 @@ import {
|
||||||
armIssueDetailInboxQuickArchive,
|
armIssueDetailInboxQuickArchive,
|
||||||
createIssueDetailLocationState,
|
createIssueDetailLocationState,
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
|
hasLegacyIssueDetailQuery,
|
||||||
|
readIssueDetailLocationState,
|
||||||
readIssueDetailBreadcrumb,
|
readIssueDetailBreadcrumb,
|
||||||
|
rememberIssueDetailLocationState,
|
||||||
shouldArmIssueDetailInboxQuickArchive,
|
shouldArmIssueDetailInboxQuickArchive,
|
||||||
} from "./issueDetailBreadcrumb";
|
} from "./issueDetailBreadcrumb";
|
||||||
|
|
||||||
|
const sessionStorageMock = (() => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "window", {
|
||||||
|
configurable: true,
|
||||||
|
value: { sessionStorage: sessionStorageMock },
|
||||||
|
});
|
||||||
|
|
||||||
describe("issueDetailBreadcrumb", () => {
|
describe("issueDetailBreadcrumb", () => {
|
||||||
|
it("returns clean issue detail paths", () => {
|
||||||
|
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers the full breadcrumb from route state", () => {
|
it("prefers the full breadcrumb from route state", () => {
|
||||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||||
|
|
||||||
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
|
expect(readIssueDetailBreadcrumb("PAP-465", state, "?from=issues")).toEqual({
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
href: "/inbox/mine",
|
href: "/inbox/mine",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the source query param when route state is unavailable", () => {
|
it("falls back to the source query param when route state is unavailable", () => {
|
||||||
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
|
expect(readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox")).toEqual({
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
href: "/inbox",
|
href: "/inbox",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds the source query param when building an issue detail path", () => {
|
it("can detect legacy query-based breadcrumb links", () => {
|
||||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
expect(hasLegacyIssueDetailQuery("?from=inbox&fromHref=%2Finbox%2Fmine")).toBe(true);
|
||||||
|
expect(hasLegacyIssueDetailQuery("?q=test")).toBe(false);
|
||||||
expect(createIssueDetailPath("PAP-465", state)).toBe(
|
|
||||||
"/issues/PAP-465?from=inbox&fromHref=%2Finbox%2Fmine",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reuses the current source query param when state has been dropped", () => {
|
|
||||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
|
|
||||||
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores the exact breadcrumb href from the query fallback", () => {
|
it("restores the exact breadcrumb href from the query fallback", () => {
|
||||||
expect(
|
expect(
|
||||||
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
href: "/PAP/inbox/unread",
|
href: "/PAP/inbox/unread",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads hidden breadcrumb context from session storage when route state is unavailable", () => {
|
||||||
|
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||||
|
sessionStorageMock.clear();
|
||||||
|
rememberIssueDetailLocationState("PAP-465", state);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readIssueDetailLocationState("PAP-465", null),
|
||||||
|
).toEqual({
|
||||||
|
issueDetailBreadcrumb: { label: "Inbox", href: "/inbox/mine" },
|
||||||
|
issueDetailSource: "inbox",
|
||||||
|
issueDetailInboxQuickArchiveArmed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ type IssueDetailLocationState = {
|
||||||
|
|
||||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||||
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
|
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
|
||||||
|
const ISSUE_DETAIL_STORAGE_KEY_PREFIX = "paperclip:issue-detail-breadcrumb:";
|
||||||
|
|
||||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||||
if (typeof value !== "object" || value === null) return false;
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
|
@ -44,6 +45,17 @@ function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null
|
||||||
return href && href.startsWith("/") ? href : null;
|
return href && href.startsWith("/") ? href : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferIssueDetailSource(
|
||||||
|
state: Partial<IssueDetailLocationState> | null,
|
||||||
|
breadcrumb: IssueDetailBreadcrumb | null,
|
||||||
|
): IssueDetailSource | null {
|
||||||
|
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
|
||||||
|
if (!breadcrumb) return null;
|
||||||
|
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
|
||||||
|
if (breadcrumb.label === "Issues" || breadcrumb.href.includes("/issues")) return "issues";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||||
return { label: "Issues", href: "/issues" };
|
return { label: "Issues", href: "/issues" };
|
||||||
|
|
@ -71,34 +83,97 @@ export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLoca
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
|
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
|
||||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
if (typeof window === "undefined" || !window.sessionStorage) return null;
|
||||||
const breadcrumb =
|
|
||||||
(typeof state === "object" && state !== null
|
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
|
||||||
? (state as IssueDetailLocationState).issueDetailBreadcrumb
|
if (!raw) return null;
|
||||||
: null);
|
|
||||||
const breadcrumbHref =
|
try {
|
||||||
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
|
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
|
||||||
readIssueDetailBreadcrumbHrefFromSearch(search);
|
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
|
||||||
if (!source) return `/issues/${issuePathId}`;
|
? parsed.issueDetailBreadcrumb
|
||||||
const params = new URLSearchParams();
|
: null;
|
||||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
const source = inferIssueDetailSource(parsed, breadcrumb);
|
||||||
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
|
if (!breadcrumb || !source) return null;
|
||||||
return `/issues/${issuePathId}?${params.toString()}`;
|
return {
|
||||||
|
issueDetailBreadcrumb: breadcrumb,
|
||||||
|
issueDetailSource: source,
|
||||||
|
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
|
function normalizeIssueDetailLocationState(
|
||||||
|
state: unknown,
|
||||||
|
search?: string,
|
||||||
|
): IssueDetailLocationState | null {
|
||||||
if (typeof state === "object" && state !== null) {
|
if (typeof state === "object" && state !== null) {
|
||||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||||
if (isIssueDetailBreadcrumb(candidate)) return candidate;
|
if (isIssueDetailBreadcrumb(candidate)) {
|
||||||
|
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
|
||||||
|
if (!source) return null;
|
||||||
|
return {
|
||||||
|
issueDetailBreadcrumb: candidate,
|
||||||
|
issueDetailSource: source,
|
||||||
|
issueDetailInboxQuickArchiveArmed:
|
||||||
|
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = readIssueDetailSourceFromSearch(search);
|
const source = readIssueDetailSourceFromSearch(search);
|
||||||
|
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
|
||||||
const fallback = breadcrumbForSource(source);
|
return {
|
||||||
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
|
||||||
return href ? { ...fallback, href } : fallback;
|
issueDetailSource: source,
|
||||||
|
issueDetailInboxQuickArchiveArmed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberIssueDetailLocationState(issuePathId: string, state: unknown, search?: string): void {
|
||||||
|
if (typeof window === "undefined" || !window.sessionStorage) return;
|
||||||
|
|
||||||
|
const normalized = normalizeIssueDetailLocationState(state, search);
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`,
|
||||||
|
JSON.stringify(normalized),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIssueDetailPath(issuePathId: string): string {
|
||||||
|
return `/issues/${issuePathId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLegacyIssueDetailQuery(search?: string): boolean {
|
||||||
|
if (!search) return false;
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return params.has(ISSUE_DETAIL_SOURCE_QUERY_PARAM) || params.has(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readIssueDetailLocationState(
|
||||||
|
issuePathId: string | null | undefined,
|
||||||
|
state: unknown,
|
||||||
|
search?: string,
|
||||||
|
): IssueDetailLocationState | null {
|
||||||
|
const normalized = normalizeIssueDetailLocationState(state, search);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
if (!issuePathId) return null;
|
||||||
|
return readStoredIssueDetailLocationState(issuePathId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readIssueDetailBreadcrumb(
|
||||||
|
issuePathId: string | null | undefined,
|
||||||
|
state: unknown,
|
||||||
|
search?: string,
|
||||||
|
): IssueDetailBreadcrumb | null {
|
||||||
|
return readIssueDetailLocationState(issuePathId, state, search)?.issueDetailBreadcrumb ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
|
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
|
||||||
|
|
|
||||||
|
|
@ -212,4 +212,18 @@ describe("optimistic issue comments", () => {
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not mark comments from the active run agent as queued", () => {
|
||||||
|
expect(
|
||||||
|
isQueuedIssueComment({
|
||||||
|
comment: {
|
||||||
|
createdAt: new Date("2026-03-28T16:20:05.000Z"),
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
},
|
||||||
|
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
|
||||||
|
activeRunAgentId: "agent-1",
|
||||||
|
runId: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,20 @@ export function createOptimisticIssueComment(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQueuedIssueComment(params: {
|
export function isQueuedIssueComment(params: {
|
||||||
comment: Pick<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
|
comment: Pick<IssueTimelineComment, "createdAt"> &
|
||||||
|
Partial<Pick<OptimisticIssueComment, "clientStatus">> & {
|
||||||
|
authorAgentId?: string | null;
|
||||||
|
};
|
||||||
activeRunStartedAt?: Date | string | null;
|
activeRunStartedAt?: Date | string | null;
|
||||||
|
activeRunAgentId?: string | null;
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
interruptedRunId?: string | null;
|
interruptedRunId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (params.runId) return false;
|
if (params.runId) return false;
|
||||||
if (params.interruptedRunId) return false;
|
if (params.interruptedRunId) return false;
|
||||||
|
if (params.comment.authorAgentId && params.activeRunAgentId && params.comment.authorAgentId === params.activeRunAgentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (params.comment.clientStatus === "queued") return true;
|
if (params.comment.clientStatus === "queued") return true;
|
||||||
if (!params.activeRunStartedAt) return false;
|
if (!params.activeRunStartedAt) return false;
|
||||||
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
|
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export const queryKeys = {
|
||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
list: (companyId: string) => ["issues", companyId] as const,
|
||||||
search: (companyId: string, q: string, projectId?: string) =>
|
search: (companyId: string, q: string, projectId?: string, limit?: number) =>
|
||||||
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
|
["issues", companyId, "search", q, projectId ?? "__all-projects__", limit ?? "__no-limit__"] as const,
|
||||||
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
||||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,9 @@ export function Approvals() {
|
||||||
onReject={() => rejectMutation.mutate(approval.id)}
|
onReject={() => rejectMutation.mutate(approval.id)}
|
||||||
detailLink={`/approvals/${approval.id}`}
|
detailLink={`/approvals/${approval.id}`}
|
||||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||||
|
pendingAction={
|
||||||
|
approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ChangeEvent, useEffect, useState } from "react";
|
import { ChangeEvent, useEffect, useState } from "react";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
|
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
|
@ -548,16 +549,16 @@ export function CompanySettings() {
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" asChild>
|
<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" />
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Export
|
Export
|
||||||
</a>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" asChild>
|
<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" />
|
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Import
|
Import
|
||||||
</a>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-4 overflow-hidden sm:space-y-6">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||||
|
|
@ -393,19 +393,20 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||||
<div className="space-y-6">
|
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||||
<div className="rounded-2xl border border-border bg-card p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
Execution workspace
|
Execution workspace
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay
|
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
|
||||||
|
<span className="hidden sm:inline"> These settings stay
|
||||||
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
||||||
and runtime-service behavior in sync with the actual workspace being reused.
|
and runtime-service behavior in sync with the actual workspace being reused.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||||
|
|
@ -482,7 +483,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||||
value={form.provisionCommand}
|
value={form.provisionCommand}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||||
placeholder="bash ./scripts/provision-worktree.sh"
|
placeholder="bash ./scripts/provision-worktree.sh"
|
||||||
|
|
@ -490,7 +491,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||||
value={form.teardownCommand}
|
value={form.teardownCommand}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||||
|
|
@ -501,7 +502,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
<div className="mt-4 grid gap-4">
|
<div className="mt-4 grid gap-4">
|
||||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
|
||||||
value={form.cleanupCommand}
|
value={form.cleanupCommand}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||||
placeholder="pkill -f vite || true"
|
placeholder="pkill -f vite || true"
|
||||||
|
|
@ -546,14 +547,22 @@ export function ExecutionWorkspaceDetail() {
|
||||||
id="inherit-runtime-config"
|
id="inherit-runtime-config"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.inheritRuntime}
|
checked={form.inheritRuntime}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
|
const checked = event.target.checked;
|
||||||
|
setForm((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
// When unchecking "inherit" and the field is empty, copy inherited config as a starting point
|
||||||
|
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||||
|
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||||
}
|
}
|
||||||
|
return { ...current, inheritRuntime: checked };
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
className="min-h-32 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-48"
|
||||||
value={form.workspaceRuntime}
|
value={form.workspaceRuntime}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||||
disabled={form.inheritRuntime}
|
disabled={form.inheritRuntime}
|
||||||
|
|
@ -586,8 +595,8 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||||
<div className="rounded-2xl border border-border bg-card p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||||
|
|
@ -632,7 +641,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||||
|
|
@ -676,7 +685,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||||
|
|
@ -755,7 +764,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||||
|
|
@ -798,7 +807,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5">
|
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
||||||
|
|
@ -819,12 +828,12 @@ export function ExecutionWorkspaceDetail() {
|
||||||
: "Failed to load linked issues."}
|
: "Failed to load linked issues."}
|
||||||
</p>
|
</p>
|
||||||
) : linkedIssues.length > 0 ? (
|
) : linkedIssues.length > 0 ? (
|
||||||
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
<div className="-mx-1 flex flex-col gap-3 px-1 pb-1 sm:flex-row sm:overflow-x-auto">
|
||||||
{linkedIssues.map((issue) => (
|
{linkedIssues.map((issue) => (
|
||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={issueUrl(issue)}
|
to={issueUrl(issue)}
|
||||||
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
|
className="rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20 sm:min-w-72"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,8 @@ describe("InboxIssueTrailingColumns", () => {
|
||||||
workspaceName={null}
|
workspaceName={null}
|
||||||
assigneeName={null}
|
assigneeName={null}
|
||||||
currentUserId={null}
|
currentUserId={null}
|
||||||
|
parentIdentifier={null}
|
||||||
|
parentTitle={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -229,6 +231,8 @@ describe("InboxIssueTrailingColumns", () => {
|
||||||
workspaceName={null}
|
workspaceName={null}
|
||||||
assigneeName={null}
|
assigneeName={null}
|
||||||
currentUserId={null}
|
currentUserId={null}
|
||||||
|
parentIdentifier={null}
|
||||||
|
parentTitle={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
armIssueDetailInboxQuickArchive,
|
armIssueDetailInboxQuickArchive,
|
||||||
createIssueDetailLocationState,
|
createIssueDetailLocationState,
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
|
rememberIssueDetailLocationState,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
|
@ -140,13 +141,14 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
|
|
||||||
|
|
||||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
||||||
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
|
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
|
||||||
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||||
status: "Status",
|
status: "Status",
|
||||||
id: "ID",
|
id: "ID",
|
||||||
assignee: "Assignee",
|
assignee: "Assignee",
|
||||||
project: "Project",
|
project: "Project",
|
||||||
workspace: "Workspace",
|
workspace: "Workspace",
|
||||||
|
parent: "Parent issue",
|
||||||
labels: "Tags",
|
labels: "Tags",
|
||||||
updated: "Last updated",
|
updated: "Last updated",
|
||||||
};
|
};
|
||||||
|
|
@ -156,6 +158,7 @@ const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||||
assignee: "Assigned agent or board user.",
|
assignee: "Assigned agent or board user.",
|
||||||
project: "Linked project pill with its color.",
|
project: "Linked project pill with its color.",
|
||||||
workspace: "Execution or project workspace used for the issue.",
|
workspace: "Execution or project workspace used for the issue.",
|
||||||
|
parent: "Parent issue identifier and title.",
|
||||||
labels: "Issue labels and tags.",
|
labels: "Issue labels and tags.",
|
||||||
updated: "Latest visible activity time.",
|
updated: "Latest visible activity time.",
|
||||||
};
|
};
|
||||||
|
|
@ -223,8 +226,9 @@ function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||||
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||||
|
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||||
return "minmax(6rem, 7rem)";
|
return "minmax(4rem, 5.5rem)";
|
||||||
})
|
})
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +241,8 @@ export function InboxIssueTrailingColumns({
|
||||||
workspaceName,
|
workspaceName,
|
||||||
assigneeName,
|
assigneeName,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
parentIdentifier,
|
||||||
|
parentTitle,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
columns: InboxIssueColumn[];
|
columns: InboxIssueColumn[];
|
||||||
|
|
@ -245,6 +251,8 @@ export function InboxIssueTrailingColumns({
|
||||||
workspaceName: string | null;
|
workspaceName: string | null;
|
||||||
assigneeName: string | null;
|
assigneeName: string | null;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
|
parentIdentifier: string | null;
|
||||||
|
parentTitle: string | null;
|
||||||
}) {
|
}) {
|
||||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||||
|
|
@ -347,6 +355,22 @@ export function InboxIssueTrailingColumns({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (column === "parent") {
|
||||||
|
if (!issue.parentId) {
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
|
||||||
|
{parentIdentifier ? (
|
||||||
|
<span className="font-mono">{parentIdentifier}</span>
|
||||||
|
) : (
|
||||||
|
<span className="italic">Sub-issue</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||||
{activityText}
|
{activityText}
|
||||||
|
|
@ -1245,30 +1269,53 @@ export function Inbox() {
|
||||||
|
|
||||||
const archiveIssueMutation = useMutation({
|
const archiveIssueMutation = useMutation({
|
||||||
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
||||||
onMutate: (id) => {
|
onMutate: async (id) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
||||||
|
|
||||||
|
// Cancel in-flight refetches so they don't overwrite our optimistic update
|
||||||
|
const queryKeys_ = [
|
||||||
|
queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||||
|
queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
||||||
|
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
||||||
|
];
|
||||||
|
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
|
||||||
|
|
||||||
|
// Snapshot previous data for rollback
|
||||||
|
const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const);
|
||||||
|
|
||||||
|
// Optimistically remove the issue from all inbox query caches
|
||||||
|
for (const qk of queryKeys_) {
|
||||||
|
queryClient.setQueryData(qk, (old: unknown) => {
|
||||||
|
if (!Array.isArray(old)) return old;
|
||||||
|
return old.filter((issue: { id: string }) => issue.id !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousData };
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onError: (err, id, context) => {
|
||||||
invalidateInboxIssueQueries();
|
|
||||||
},
|
|
||||||
onError: (err, id) => {
|
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
||||||
setArchivingIssueIds((prev) => {
|
setArchivingIssueIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Restore previous query data on failure
|
||||||
|
if (context?.previousData) {
|
||||||
|
for (const [qk, data] of context.previousData) {
|
||||||
|
queryClient.setQueryData(qk, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSettled: (_data, error, id) => {
|
onSettled: (_data, error, id) => {
|
||||||
if (error) return;
|
// Clean up archiving state and refetch to sync with server
|
||||||
window.setTimeout(() => {
|
|
||||||
setArchivingIssueIds((prev) => {
|
setArchivingIssueIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 500);
|
invalidateInboxIssueQueries();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1498,7 +1545,8 @@ export function Inbox() {
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
const pathId = item.issue.identifier ?? item.issue.id;
|
const pathId = item.issue.identifier ?? item.issue.id;
|
||||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||||
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
|
rememberIssueDetailLocationState(pathId, detailState);
|
||||||
|
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||||
} else if (item.kind === "approval") {
|
} else if (item.kind === "approval") {
|
||||||
act.navigate(`/approvals/${item.approval.id}`);
|
act.navigate(`/approvals/${item.approval.id}`);
|
||||||
} else if (item.kind === "failed_run") {
|
} else if (item.kind === "failed_run") {
|
||||||
|
|
@ -1566,6 +1614,18 @@ export function Inbox() {
|
||||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||||
<PageTabBar
|
<PageTabBar
|
||||||
|
|
@ -1585,14 +1645,14 @@ export function Inbox() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative hidden sm:block">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search inbox…"
|
placeholder="Search inbox…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
|
className="h-8 w-[220px] pl-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -1601,7 +1661,7 @@ export function Inbox() {
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
|
className="hidden h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground sm:inline-flex"
|
||||||
>
|
>
|
||||||
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||||
Show / hide columns
|
Show / hide columns
|
||||||
|
|
@ -1686,6 +1746,7 @@ export function Inbox() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{tab === "all" && (
|
{tab === "all" && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -1941,6 +2002,8 @@ export function Inbox() {
|
||||||
})}
|
})}
|
||||||
assigneeName={agentName(issue.assigneeAgentId)}
|
assigneeName={agentName(issue.assigneeAgentId)}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
|
||||||
|
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
|
@ -10,6 +11,7 @@ import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
|
@ -17,8 +19,11 @@ import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../li
|
||||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
|
hasLegacyIssueDetailQuery,
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
|
readIssueDetailLocationState,
|
||||||
readIssueDetailBreadcrumb,
|
readIssueDetailBreadcrumb,
|
||||||
|
rememberIssueDetailLocationState,
|
||||||
shouldArmIssueDetailInboxQuickArchive,
|
shouldArmIssueDetailInboxQuickArchive,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
||||||
|
|
@ -33,6 +38,7 @@ import {
|
||||||
} from "../lib/optimistic-issue-comments";
|
} from "../lib/optimistic-issue-comments";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
|
import { ApprovalCard } from "../components/ApprovalCard";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { CommentThread } from "../components/CommentThread";
|
import { CommentThread } from "../components/CommentThread";
|
||||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||||
|
|
@ -44,21 +50,18 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Activity as ActivityIcon,
|
Activity as ActivityIcon,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -287,6 +290,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
const { issueId } = useParams<{ issueId: string }>();
|
const { issueId } = useParams<{ issueId: string }>();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const { openNewIssue } = useDialog();
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -297,9 +301,11 @@ export function IssueDetail() {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||||
const [detailTab, setDetailTab] = useState("comments");
|
const [detailTab, setDetailTab] = useState("comments");
|
||||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||||
approvals: false,
|
approvalId: string;
|
||||||
});
|
action: "approve" | "reject";
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||||
|
|
@ -375,9 +381,13 @@ export function IssueDetail() {
|
||||||
),
|
),
|
||||||
[activeRun, liveRuns],
|
[activeRun, liveRuns],
|
||||||
);
|
);
|
||||||
|
const resolvedIssueDetailState = useMemo(
|
||||||
|
() => readIssueDetailLocationState(issueId, location.state, location.search),
|
||||||
|
[issueId, location.state, location.search],
|
||||||
|
);
|
||||||
const sourceBreadcrumb = useMemo(
|
const sourceBreadcrumb = useMemo(
|
||||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||||
[location.state, location.search],
|
[issueId, location.state, location.search],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out runs already shown by the live widget to avoid duplication
|
// Filter out runs already shown by the live widget to avoid duplication
|
||||||
|
|
@ -484,6 +494,45 @@ export function IssueDetail() {
|
||||||
.filter((i) => i.parentId === issue.id)
|
.filter((i) => i.parentId === issue.id)
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
}, [allIssues, issue]);
|
}, [allIssues, issue]);
|
||||||
|
const childIssuesPanelKey = useMemo(
|
||||||
|
() => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"),
|
||||||
|
[childIssues],
|
||||||
|
);
|
||||||
|
const issuePanelKey = issue
|
||||||
|
? `${issue.id}:${String(issue.updatedAt)}:${childIssuesPanelKey}`
|
||||||
|
: "";
|
||||||
|
const openNewSubIssue = useCallback(() => {
|
||||||
|
if (!issue) return;
|
||||||
|
openNewIssue({
|
||||||
|
parentId: issue.id,
|
||||||
|
parentIdentifier: issue.identifier ?? undefined,
|
||||||
|
parentTitle: issue.title,
|
||||||
|
projectId: issue.projectId ?? undefined,
|
||||||
|
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||||
|
goalId: issue.goalId ?? undefined,
|
||||||
|
executionWorkspaceId: issue.executionWorkspaceId ?? undefined,
|
||||||
|
executionWorkspaceMode: issue.executionWorkspaceId ? "reuse_existing" : issue.executionWorkspacePreference ?? undefined,
|
||||||
|
parentExecutionWorkspaceLabel:
|
||||||
|
issue.currentExecutionWorkspace?.name
|
||||||
|
?? issue.currentExecutionWorkspace?.branchName
|
||||||
|
?? issue.currentExecutionWorkspace?.cwd
|
||||||
|
?? issue.executionWorkspaceId
|
||||||
|
?? undefined,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
issue?.currentExecutionWorkspace?.branchName,
|
||||||
|
issue?.currentExecutionWorkspace?.cwd,
|
||||||
|
issue?.currentExecutionWorkspace?.name,
|
||||||
|
issue?.executionWorkspaceId,
|
||||||
|
issue?.executionWorkspacePreference,
|
||||||
|
issue?.goalId,
|
||||||
|
issue?.id,
|
||||||
|
issue?.identifier,
|
||||||
|
issue?.projectId,
|
||||||
|
issue?.projectWorkspaceId,
|
||||||
|
issue?.title,
|
||||||
|
openNewIssue,
|
||||||
|
]);
|
||||||
|
|
||||||
const commentReassignOptions = useMemo(() => {
|
const commentReassignOptions = useMemo(() => {
|
||||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||||
|
|
@ -546,6 +595,7 @@ export function IssueDetail() {
|
||||||
isQueuedIssueComment({
|
isQueuedIssueComment({
|
||||||
comment: nextComment,
|
comment: nextComment,
|
||||||
activeRunStartedAt,
|
activeRunStartedAt,
|
||||||
|
activeRunAgentId: runningIssueRun?.agentId ?? null,
|
||||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||||
})
|
})
|
||||||
|
|
@ -650,6 +700,42 @@ export function IssueDetail() {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||||
|
updateIssue.mutate(data);
|
||||||
|
}, [updateIssue.mutate]);
|
||||||
|
|
||||||
|
const approvalDecision = useMutation({
|
||||||
|
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
|
||||||
|
if (action === "approve") {
|
||||||
|
return approvalsApi.approve(approvalId);
|
||||||
|
}
|
||||||
|
return approvalsApi.reject(approvalId);
|
||||||
|
},
|
||||||
|
onMutate: ({ approvalId, action }) => {
|
||||||
|
setPendingApprovalAction({ approvalId, action });
|
||||||
|
},
|
||||||
|
onSuccess: (_approval, variables) => {
|
||||||
|
invalidateIssue();
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
|
||||||
|
if (resolvedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
title: variables.action === "approve" ? "Approval approved" : "Approval rejected",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err, variables) => {
|
||||||
|
pushToast({
|
||||||
|
title: variables.action === "approve" ? "Approval failed" : "Rejection failed",
|
||||||
|
body: err instanceof Error ? err.message : "Unable to update approval",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setPendingApprovalAction(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const addComment = useMutation({
|
const addComment = useMutation({
|
||||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||||
|
|
@ -967,13 +1053,24 @@ export function IssueDetail() {
|
||||||
|
|
||||||
// Redirect to identifier-based URL if navigated via UUID
|
// Redirect to identifier-based URL if navigated via UUID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const nextState = resolvedIssueDetailState ?? location.state;
|
||||||
if (issue?.identifier && issueId !== issue.identifier) {
|
if (issue?.identifier && issueId !== issue.identifier) {
|
||||||
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
|
rememberIssueDetailLocationState(issue.identifier, nextState, location.search);
|
||||||
|
navigate(createIssueDetailPath(issue.identifier), {
|
||||||
replace: true,
|
replace: true,
|
||||||
state: location.state,
|
state: nextState,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issueId && hasLegacyIssueDetailQuery(location.search)) {
|
||||||
|
rememberIssueDetailLocationState(issueId, nextState, location.search);
|
||||||
|
navigate(createIssueDetailPath(issueId), {
|
||||||
|
replace: true,
|
||||||
|
state: nextState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [issue, issueId, navigate, location.state, location.search]);
|
}, [issue, issueId, navigate, location.state, location.search, resolvedIssueDetailState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!issue?.id) return;
|
if (!issue?.id) return;
|
||||||
|
|
@ -983,13 +1080,20 @@ export function IssueDetail() {
|
||||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue) {
|
if (!issue) {
|
||||||
openPanel(
|
closePanel();
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
openPanel(
|
||||||
|
<IssueProperties
|
||||||
|
issue={issue}
|
||||||
|
childIssues={childIssues}
|
||||||
|
onAddSubIssue={openNewSubIssue}
|
||||||
|
onUpdate={handleIssuePropertiesUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
|
||||||
|
|
||||||
const inboxQuickArchiveArmedRef = useRef(false);
|
const inboxQuickArchiveArmedRef = useRef(false);
|
||||||
const canQuickArchiveFromInbox =
|
const canQuickArchiveFromInbox =
|
||||||
|
|
@ -1115,13 +1219,13 @@ export function IssueDetail() {
|
||||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||||
const attachmentList = attachments ?? [];
|
const attachmentList = attachments ?? [];
|
||||||
const imageAttachments = attachmentList.filter(isImageAttachment);
|
const imageAttachments = attachmentList.filter(isImageAttachment);
|
||||||
|
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
|
||||||
const hasAttachments = attachmentList.length > 0;
|
const hasAttachments = attachmentList.length > 0;
|
||||||
const attachmentUploadButton = (
|
const attachmentUploadButton = (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFilePicked}
|
onChange={handleFilePicked}
|
||||||
multiple
|
multiple
|
||||||
|
|
@ -1156,8 +1260,14 @@ export function IssueDetail() {
|
||||||
<span key={ancestor.id} className="flex items-center gap-1">
|
<span key={ancestor.id} className="flex items-center gap-1">
|
||||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||||
<Link
|
<Link
|
||||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
|
||||||
state={location.state}
|
state={resolvedIssueDetailState ?? location.state}
|
||||||
|
onClickCapture={() =>
|
||||||
|
rememberIssueDetailLocationState(
|
||||||
|
ancestor.identifier ?? ancestor.id,
|
||||||
|
resolvedIssueDetailState ?? location.state,
|
||||||
|
location.search,
|
||||||
|
)}
|
||||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||||
title={ancestor.title}
|
title={ancestor.title}
|
||||||
>
|
>
|
||||||
|
|
@ -1330,6 +1440,9 @@ export function IssueDetail() {
|
||||||
const attachment = await uploadAttachment.mutateAsync(file);
|
const attachment = await uploadAttachment.mutateAsync(file);
|
||||||
return attachment.contentPath;
|
return attachment.contentPath;
|
||||||
}}
|
}}
|
||||||
|
onDropFile={async (file) => {
|
||||||
|
await uploadAttachment.mutateAsync(file);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1374,6 +1487,50 @@ export function IssueDetail() {
|
||||||
missingBehavior="placeholder"
|
missingBehavior="placeholder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{childIssues.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
|
||||||
|
<ListTree className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Add sub-issue</span>
|
||||||
|
<span className="sm:hidden">Sub-issue</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border rounded-lg divide-y divide-border">
|
||||||
|
{childIssues.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.id}
|
||||||
|
to={createIssueDetailPath(child.identifier ?? child.id)}
|
||||||
|
state={resolvedIssueDetailState ?? location.state}
|
||||||
|
onClickCapture={() =>
|
||||||
|
rememberIssueDetailLocationState(
|
||||||
|
child.identifier ?? child.id,
|
||||||
|
resolvedIssueDetailState ?? location.state,
|
||||||
|
location.search,
|
||||||
|
)}
|
||||||
|
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<StatusIcon status={child.status} />
|
||||||
|
<PriorityIcon priority={child.priority} />
|
||||||
|
<span className="font-mono text-muted-foreground shrink-0">
|
||||||
|
{child.identifier ?? child.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{child.title}</span>
|
||||||
|
</div>
|
||||||
|
{child.assigneeAgentId && (() => {
|
||||||
|
const name = agentMap.get(child.assigneeAgentId)?.name;
|
||||||
|
return name
|
||||||
|
? <Identity name={name} size="sm" />
|
||||||
|
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
|
||||||
|
})()}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<IssueDocumentsSection
|
<IssueDocumentsSection
|
||||||
issue={issue}
|
issue={issue}
|
||||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||||
|
|
@ -1395,7 +1552,18 @@ export function IssueDetail() {
|
||||||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
|
extraActions={
|
||||||
|
<>
|
||||||
|
{!hasAttachments && attachmentUploadButton}
|
||||||
|
{childIssues.length === 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
|
||||||
|
<ListTree className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Add sub-issue</span>
|
||||||
|
<span className="sm:hidden">Sub-issue</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasAttachments ? (
|
{hasAttachments ? (
|
||||||
|
|
@ -1426,8 +1594,77 @@ export function IssueDetail() {
|
||||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nonImageAttachments.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{attachmentList.map((attachment) => (
|
{nonImageAttachments.map((attachment) => (
|
||||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<a
|
<a
|
||||||
|
|
@ -1452,27 +1689,10 @@ export function IssueDetail() {
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -1497,10 +1717,6 @@ export function IssueDetail() {
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
Comments
|
Comments
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="subissues" className="gap-1.5">
|
|
||||||
<ListTree className="h-3.5 w-3.5" />
|
|
||||||
Sub-issues
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="activity" className="gap-1.5">
|
<TabsTrigger value="activity" className="gap-1.5">
|
||||||
<ActivityIcon className="h-3.5 w-3.5" />
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
Activity
|
Activity
|
||||||
|
|
@ -1516,6 +1732,7 @@ export function IssueDetail() {
|
||||||
<CommentThread
|
<CommentThread
|
||||||
comments={timelineComments}
|
comments={timelineComments}
|
||||||
queuedComments={queuedComments}
|
queuedComments={queuedComments}
|
||||||
|
linkedApprovals={linkedApprovals}
|
||||||
feedbackVotes={feedbackVotes}
|
feedbackVotes={feedbackVotes}
|
||||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||||
|
|
@ -1523,6 +1740,13 @@ export function IssueDetail() {
|
||||||
timelineEvents={timelineEvents}
|
timelineEvents={timelineEvents}
|
||||||
companyId={issue.companyId}
|
companyId={issue.companyId}
|
||||||
projectId={issue.projectId}
|
projectId={issue.projectId}
|
||||||
|
onApproveApproval={async (approvalId) => {
|
||||||
|
await approvalDecision.mutateAsync({ approvalId, action: "approve" });
|
||||||
|
}}
|
||||||
|
onRejectApproval={async (approvalId) => {
|
||||||
|
await approvalDecision.mutateAsync({ approvalId, action: "reject" });
|
||||||
|
}}
|
||||||
|
pendingApprovalAction={pendingApprovalAction}
|
||||||
issueStatus={issue.status}
|
issueStatus={issue.status}
|
||||||
agentMap={agentMap}
|
agentMap={agentMap}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
|
@ -1565,39 +1789,27 @@ export function IssueDetail() {
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="subissues">
|
<TabsContent value="activity">
|
||||||
{childIssues.length === 0 ? (
|
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
<div className="mb-3 space-y-3">
|
||||||
) : (
|
{linkedApprovals.map((approval) => (
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
<ApprovalCard
|
||||||
{childIssues.map((child) => (
|
key={approval.id}
|
||||||
<Link
|
approval={approval}
|
||||||
key={child.id}
|
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
|
||||||
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
|
||||||
state={location.state}
|
onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
|
||||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
detailLink={`/approvals/${approval.id}`}
|
||||||
>
|
isPending={pendingApprovalAction?.approvalId === approval.id}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
pendingAction={
|
||||||
<StatusIcon status={child.status} />
|
pendingApprovalAction?.approvalId === approval.id
|
||||||
<PriorityIcon priority={child.priority} />
|
? pendingApprovalAction.action
|
||||||
<span className="font-mono text-muted-foreground shrink-0">
|
: null
|
||||||
{child.identifier ?? child.id.slice(0, 8)}
|
}
|
||||||
</span>
|
/>
|
||||||
<span className="truncate">{child.title}</span>
|
|
||||||
</div>
|
|
||||||
{child.assigneeAgentId && (() => {
|
|
||||||
const name = agentMap.get(child.assigneeAgentId)?.name;
|
|
||||||
return name
|
|
||||||
? <Identity name={name} size="sm" />
|
|
||||||
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
|
|
||||||
})()}
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="activity">
|
|
||||||
{linkedRuns && linkedRuns.length > 0 && (
|
{linkedRuns && linkedRuns.length > 0 && (
|
||||||
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
||||||
|
|
@ -1653,43 +1865,6 @@ export function IssueDetail() {
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
|
||||||
<Collapsible
|
|
||||||
open={secondaryOpen.approvals}
|
|
||||||
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, approvals: open }))}
|
|
||||||
className="rounded-lg border border-border"
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
Linked Approvals ({linkedApprovals.length})
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.approvals && "rotate-180")}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="border-t border-border divide-y divide-border">
|
|
||||||
{linkedApprovals.map((approval) => (
|
|
||||||
<Link
|
|
||||||
key={approval.id}
|
|
||||||
to={`/approvals/${approval.id}`}
|
|
||||||
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={approval.status} />
|
|
||||||
<span className="font-medium">
|
|
||||||
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Mobile properties drawer */}
|
{/* Mobile properties drawer */}
|
||||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||||
|
|
@ -1699,7 +1874,13 @@ export function IssueDetail() {
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
<ScrollArea className="flex-1 overflow-y-auto">
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
|
<IssueProperties
|
||||||
|
issue={issue}
|
||||||
|
childIssues={childIssues}
|
||||||
|
onAddSubIssue={openNewSubIssue}
|
||||||
|
onUpdate={(data) => updateIssue.mutate(data)}
|
||||||
|
inline
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue