Add workspace file viewer and artifact links (#7681)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent work is issue-centered, and reviewers often need to inspect files, artifacts, and path references produced during that work. > - Before this branch, workspace-relative paths and artifact file references were not first-class inspectable objects in the board UI. > - Safe file viewing needs shared resource contracts, server-side workspace boundary checks, and UI that opens files without exposing arbitrary host paths. > - The workspace file viewer branch needed to stay as one active PR and be rebased onto current `paperclipai/paperclip:master` for review. > - This pull request adds the workspace file resource API, issue-page file viewer and browser, markdown file-reference links, and artifact file chips. > - The benefit is that board users can inspect relevant files from issue context while preserving workspace boundaries and auditability. ## Linked Issues or Issue Description No public GitHub issue exists for this branch. Internal Paperclip issues: `PAP-1953`, `PAP-10539`, `PAP-10733`. Problem / motivation: - Board users need to open workspace-relative files mentioned by agents or attached as work-product metadata without switching to a terminal. - The UI needs to support both direct file-path opening and workspace browsing/searching from an issue page. - The server must enforce company access, workspace boundaries, size limits, rate limits, and safe audit logging. Related PR: - Prior closed attempt: #4442 - Single active PR for this branch: #7681 ## What Changed - Added shared workspace file resource types, validators, and workspace-file `resourceRef` metadata validation for work products. - Added server routes/services for resolving, listing, and previewing workspace-relative files with access checks, scan caps, list-specific limits, and audit logging. - Added the issue file viewer provider, sheet, workspace browser, command-palette action, markdown workspace-file autolinks, and artifact file chips. - Updated issue workspace UI and stories/tests for file browsing and workspace file opening. - Rebased the branch onto current `paperclipai/paperclip:master` and updated the existing single PR branch. - Addressed current-head Greptile follow-ups by applying `offset` consistently across search/recent/changed file listings, restoring stopped-service port ownership checks before auto-port reuse, and stabilizing the workspace browser pagination test. ## Verification Current local verification after rebase to `public/master`: - `pnpm exec vitest run packages/shared/src/work-product.test.ts server/src/__tests__/file-resources.test.ts server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-settings-service.test.ts server/src/__tests__/workspace-runtime.test.ts ui/src/components/FileViewerSheet.test.tsx ui/src/components/FileViewerSheet.copy.test.tsx ui/src/components/WorkspaceFileBrowser.test.tsx ui/src/components/WorkspaceFileMarkdownBody.test.tsx ui/src/context/FileViewerContext.test.ts ui/src/lib/remark-workspace-file-refs.test.ts ui/src/lib/workspace-file-parser.test.ts ui/src/components/IssueWorkspaceCard.test.tsx` - 13 files passed, 197 tests passed. - `pnpm -r --filter @paperclipai/shared --filter @paperclipai/server --filter @paperclipai/ui typecheck` - passed. - `pnpm exec vitest run ui/src/components/WorkspaceFileBrowser.test.tsx` - 1 file passed, 25 tests passed. - `pnpm exec vitest run server/src/__tests__/file-resources.test.ts server/src/__tests__/workspace-runtime.test.ts` - 2 files passed, 90 tests passed. - `pnpm -r --filter @paperclipai/server typecheck` - passed. - Confirmed branch is `0` behind and `46` ahead of current `public/master` after rebase and follow-up commits. - Confirmed the PR diff does not include `pnpm-lock.yaml`. - Confirmed the PR diff does not include `.github/workflows` changes. - Searched GitHub for duplicate or related workspace file viewer PRs/issues; #4442 is the prior closed attempt and this PR is the single active PR for the branch. - No screenshots were committed; the task explicitly asked not to add design screenshots or images unless they were part of the work. Current remote verification on head `a698a7bc10137baf7d25bd5722e1d6e0343387c1`: - Greptile Review - success, 64 files reviewed, 0 comments added, no unresolved Greptile review threads. - PR workflow `verify` - success. - Typecheck + Release Registry, General tests, workspace test shards, serialized server suites, Build, Canary Dry Run, e2e, Socket, and Snyk - success. - `security-review` - neutral, with output saying a draft advisory was filed for maintainer review and is not a merge block. - `commitperclip PR Review / review` - cancelled after the security gate detected flags and timed out while creating/reviewing the advisory. I reran it once and it cancelled the same way; no actionable code/test failure was exposed in the job logs. ## Risks - This is a broad UI/server feature PR, so review needs to pay attention to route authorization, workspace boundary handling, and markdown autolink false positives. - Workspace browsing intentionally caps list results and scan depth; very large workspaces may require users to refine search terms. - Remote workspace preview remains unavailable until remote file-access support is implemented. - The neutral commitperclip security-review advisory needs maintainer review, but the check output says it is not a merge block. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent in a Paperclip/Codex local tool-use environment, medium reasoning, with shell/GitHub CLI tool use for branch inspection, verification, rebase, PR update, Greptile review, and CI inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
bf62e3fbf1
commit
468edd8b22
|
|
@ -0,0 +1,377 @@
|
|||
---
|
||||
name: paperclip-dev-workspace-run-verify-fix
|
||||
description: >
|
||||
Run, verify, reseed, and repair a Paperclip isolated dev workspace service.
|
||||
Use when asked to start or fix a Paperclip project/worktree service and prove
|
||||
that it is managed by the Paperclip runtime, has the full bootstrapped cloned
|
||||
database, is healthy, accepts normal dev credentials, exposes populated app
|
||||
data, and is visible as running from both the control-plane and the served
|
||||
workspace app.
|
||||
---
|
||||
|
||||
# Paperclip Dev Workspace Run / Verify / Fix
|
||||
|
||||
This skill is for Paperclip-specific development workspaces whose service is
|
||||
started through project execution workspace runtime services, typically a
|
||||
worktree service such as `paperclip-dev`.
|
||||
|
||||
Success means all of these are true:
|
||||
|
||||
- the service was started through the normal managed runtime path, not a
|
||||
detached workaround
|
||||
- the worktree database is a full bootstrapped isolated clone of the primary
|
||||
instance database
|
||||
- `/api/health` returns `status: ok` and `bootstrapStatus: ready`
|
||||
- the root page returns `200` and does not show the first-admin setup gate
|
||||
- the board user can log in with their normal dev credentials
|
||||
- the app shows populated cloned data, not only a manually copied auth user
|
||||
- the main control plane shows the service as `running` / `healthy` with the
|
||||
expected URL
|
||||
- the served workspace app also knows about that service and shows it as
|
||||
`running` / `healthy`
|
||||
|
||||
If any item fails, keep fixing. Do not mark the issue done because one probe
|
||||
passed.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Read `doc/DEVELOPING.md` before running Paperclip CLI, dev server, worktree,
|
||||
database, build, or test commands.
|
||||
- Use the Paperclip CLI and API as the source of truth for worktree and
|
||||
database operations. Do not use `psql`, raw embedded-Postgres commands, or
|
||||
ad hoc row copying for the normal fix path.
|
||||
- Do not manually copy only auth rows as the final fix. That proves login, but
|
||||
it does not prove the isolated workspace has the full bootstrapped database.
|
||||
- Prefer managed runtime start/stop routes over `pnpm dev` or detached shell
|
||||
processes when the task is about a reusable workspace service.
|
||||
- Avoid destructive git or database actions. Preserve user changes in the
|
||||
worktree.
|
||||
- Run the smallest verification that proves the repair. Add focused tests only
|
||||
when code changed.
|
||||
- Leave an issue comment with root cause, exact fix, verification, and any
|
||||
commit link. Set a clear final status.
|
||||
|
||||
## Inputs to collect
|
||||
|
||||
Use environment variables when available. Do not print API keys or passwords.
|
||||
|
||||
- `PAPERCLIP_API_URL`: main control-plane API URL
|
||||
- `PAPERCLIP_API_KEY`: agent API key
|
||||
- `PAPERCLIP_RUN_ID`: current run id for runtime-service mutations
|
||||
- `PAPERCLIP_TASK_ID`: current issue id
|
||||
- `PAPERCLIP_COMPANY_ID`: company id
|
||||
- `PAPERCLIP_AGENT_ID`: current agent id
|
||||
- execution workspace id for the worktree service
|
||||
- runtime workspace command id, usually `service:paperclip-dev`
|
||||
- expected service URL, for example `http://paperclip-dev:40631`
|
||||
- dev credential owner, if the user supplied one; never post the password
|
||||
|
||||
If the execution workspace id or service command id is missing, read the issue,
|
||||
project, or execution-workspace API records first. Do not guess and start an
|
||||
unmanaged server on a random port.
|
||||
|
||||
## Normal run sequence
|
||||
|
||||
Use this when the user says to start the workspace, start it again, or fix a
|
||||
workspace that should be freshly ready.
|
||||
|
||||
1. Confirm the latest issue comment and restate the success condition in your
|
||||
own words.
|
||||
2. Inspect current runtime state from the main control plane.
|
||||
3. Stop any managed instance of the target runtime service.
|
||||
4. Reseed the worktree with a full clone from the primary instance when there
|
||||
is any doubt about database completeness.
|
||||
5. Start the runtime service through the managed runtime API.
|
||||
6. Verify health, bootstrap state, login readiness, populated data, and runtime
|
||||
visibility from both the main control plane and served workspace app.
|
||||
7. If a verification item fails, diagnose that exact failure and loop back to
|
||||
the narrowest repair step.
|
||||
8. Comment on the issue and set the final disposition.
|
||||
|
||||
## Managed start / stop
|
||||
|
||||
Use the runtime-service endpoints on the main control plane. Include
|
||||
`X-Paperclip-Run-Id` so the mutation is associated with the current heartbeat.
|
||||
|
||||
```sh
|
||||
curl -sS -X POST \
|
||||
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/stop" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary '{"workspaceCommandId":"service:paperclip-dev"}'
|
||||
```
|
||||
|
||||
```sh
|
||||
curl -sS -X POST \
|
||||
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/start" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary '{"workspaceCommandId":"service:paperclip-dev"}'
|
||||
```
|
||||
|
||||
If the API returns an existing service, treat that as a candidate only. Verify
|
||||
its real `/api/health` before trusting it.
|
||||
|
||||
## Full database reseed
|
||||
|
||||
Use a full reseed when the app says setup is incomplete, login works but data
|
||||
is missing, the cloned app does not have the expected companies/issues/agents,
|
||||
or the user explicitly asks for the normal isolated-workspace database.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes
|
||||
```
|
||||
|
||||
After reseed, restart through the managed runtime path. A reseed can copy
|
||||
runtime-service rows whose ids no longer match the local process registry, so
|
||||
runtime adoption and reconciliation must be verified after the start.
|
||||
|
||||
Do not consider the reseed complete until the served app has both auth and
|
||||
populated product data. A one-off inserted user/account row is a diagnostic
|
||||
clue, not the final state.
|
||||
|
||||
## Core verification probes
|
||||
|
||||
Set `SERVICE_URL` to the service URL returned by the runtime API.
|
||||
|
||||
```sh
|
||||
curl -sS "$SERVICE_URL/api/health" | jq
|
||||
curl -sS -I "$SERVICE_URL/" | head
|
||||
```
|
||||
|
||||
Expected health:
|
||||
|
||||
- `status: "ok"`
|
||||
- `bootstrapStatus: "ready"`
|
||||
- `bootstrapInviteActive: false`
|
||||
|
||||
Failures to reject:
|
||||
|
||||
- `bootstrap_pending`: the instance will show the first-admin setup gate
|
||||
- `database_unreachable`: a web process is listening but its database is dead
|
||||
- a root `200` with unhealthy `/api/health`: stale process adoption bug or a
|
||||
dead embedded database behind a live Node process
|
||||
|
||||
Check the port owner when a process is already listening:
|
||||
|
||||
```sh
|
||||
lsof -nP -iTCP:"$SERVICE_PORT" -sTCP:LISTEN || true
|
||||
```
|
||||
|
||||
Use this only to identify and remove a stale matching Paperclip dev-runner
|
||||
process after managed stop fails. Do not kill unrelated processes.
|
||||
|
||||
## Verify main control-plane runtime state
|
||||
|
||||
Read the execution workspace from the main API and inspect the runtime service
|
||||
record.
|
||||
|
||||
```sh
|
||||
curl -sS \
|
||||
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
|
||||
```
|
||||
|
||||
The target service should show:
|
||||
|
||||
- matching workspace command id, usually `service:paperclip-dev`
|
||||
- `status: "running"`
|
||||
- healthy health fields, if present
|
||||
- the same URL you are probing
|
||||
- a local provider reference that maps to the running port owner
|
||||
|
||||
If the main app says running but `/api/health` is bad, stop and replace the
|
||||
stale process through the managed runtime.
|
||||
|
||||
## Verify served workspace runtime state
|
||||
|
||||
The cloned Paperclip app must also know about the service. Query the same
|
||||
execution workspace through the served app when agent auth is available there:
|
||||
|
||||
```sh
|
||||
curl -sS \
|
||||
"$SERVICE_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
|
||||
```
|
||||
|
||||
The served app should agree that the service is `running` / `healthy` at the
|
||||
same URL. If the main control plane and served app disagree after a reseed,
|
||||
the cloned database may contain copied runtime-service ids that do not match
|
||||
the local process registry. Use the normal start/adoption path again and verify
|
||||
both sides. If code changed in this area, add a focused regression test.
|
||||
|
||||
## Verify auth and populated data
|
||||
|
||||
Auth and data checks should use product APIs and browser/QA review, not raw DB
|
||||
queries.
|
||||
|
||||
Minimum API checks:
|
||||
|
||||
```sh
|
||||
curl -sS "$SERVICE_URL/api/health" | jq '.status, .bootstrapStatus'
|
||||
curl -sS "$SERVICE_URL/api/companies" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
|
||||
curl -sS "$SERVICE_URL/api/agents/me" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq
|
||||
```
|
||||
|
||||
Then verify at least one expected cloned product record through the API, such
|
||||
as a known project, issue key, company, or execution workspace that should
|
||||
exist in the primary instance. Pick a record relevant to the current issue
|
||||
rather than a random table count.
|
||||
|
||||
Browser or QA check:
|
||||
|
||||
- open the service URL
|
||||
- confirm the first-admin setup gate is gone
|
||||
- sign in with normal dev credentials
|
||||
- confirm the board loads populated companies/projects/issues/runs
|
||||
- confirm the workspace service appears with the running URL
|
||||
|
||||
If this environment cannot launch a browser, ask QA to do the visual/login
|
||||
check and still complete all API checks you can run. Report that browser
|
||||
verification was delegated and why.
|
||||
|
||||
## Common failures and fixes
|
||||
|
||||
### Setup gate appears
|
||||
|
||||
Symptom: the page says no admin has claimed the instance.
|
||||
|
||||
Fix: run a full worktree reseed from the primary instance, then restart the
|
||||
managed service. Claiming a first admin can clear the gate, but if the user
|
||||
asked for the normal isolated workspace database, full reseed is the correct
|
||||
fix.
|
||||
|
||||
Verify: `/api/health` has `bootstrapStatus: ready`, login works, and populated
|
||||
data exists.
|
||||
|
||||
### Login fails
|
||||
|
||||
Symptom: bootstrap is ready, but the user's normal dev credentials do not work.
|
||||
|
||||
Likely cause: the isolated DB has roles or bootstrap state but lacks the
|
||||
primary instance auth users/accounts.
|
||||
|
||||
Fix: full reseed. Do not manually copy only Better Auth user/account rows as
|
||||
the final fix.
|
||||
|
||||
Verify: user login through browser/QA and `/api/agents/me` with the agent key.
|
||||
|
||||
### Login works but data is missing
|
||||
|
||||
Symptom: user can sign in, but companies/issues/projects/runs are empty or
|
||||
clearly incomplete.
|
||||
|
||||
Likely cause: a partial auth repair was done instead of a full cloned database.
|
||||
|
||||
Fix: full reseed, managed restart, then verify representative cloned records.
|
||||
|
||||
### Port listens but health says database unreachable
|
||||
|
||||
Symptom: `curl -I /` returns a response, but `/api/health` reports
|
||||
`database_unreachable`.
|
||||
|
||||
Likely cause: stale Node/web process remained alive after embedded Postgres
|
||||
died.
|
||||
|
||||
Fix: managed stop first. If the process survives, identify the matching
|
||||
Paperclip dev-runner process group for the target port and terminate only that
|
||||
group. Then managed start.
|
||||
|
||||
Verify: `/api/health` is ok after a stability wait and the runtime record is
|
||||
healthy.
|
||||
|
||||
### Main control plane loses track of a running service
|
||||
|
||||
Symptom: the service URL works, but the main app says the service was not
|
||||
created or is stopped.
|
||||
|
||||
Likely cause: detached workaround process, stale provider ref, or service
|
||||
adoption trusted the root URL instead of health.
|
||||
|
||||
Fix: shut down the unmanaged process and restart through the managed runtime.
|
||||
If code repair is needed, ensure adoption checks `/api/health`, replaces
|
||||
unhealthy adopted processes, and records the current provider ref.
|
||||
|
||||
Verify: main runtime row and `/api/health` agree.
|
||||
|
||||
### Served app loses track after full reseed
|
||||
|
||||
Symptom: the main app sees `paperclip-dev` running, but the cloned app copied a
|
||||
runtime-service row whose id does not match the local registry.
|
||||
|
||||
Likely cause: normal DB clone copied persisted runtime rows from the primary
|
||||
instance into an isolated environment with different local process metadata.
|
||||
|
||||
Fix: use managed start/adoption again. If code repair is needed, adoption
|
||||
should reconcile by service identity and port, not only by copied row id.
|
||||
|
||||
Verify: main app and served app both show the same service as
|
||||
`running` / `healthy`.
|
||||
|
||||
### Runtime start returns permission or run-FK errors in the cloned app
|
||||
|
||||
Symptom: starting from the served app returns `403` or a mutation partially
|
||||
applies before activity logging fails.
|
||||
|
||||
Likely causes: the cloned issue/run/agent state does not match the current
|
||||
heartbeat, or the run id is absent in the cloned database after reseed.
|
||||
|
||||
Fix: prefer the main control-plane managed runtime path and full reseed. If
|
||||
the cloned app state itself must be repaired, use normal Paperclip issue/run
|
||||
transitions first. Do not hide the condition with raw DB edits; report the
|
||||
exact guard or missing row if it blocks the normal path.
|
||||
|
||||
## When code changes are required
|
||||
|
||||
Make a code change only when the normal operational repair exposes a product
|
||||
bug. Examples from this failure class:
|
||||
|
||||
- local port owner detection used the wrong `lsof` arguments
|
||||
- service adoption trusted root `200` instead of `/api/health`
|
||||
- unhealthy adopted services were not terminated/replaced
|
||||
- reseeded runtime-service ids were not reconciled with local process registry
|
||||
|
||||
Add focused tests in the affected service test file. For workspace runtime
|
||||
repairs, the narrow verification is usually:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Commit logical code changes and link the commit in the issue comment. If no
|
||||
tracked code changed, say so explicitly.
|
||||
|
||||
## Final issue comment template
|
||||
|
||||
Use concrete evidence, not a vague "it works".
|
||||
|
||||
```md
|
||||
Fixed and verified the workspace service.
|
||||
|
||||
Root cause:
|
||||
- <why it broke>
|
||||
|
||||
Fix:
|
||||
- <normal reseed/start/repair steps>
|
||||
- <code commit if any>
|
||||
|
||||
Verified:
|
||||
- main control plane shows <service> running/healthy at <url>
|
||||
- served workspace app shows the same service running/healthy
|
||||
- <url>/api/health is ok with bootstrapStatus ready
|
||||
- root page returns 200 and no setup gate
|
||||
- dev login verified by <agent browser / QA / user> without posting credentials
|
||||
- cloned data verified via <specific API records>
|
||||
- targeted tests: <commands>
|
||||
|
||||
Remaining:
|
||||
- <none, or named owner/action if blocked>
|
||||
```
|
||||
|
||||
Mark the issue `done` only when every success-condition item is satisfied. If
|
||||
not, mark `blocked` with a named unblock owner and the exact action needed.
|
||||
|
|
@ -85,7 +85,7 @@ Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` ali
|
|||
When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file.
|
||||
|
||||
6. Attach inspectable generated artifacts.
|
||||
When your task produces a user-inspectable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. See `doc/AGENT-ARTIFACTS.md` for `.mp4` and `.webm` examples.
|
||||
When your task produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path. See `doc/AGENT-ARTIFACTS.md` for details and `.mp4`/`.webm` examples.
|
||||
|
||||
## 6. Database Change Workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# Agent Artifact Upload Workflow
|
||||
|
||||
Generated files that a board user or reviewer should inspect must be attached to
|
||||
the Paperclip issue before the agent chooses a final disposition. A local
|
||||
workspace path is not enough, because cloud users and reviewers often cannot
|
||||
access the agent's disk.
|
||||
Generated files that a board user or reviewer should inspect as deliverables
|
||||
must be attached to the Paperclip issue before the agent chooses a final
|
||||
disposition. A local workspace path is not enough, because cloud users and
|
||||
reviewers often cannot access the agent's disk.
|
||||
|
||||
Use the helper bundled with the Paperclip skill from the repo root:
|
||||
|
||||
|
|
@ -27,9 +27,50 @@ It uploads the file to
|
|||
artifact work product on `POST /api/issues/{issueId}/work-products` by default.
|
||||
The command prints issue-safe markdown links for the final task comment.
|
||||
|
||||
## Uploaded Artifacts vs Workspace Files
|
||||
|
||||
Use uploaded artifacts for deliverables: videos, PDFs, screenshots, archives,
|
||||
reports, rendered HTML, or any file the board should inspect without needing the
|
||||
agent's checkout. Attachment-backed artifact work products set `type` to
|
||||
`artifact` and `provider` to `paperclip`, with metadata canonicalized from the
|
||||
uploaded `attachmentId`.
|
||||
|
||||
Use `workspace_file` metadata only for important files that intentionally remain
|
||||
in a project or execution workspace, such as source files, committed markdown
|
||||
plans, or generated files whose meaning depends on the checkout. Workspace-only
|
||||
references are useful signposts, but they are not durable uploads.
|
||||
|
||||
Expected work product metadata shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"resourceRef": {
|
||||
"kind": "workspace_file",
|
||||
"issueId": "<issue-id>",
|
||||
"workspaceKind": "execution_workspace",
|
||||
"workspaceId": "<execution-workspace-id>",
|
||||
"relativePath": "doc/plans/example.md",
|
||||
"line": 1,
|
||||
"column": 1,
|
||||
"displayPath": "doc/plans/example.md:1:1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`workspaceKind` is `execution_workspace` or `project_workspace`. `line` and
|
||||
`column` are optional. `relativePath` must be relative to that workspace root;
|
||||
do not store host-local absolute paths as workspace references.
|
||||
|
||||
Workspace file links resolve only inside registered Paperclip workspaces. The
|
||||
default target is the current issue's execution workspace first, then its
|
||||
project workspace. A link may target another same-company project workspace only
|
||||
when it carries both that `projectId` and `workspaceId`. Paperclip does not
|
||||
resolve arbitrary machine-wide filesystem paths, absolute host paths, home
|
||||
paths, or relative paths that escape the selected workspace.
|
||||
|
||||
## Completion Pattern
|
||||
|
||||
When a task produces a user-inspectable file:
|
||||
When a task produces a user-inspectable deliverable file:
|
||||
|
||||
1. Generate and verify the file locally.
|
||||
2. Upload it with `skills/paperclip/scripts/paperclip-upload-artifact.sh`.
|
||||
|
|
@ -38,9 +79,12 @@ When a task produces a user-inspectable file:
|
|||
4. Link the printed attachment URL in the final issue comment.
|
||||
5. Then set the final issue status.
|
||||
|
||||
Final comments should name the uploaded artifact, not just the local filesystem
|
||||
path. Local paths can be included as diagnostic context, but they cannot be the
|
||||
only access path.
|
||||
Final comments should name and link the uploaded artifact or work product, not
|
||||
just the local filesystem path. For workspace-only files, include the work
|
||||
product title and recorded relative path. Local paths can be included as
|
||||
diagnostic context, but they cannot be the only access path. Browse/search is a
|
||||
fallback for recovering workspace files when the issue link or chip is not
|
||||
available, not the preferred way to deliver files to users.
|
||||
|
||||
## Video Examples
|
||||
|
||||
|
|
|
|||
|
|
@ -214,9 +214,9 @@ pnpm paperclipai configure --section storage
|
|||
|
||||
## Agent Artifact Uploads
|
||||
|
||||
When an agent generates a file that a board user or reviewer should inspect,
|
||||
attach it to the issue before marking the task complete. Do not rely on a local
|
||||
workspace path as the only access path.
|
||||
When an agent generates a file that a board user or reviewer should inspect as
|
||||
a deliverable, attach it to the issue before marking the task complete. Do not
|
||||
rely on a local workspace path as the only access path.
|
||||
|
||||
Use the helper bundled with the Paperclip skill from the repo root:
|
||||
|
||||
|
|
@ -237,6 +237,10 @@ skills/paperclip/scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
|
|||
The helper uploads the file as an issue attachment, creates an artifact work
|
||||
product by default, and prints markdown links for the final issue comment. See
|
||||
`doc/AGENT-ARTIFACTS.md` for the full completion pattern and direct API shape.
|
||||
If a file intentionally remains workspace-only, create a work product with
|
||||
`metadata.resourceRef.kind: "workspace_file"` and include the workspace-relative
|
||||
path in the final comment. Use browse/search only as the fallback for recovering
|
||||
that file, not as the main completion path for deliverables.
|
||||
|
||||
## Default Agent Workspaces
|
||||
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@ Operational policy:
|
|||
- Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`.
|
||||
- Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking.
|
||||
- Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`.
|
||||
- Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access.
|
||||
|
||||
## 7.15 `documents` + `document_revisions` + `issue_documents`
|
||||
|
||||
|
|
|
|||
|
|
@ -449,6 +449,19 @@ export type {
|
|||
WorkspaceOperation,
|
||||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileContentEncoding,
|
||||
WorkspaceFileListDirectoryItem,
|
||||
WorkspaceFileListFileItem,
|
||||
WorkspaceFileListItem,
|
||||
WorkspaceFileListMode,
|
||||
WorkspaceFileListResponse,
|
||||
WorkspaceFilePreviewKind,
|
||||
WorkspaceFileRef,
|
||||
WorkspaceFileResourceKind,
|
||||
WorkspaceFileSelector,
|
||||
WorkspaceFileWorkspaceKind,
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
WorkspaceRealizationRecord,
|
||||
WorkspaceRealizationRequest,
|
||||
|
|
@ -1014,6 +1027,7 @@ export {
|
|||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
createIssueWorkProductSchema,
|
||||
issueWorkProductMetadataSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
attachmentArtifactWorkProductMetadataSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
|
|
@ -1037,6 +1051,16 @@ export {
|
|||
executionWorkspaceCloseLinkedIssueSchema,
|
||||
executionWorkspaceCloseReadinessSchema,
|
||||
executionWorkspaceCloseReadinessStateSchema,
|
||||
resolvedWorkspaceResourceSchema,
|
||||
workspaceFileContentSchema,
|
||||
workspaceFileListModeSchema,
|
||||
workspaceFileListQuerySchema,
|
||||
workspaceFilePreviewKindSchema,
|
||||
workspaceFileRefSchema,
|
||||
workspaceFileResourceKindSchema,
|
||||
workspaceFileResourceQuerySchema,
|
||||
workspaceFileSelectorSchema,
|
||||
workspaceFileWorkspaceKindSchema,
|
||||
issueDocumentFormatSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
|
|
@ -1065,6 +1089,8 @@ export {
|
|||
type UpdateIssueWorkProduct,
|
||||
type CompanyArtifactsQuery,
|
||||
type UpdateExecutionWorkspace,
|
||||
type WorkspaceFileListQuery,
|
||||
type WorkspaceFileResourceQuery,
|
||||
type IssueDocumentFormat,
|
||||
type UpsertIssueDocument,
|
||||
type RestoreIssueDocumentRevision,
|
||||
|
|
|
|||
|
|
@ -216,6 +216,21 @@ export type {
|
|||
WorkspaceOperationPhase,
|
||||
WorkspaceOperationStatus,
|
||||
} from "./workspace-operation.js";
|
||||
export type {
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileContentEncoding,
|
||||
WorkspaceFileListDirectoryItem,
|
||||
WorkspaceFileListFileItem,
|
||||
WorkspaceFileListItem,
|
||||
WorkspaceFileListMode,
|
||||
WorkspaceFileListResponse,
|
||||
WorkspaceFilePreviewKind,
|
||||
WorkspaceFileRef,
|
||||
WorkspaceFileResourceKind,
|
||||
WorkspaceFileSelector,
|
||||
WorkspaceFileWorkspaceKind,
|
||||
ResolvedWorkspaceResource,
|
||||
} from "./workspace-file-resource.js";
|
||||
export type {
|
||||
IssueWorkProduct,
|
||||
IssueWorkProductType,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
export type WorkspaceFileWorkspaceKind = "execution_workspace" | "project_workspace";
|
||||
export type WorkspaceFileSelector = "auto" | "execution" | "project";
|
||||
export type WorkspaceFileListMode = "all" | "recent" | "changed";
|
||||
export type WorkspaceFilePreviewKind = "text" | "image" | "video" | "pdf" | "unsupported";
|
||||
export type WorkspaceFileResourceKind = "file" | "directory" | "remote_resource";
|
||||
export type WorkspaceFileContentEncoding = "utf8" | "base64";
|
||||
|
||||
export interface WorkspaceFileRef {
|
||||
kind: "workspace_file";
|
||||
issueId?: string;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
workspaceKind: WorkspaceFileWorkspaceKind;
|
||||
workspaceId: string;
|
||||
relativePath: string;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface ResolvedWorkspaceResource {
|
||||
kind: WorkspaceFileResourceKind;
|
||||
provider: "local_fs" | "git_worktree" | "remote_managed" | string;
|
||||
title: string;
|
||||
displayPath: string;
|
||||
workspaceLabel: string;
|
||||
workspaceKind: WorkspaceFileWorkspaceKind;
|
||||
workspaceId: string;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
contentType?: string | null;
|
||||
byteSize?: number | null;
|
||||
previewKind: WorkspaceFilePreviewKind;
|
||||
denialReason?: string | null;
|
||||
capabilities: {
|
||||
preview: boolean;
|
||||
download: false;
|
||||
listChildren: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceFileContent {
|
||||
resource: ResolvedWorkspaceResource;
|
||||
content: {
|
||||
encoding: WorkspaceFileContentEncoding;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceFileListFileItem {
|
||||
kind: "file";
|
||||
provider: "local_fs" | "git_worktree" | string;
|
||||
title: string;
|
||||
relativePath: string;
|
||||
displayPath: string;
|
||||
workspaceLabel: string;
|
||||
workspaceKind: WorkspaceFileWorkspaceKind;
|
||||
workspaceId: string;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
contentType?: string | null;
|
||||
byteSize?: number | null;
|
||||
modifiedAt?: string | null;
|
||||
previewKind: Exclude<WorkspaceFilePreviewKind, "unsupported">;
|
||||
capabilities: {
|
||||
preview: true;
|
||||
download: false;
|
||||
listChildren: false;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceFileListDirectoryItem {
|
||||
kind: "directory";
|
||||
provider: "local_fs" | "git_worktree" | string;
|
||||
title: string;
|
||||
relativePath: string;
|
||||
displayPath: string;
|
||||
workspaceLabel: string;
|
||||
workspaceKind: WorkspaceFileWorkspaceKind;
|
||||
workspaceId: string;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
contentType: null;
|
||||
byteSize: null;
|
||||
modifiedAt?: string | null;
|
||||
previewKind: "unsupported";
|
||||
capabilities: {
|
||||
preview: false;
|
||||
download: false;
|
||||
listChildren: true;
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceFileListItem = WorkspaceFileListFileItem | WorkspaceFileListDirectoryItem;
|
||||
|
||||
export interface WorkspaceFileListResponse {
|
||||
kind: "workspace_file_list";
|
||||
state: "available" | "unavailable";
|
||||
unavailableReason?: string | null;
|
||||
workspace: {
|
||||
provider: "local_fs" | "git_worktree" | string;
|
||||
workspaceLabel: string;
|
||||
workspaceKind: WorkspaceFileWorkspaceKind;
|
||||
workspaceId: string;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
} | null;
|
||||
query: {
|
||||
workspace: WorkspaceFileSelector;
|
||||
mode: WorkspaceFileListMode;
|
||||
path?: string | null;
|
||||
q: string | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
items: WorkspaceFileListItem[];
|
||||
scannedCount: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
|
@ -320,12 +320,14 @@ export {
|
|||
|
||||
export {
|
||||
createIssueWorkProductSchema,
|
||||
issueWorkProductMetadataSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
attachmentArtifactWorkProductMetadataSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
issueWorkProductStatusSchema,
|
||||
issueWorkProductReviewStateSchema,
|
||||
type CreateIssueWorkProduct,
|
||||
type IssueWorkProductMetadata,
|
||||
type UpdateIssueWorkProduct,
|
||||
} from "./work-product.js";
|
||||
|
||||
|
|
@ -356,6 +358,21 @@ export {
|
|||
type UpdateExecutionWorkspace,
|
||||
} from "./execution-workspace.js";
|
||||
|
||||
export {
|
||||
resolvedWorkspaceResourceSchema,
|
||||
workspaceFileListModeSchema,
|
||||
workspaceFileListQuerySchema,
|
||||
workspaceFileContentSchema,
|
||||
workspaceFilePreviewKindSchema,
|
||||
workspaceFileRefSchema,
|
||||
workspaceFileResourceKindSchema,
|
||||
workspaceFileResourceQuerySchema,
|
||||
workspaceFileSelectorSchema,
|
||||
workspaceFileWorkspaceKindSchema,
|
||||
type WorkspaceFileListQuery,
|
||||
type WorkspaceFileResourceQuery,
|
||||
} from "./workspace-file-resource.js";
|
||||
|
||||
export {
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
enableExperimentalFileViewer: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { workspaceFileRefSchema } from "./workspace-file-resource.js";
|
||||
|
||||
function attachmentContentPath(attachmentId: string): string {
|
||||
return `/api/attachments/${attachmentId}/content`;
|
||||
|
|
@ -68,6 +69,14 @@ export const attachmentArtifactWorkProductMetadataSchema = z.object({
|
|||
|
||||
export type AttachmentArtifactWorkProductMetadata = z.infer<typeof attachmentArtifactWorkProductMetadataSchema>;
|
||||
|
||||
export const issueWorkProductMetadataSchema = z
|
||||
.object({
|
||||
resourceRef: workspaceFileRefSchema.optional().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type IssueWorkProductMetadata = z.infer<typeof issueWorkProductMetadataSchema>;
|
||||
|
||||
export const createIssueWorkProductSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
|
|
@ -82,7 +91,7 @@ export const createIssueWorkProductSchema = z.object({
|
|||
isPrimary: z.boolean().optional().default(false),
|
||||
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"),
|
||||
summary: z.string().optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
metadata: issueWorkProductMetadataSchema.optional().nullable(),
|
||||
createdByRunId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const workspaceFileListSearchMaxBytes = 128;
|
||||
|
||||
function utf8ByteLength(value: string) {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
|
||||
export const workspaceFileWorkspaceKindSchema = z.enum(["execution_workspace", "project_workspace"]);
|
||||
export const workspaceFileSelectorSchema = z.enum(["auto", "execution", "project"]).default("auto");
|
||||
export const workspaceFileListModeSchema = z.enum(["all", "recent", "changed"]).default("all");
|
||||
export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "video", "pdf", "unsupported"]);
|
||||
export const workspaceFileResourceKindSchema = z.enum(["file", "directory", "remote_resource"]);
|
||||
|
||||
export const workspaceFileRefSchema = z.object({
|
||||
kind: z.literal("workspace_file"),
|
||||
issueId: z.string().uuid().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
projectName: z.string().min(1).optional(),
|
||||
workspaceKind: workspaceFileWorkspaceKindSchema,
|
||||
workspaceId: z.string().uuid(),
|
||||
relativePath: z.string().min(1),
|
||||
line: z.number().int().positive().nullable().optional(),
|
||||
column: z.number().int().positive().nullable().optional(),
|
||||
displayPath: z.string().min(1),
|
||||
});
|
||||
|
||||
export const workspaceFileResourceQuerySchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
workspaceId: z.string().uuid().optional(),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((value) => !/[\x00-\x1f\x7f]/.test(value), {
|
||||
message: "Workspace file path contains an invalid character",
|
||||
params: { code: "invalid_path" },
|
||||
}),
|
||||
workspace: workspaceFileSelectorSchema.optional(),
|
||||
}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), {
|
||||
message: "Workspace file target requires both projectId and workspaceId",
|
||||
path: ["workspaceId"],
|
||||
params: { code: "invalid_target" },
|
||||
});
|
||||
|
||||
export const workspaceFileListQuerySchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
workspaceId: z.string().uuid().optional(),
|
||||
workspace: workspaceFileSelectorSchema.optional(),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((value) => !/[\x00-\x1f\x7f]/.test(value), {
|
||||
message: "Workspace folder path contains an invalid character",
|
||||
params: { code: "invalid_path" },
|
||||
})
|
||||
.optional(),
|
||||
mode: workspaceFileListModeSchema.optional(),
|
||||
q: z
|
||||
.string()
|
||||
.refine((value) => !/[\x00-\x1f\x7f]/.test(value), {
|
||||
message: "Workspace file search contains an invalid character",
|
||||
params: { code: "invalid_query" },
|
||||
})
|
||||
.refine((value) => utf8ByteLength(value.trim()) <= workspaceFileListSearchMaxBytes, {
|
||||
message: "Workspace file search is too long",
|
||||
params: { code: "invalid_query" },
|
||||
})
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
offset: z.coerce.number().int().min(0).max(10_000).default(0),
|
||||
}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), {
|
||||
message: "Workspace file target requires both projectId and workspaceId",
|
||||
path: ["workspaceId"],
|
||||
params: { code: "invalid_target" },
|
||||
});
|
||||
|
||||
export const resolvedWorkspaceResourceSchema = z.object({
|
||||
kind: workspaceFileResourceKindSchema,
|
||||
provider: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
displayPath: z.string().min(1),
|
||||
workspaceLabel: z.string().min(1),
|
||||
workspaceKind: workspaceFileWorkspaceKindSchema,
|
||||
workspaceId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable().optional(),
|
||||
projectName: z.string().min(1).nullable().optional(),
|
||||
contentType: z.string().nullable().optional(),
|
||||
byteSize: z.number().int().nonnegative().nullable().optional(),
|
||||
previewKind: workspaceFilePreviewKindSchema,
|
||||
denialReason: z.string().nullable().optional(),
|
||||
capabilities: z.object({
|
||||
preview: z.boolean(),
|
||||
download: z.literal(false),
|
||||
listChildren: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const workspaceFileContentSchema = z.object({
|
||||
resource: resolvedWorkspaceResourceSchema,
|
||||
content: z.object({
|
||||
encoding: z.enum(["utf8", "base64"]),
|
||||
data: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type WorkspaceFileResourceQuery = z.infer<typeof workspaceFileResourceQuerySchema>;
|
||||
export type WorkspaceFileListQuery = z.infer<typeof workspaceFileListQuerySchema>;
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
issueWorkProductMetadataSchema,
|
||||
createIssueWorkProductSchema,
|
||||
} from "./validators/work-product.js";
|
||||
|
||||
describe("work product metadata with resource ref", () => {
|
||||
it("accepts valid workspace file ref in metadata", () => {
|
||||
const result = issueWorkProductMetadataSchema.parse({
|
||||
resourceRef: {
|
||||
kind: "workspace_file",
|
||||
issueId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "223e4567-e89b-12d3-a456-426614174001",
|
||||
relativePath: "src/components/App.tsx",
|
||||
line: 42,
|
||||
column: 10,
|
||||
displayPath: "src/components/App.tsx:42:10",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.resourceRef?.kind).toBe("workspace_file");
|
||||
expect(result.resourceRef?.issueId).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
expect(result.resourceRef?.relativePath).toBe("src/components/App.tsx");
|
||||
});
|
||||
|
||||
it("accepts workspace file ref without line/column", () => {
|
||||
const result = issueWorkProductMetadataSchema.parse({
|
||||
resourceRef: {
|
||||
kind: "workspace_file",
|
||||
issueId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
workspaceKind: "project_workspace",
|
||||
workspaceId: "223e4567-e89b-12d3-a456-426614174001",
|
||||
relativePath: "README.md",
|
||||
displayPath: "README.md",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.resourceRef?.line).toBeUndefined();
|
||||
expect(result.resourceRef?.column).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts null resourceRef", () => {
|
||||
const result = issueWorkProductMetadataSchema.parse({
|
||||
resourceRef: null,
|
||||
});
|
||||
|
||||
expect(result.resourceRef).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts undefined resourceRef", () => {
|
||||
const result = issueWorkProductMetadataSchema.parse({});
|
||||
|
||||
expect(result.resourceRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid resourceRef missing kind", () => {
|
||||
expect(() => {
|
||||
issueWorkProductMetadataSchema.parse({
|
||||
resourceRef: {
|
||||
issueId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "223e4567-e89b-12d3-a456-426614174001",
|
||||
relativePath: "src/components/App.tsx",
|
||||
displayPath: "src/components/App.tsx",
|
||||
},
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid workspaceKind", () => {
|
||||
expect(() => {
|
||||
issueWorkProductMetadataSchema.parse({
|
||||
resourceRef: {
|
||||
kind: "workspace_file",
|
||||
issueId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
workspaceKind: "invalid_kind",
|
||||
workspaceId: "223e4567-e89b-12d3-a456-426614174001",
|
||||
relativePath: "src/components/App.tsx",
|
||||
displayPath: "src/components/App.tsx",
|
||||
},
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid issueId (not UUID)", () => {
|
||||
expect(() => {
|
||||
issueWorkProductMetadataSchema.parse({
|
||||
resourceRef: {
|
||||
kind: "workspace_file",
|
||||
issueId: "not-a-uuid",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "223e4567-e89b-12d3-a456-426614174001",
|
||||
relativePath: "src/components/App.tsx",
|
||||
displayPath: "src/components/App.tsx",
|
||||
},
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create issue work product with resource ref", () => {
|
||||
it("accepts work product with resourceRef in metadata", () => {
|
||||
const result = createIssueWorkProductSchema.parse({
|
||||
type: "artifact",
|
||||
provider: "paperclip",
|
||||
title: "Build output",
|
||||
status: "active",
|
||||
metadata: {
|
||||
resourceRef: {
|
||||
kind: "workspace_file",
|
||||
issueId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "223e4567-e89b-12d3-a456-426614174001",
|
||||
relativePath: "dist/index.js",
|
||||
displayPath: "dist/index.js",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((result.metadata as any)?.resourceRef?.kind).toBe("workspace_file");
|
||||
});
|
||||
|
||||
it("accepts work product without resourceRef in metadata", () => {
|
||||
const result = createIssueWorkProductSchema.parse({
|
||||
type: "pull_request",
|
||||
provider: "github",
|
||||
title: "Feature PR",
|
||||
status: "active",
|
||||
metadata: {
|
||||
prNumber: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.metadata?.resourceRef).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env node
|
||||
// Screenshots every FileViewerSheet Storybook state at desktop + mobile viewports.
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const BASE = "http://localhost:6006/iframe.html";
|
||||
const OUT_DIR = path.resolve(process.argv[2] || "/tmp/pap-1963");
|
||||
|
||||
const SHOTS = [
|
||||
{ id: "components-file-viewer-sheet--text-content-with-highlighted-line", label: "content-highlighted-line" },
|
||||
{ id: "components-file-viewer-sheet--text-content-long-path-truncation", label: "content-long-path" },
|
||||
{ id: "components-file-viewer-sheet--open-file-prompt", label: "open-file-prompt" },
|
||||
{ id: "components-file-viewer-sheet--loading-spinner", label: "loading-spinner" },
|
||||
{ id: "components-file-viewer-sheet--error-not-found-with-fallback", label: "error-not-found" },
|
||||
{ id: "components-file-viewer-sheet--error-no-workspace", label: "error-no-workspace" },
|
||||
{ id: "components-file-viewer-sheet--error-outside-workspace", label: "error-outside-workspace" },
|
||||
{ id: "components-file-viewer-sheet--error-denied-sensitive", label: "error-denied" },
|
||||
{ id: "components-file-viewer-sheet--remote-workspace", label: "remote-workspace" },
|
||||
{ id: "components-file-viewer-sheet--workspace-archived", label: "workspace-archived" },
|
||||
{ id: "components-file-viewer-sheet--binary-unsupported", label: "binary-unsupported" },
|
||||
{ id: "components-file-viewer-sheet--too-large-to-preview", label: "too-large" },
|
||||
];
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
async function run() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const vp of VIEWPORTS) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
for (const shot of SHOTS) {
|
||||
const url = `${BASE}?id=${shot.id}&viewMode=story&args=`;
|
||||
console.log(`[${vp.name}] → ${shot.label}`);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
// Give the sheet animation + potential network stubs ~1.5s to settle.
|
||||
await page.waitForTimeout(shot.label === "loading-spinner" ? 900 : 1600);
|
||||
const out = path.join(OUT_DIR, `${vp.name}-${shot.label}.png`);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
}
|
||||
// Extra capture: mobile view story (its own viewport story) as a cross-check.
|
||||
if (vp.name === "mobile") {
|
||||
await page.goto(
|
||||
`${BASE}?id=components-file-viewer-sheet--mobile-view&viewMode=story&args=`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({
|
||||
path: path.join(OUT_DIR, `mobile-story-variant.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nScreenshots written to ${OUT_DIR}`);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env node
|
||||
// Screenshot a single story id at desktop 1440x900.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const [, , storyId, out, widthArg, heightArg, waitArg] = process.argv;
|
||||
const width = Number(widthArg) || 1440;
|
||||
const height = Number(heightArg) || 900;
|
||||
const waitMs = Number(waitArg) || 2500;
|
||||
const url = `http://localhost:6006/iframe.html?id=${storyId}&viewMode=story`;
|
||||
|
||||
await fs.mkdir(path.dirname(path.resolve(out)), { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`Wrote ${out}`);
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -65,6 +65,7 @@ describe("instance settings routes", () => {
|
|||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
@ -84,6 +85,7 @@ describe("instance settings routes", () => {
|
|||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
@ -128,6 +130,7 @@ describe("instance settings routes", () => {
|
|||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ describe("instance settings service", () => {
|
|||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
@ -17,6 +18,7 @@ describe("instance settings service", () => {
|
|||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const apiPrefixes: Record<string, string> = {
|
|||
"dashboard.ts": "/api",
|
||||
"environments.ts": "/api",
|
||||
"execution-workspaces.ts": "/api",
|
||||
"file-resources.ts": "/api",
|
||||
"goals.ts": "/api",
|
||||
"health.ts": "/api/health",
|
||||
"inbox-dismissals.ts": "/api",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -37,7 +38,7 @@ import {
|
|||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
|
||||
import { readLocalServicePortOwner, writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
|
||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
|
|
@ -47,6 +48,17 @@ import {
|
|||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function stableStringifyForTest(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringifyForTest(entry)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyForTest(rec[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
const leasedRunIds = new Set<string>();
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -2915,6 +2927,34 @@ describe("resolveShell (shell fallback)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("readLocalServicePortOwner", () => {
|
||||
it("detects the owner of a listening TCP port", async () => {
|
||||
try {
|
||||
await execFileAsync("lsof", ["-v"]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
expect(port).toBeTypeOf("number");
|
||||
|
||||
const owner = await readLocalServicePortOwner(port!);
|
||||
expect(owner).toBe(process.pid);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
|
@ -3024,6 +3064,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await fs.rm(paperclipHome, { recursive: true, force: true });
|
||||
await resetRuntimeServicesForTests();
|
||||
|
||||
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||
|
|
@ -3046,6 +3087,224 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not reuse a stopped auto-port service port while another process owns it", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-unhealthy-adopt-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = `runtime-unhealthy-adopt-${randomUUID()}`;
|
||||
|
||||
const portProbe = net.createServer();
|
||||
await new Promise<void>((resolve) => portProbe.listen(0, "127.0.0.1", resolve));
|
||||
const address = portProbe.address();
|
||||
const stalePort = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
portProbe.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
expect(stalePort).toBeTypeOf("number");
|
||||
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const stoppedServiceId = randomUUID();
|
||||
const serviceCommand =
|
||||
"node -e \"const http=require('node:http'); const stale=process.env.STALE_HEALTH==='1'; http.createServer((req,res)=>{ if (req.url==='/api/health' && stale) { res.statusCode=503; res.end('database_unreachable'); return; } res.end('ok'); }).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
const scopeType = "agent";
|
||||
const scopeId = agentId;
|
||||
const reuseKey = createHash("sha256")
|
||||
.update(
|
||||
stableStringifyForTest({
|
||||
scopeType,
|
||||
scopeId,
|
||||
serviceName: "paperclip-dev",
|
||||
command: serviceCommand,
|
||||
cwd: workspaceRoot,
|
||||
port: null,
|
||||
env: {},
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
const staleProcess = spawn(resolveShell(), ["-lc", serviceCommand], {
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(stalePort),
|
||||
STALE_HEALTH: "1",
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
stdio: "ignore",
|
||||
});
|
||||
staleProcess.unref();
|
||||
|
||||
try {
|
||||
const rootUrl = `http://127.0.0.1:${stalePort}`;
|
||||
const healthUrl = `${rootUrl}/api/health`;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(rootUrl);
|
||||
if (response.ok) break;
|
||||
} catch {
|
||||
// Keep polling until the stale process has bound its port.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await expect(fetch(rootUrl)).resolves.toMatchObject({ ok: true });
|
||||
await expect(fetch(healthUrl)).resolves.toMatchObject({ ok: false, status: 503 });
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime unhealthy adoption test",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: null,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Runtime unhealthy adoption",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspaceRoot,
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values({
|
||||
id: stoppedServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId,
|
||||
issueId: null,
|
||||
scopeType,
|
||||
scopeId,
|
||||
serviceName: "paperclip-dev",
|
||||
status: "stopped",
|
||||
lifecycle: "shared",
|
||||
reuseKey,
|
||||
command: serviceCommand,
|
||||
cwd: workspaceRoot,
|
||||
port: stalePort,
|
||||
url: rootUrl,
|
||||
provider: "local_process",
|
||||
providerRef: String(staleProcess.pid ?? ""),
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
stoppedAt: new Date(),
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "unknown",
|
||||
});
|
||||
|
||||
leasedRunIds.add(runId);
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
db,
|
||||
runId,
|
||||
agent: {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
},
|
||||
issue: null,
|
||||
workspace: {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId,
|
||||
workspaceId: null,
|
||||
},
|
||||
executionWorkspaceId,
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "paperclip-dev",
|
||||
command: serviceCommand,
|
||||
cwd: ".",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
expose: {
|
||||
type: "url",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "agent",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(services).toHaveLength(1);
|
||||
expect(services[0]?.reused).toBe(false);
|
||||
expect(services[0]?.id).toBe(stoppedServiceId);
|
||||
expect(services[0]?.port).not.toBe(stalePort);
|
||||
expect(services[0]?.url).not.toBe(rootUrl);
|
||||
await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
await expect(fetch(healthUrl)).resolves.toMatchObject({ ok: false, status: 503 });
|
||||
expect(await readLocalServicePortOwner(stalePort!)).toBe(staleProcess.pid);
|
||||
} finally {
|
||||
leasedRunIds.delete(runId);
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspaceRoot,
|
||||
});
|
||||
if (staleProcess.pid) {
|
||||
try {
|
||||
process.kill(-staleProcess.pid, "SIGKILL");
|
||||
} catch {
|
||||
try {
|
||||
process.kill(staleProcess.pid, "SIGKILL");
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("marks persisted local services stopped when the registry pid is stale", async () => {
|
||||
const companyId = randomUUID();
|
||||
const runtimeServiceId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { agentRoutes } from "./routes/agents.js";
|
|||
import { projectRoutes } from "./routes/projects.js";
|
||||
import { issueRoutes } from "./routes/issues.js";
|
||||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||
import { fileResourceRoutes } from "./routes/file-resources.js";
|
||||
import { routineRoutes } from "./routes/routines.js";
|
||||
import { environmentRoutes } from "./routes/environments.js";
|
||||
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
||||
|
|
@ -222,6 +223,7 @@ export async function createApp(
|
|||
pluginWorkerManager: workerManager,
|
||||
}));
|
||||
api.use(issueTreeControlRoutes(db));
|
||||
api.use(fileResourceRoutes(db));
|
||||
api.use(routineRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(environmentRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(executionWorkspaceRoutes(db));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ You are an agent at Paperclip company.
|
|||
- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning.
|
||||
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
|
||||
- Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit.
|
||||
- When your work produces a user-inspectable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. Use `skills/paperclip/scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path.
|
||||
- When your work produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. Use `skills/paperclip/scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path.
|
||||
- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.
|
||||
- Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.
|
||||
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,644 @@
|
|||
import { Router } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
workspaceFileListQuerySchema,
|
||||
workspaceFileResourceQuerySchema,
|
||||
type ResolvedWorkspaceResource,
|
||||
type WorkspaceFileContent,
|
||||
type WorkspaceFileListResponse,
|
||||
} from "@paperclipai/shared";
|
||||
import { HttpError, unprocessable } from "../errors.js";
|
||||
import { workspaceFileResourceService } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
|
||||
export type WorkspaceFileResourceService = {
|
||||
getIssue(issueId: string): Promise<{ companyId: string }>;
|
||||
list(issueId: string, input: {
|
||||
workspace?: "auto" | "execution" | "project" | null;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
path?: string | null;
|
||||
mode?: "all" | "recent" | "changed" | null;
|
||||
q?: string | null;
|
||||
limit?: number | null;
|
||||
offset?: number | null;
|
||||
}, opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> }): Promise<WorkspaceFileListResponse>;
|
||||
resolve(
|
||||
issueId: string,
|
||||
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
|
||||
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
|
||||
): Promise<ResolvedWorkspaceResource>;
|
||||
readContent(
|
||||
issueId: string,
|
||||
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
|
||||
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
|
||||
): Promise<WorkspaceFileContent>;
|
||||
};
|
||||
|
||||
type FileResourceLimiter = {
|
||||
acquire(key: string): () => void;
|
||||
};
|
||||
|
||||
export function createFileResourceLimiter(opts: {
|
||||
maxConcurrent?: number;
|
||||
maxRequests?: number;
|
||||
windowMs?: number;
|
||||
requestLimitMessage?: string;
|
||||
concurrencyLimitMessage?: string;
|
||||
} = {}): FileResourceLimiter {
|
||||
const maxConcurrent = opts.maxConcurrent ?? 6;
|
||||
const maxRequests = opts.maxRequests ?? 120;
|
||||
const windowMs = opts.windowMs ?? 60_000;
|
||||
const requestLimitMessage = opts.requestLimitMessage ?? "Too many file preview requests";
|
||||
const concurrencyLimitMessage = opts.concurrencyLimitMessage ?? "Too many concurrent file preview requests";
|
||||
const activeByKey = new Map<string, number>();
|
||||
const windowsByKey = new Map<string, { startedAt: number; count: number }>();
|
||||
|
||||
return {
|
||||
acquire(key: string) {
|
||||
const now = Date.now();
|
||||
for (const [windowKey, existing] of windowsByKey) {
|
||||
if (now - existing.startedAt >= windowMs) windowsByKey.delete(windowKey);
|
||||
}
|
||||
const window = windowsByKey.get(key);
|
||||
if (!window || now - window.startedAt >= windowMs) {
|
||||
windowsByKey.set(key, { startedAt: now, count: 1 });
|
||||
} else {
|
||||
window.count += 1;
|
||||
if (window.count > maxRequests) {
|
||||
throw new HttpError(429, requestLimitMessage, { code: "rate_limited" });
|
||||
}
|
||||
}
|
||||
|
||||
const active = activeByKey.get(key) ?? 0;
|
||||
if (active >= maxConcurrent) {
|
||||
throw new HttpError(429, concurrencyLimitMessage, { code: "concurrency_limited" });
|
||||
}
|
||||
activeByKey.set(key, active + 1);
|
||||
return () => {
|
||||
const current = activeByKey.get(key) ?? 0;
|
||||
if (current <= 1) activeByKey.delete(key);
|
||||
else activeByKey.set(key, current - 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileResourceListLimiter(opts: {
|
||||
maxConcurrent?: number;
|
||||
maxRequests?: number;
|
||||
windowMs?: number;
|
||||
} = {}): FileResourceLimiter {
|
||||
return createFileResourceLimiter({
|
||||
maxConcurrent: opts.maxConcurrent ?? 2,
|
||||
maxRequests: opts.maxRequests ?? 30,
|
||||
windowMs: opts.windowMs,
|
||||
requestLimitMessage: "Too many workspace file list requests",
|
||||
concurrencyLimitMessage: "Too many concurrent workspace file list requests",
|
||||
});
|
||||
}
|
||||
|
||||
function limiterKey(companyId: string, actorId: string, issueId: string) {
|
||||
return `${companyId}:${actorId}:${issueId}`;
|
||||
}
|
||||
|
||||
function readQuery(query: unknown) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = workspaceFileResourceQuerySchema.parse(query);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const refinement = error.errors.find((issue) => {
|
||||
const code = (issue as { params?: { code?: string } }).params?.code;
|
||||
return code === "invalid_path" || code === "invalid_target";
|
||||
});
|
||||
const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code;
|
||||
if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_path" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
path: parsed.path,
|
||||
workspace: parsed.workspace ?? "auto",
|
||||
projectId: parsed.projectId ?? null,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function readListQuery(query: unknown) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = workspaceFileListQuerySchema.parse(query);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const refinement = error.errors.find((issue) => {
|
||||
const code = (issue as { params?: { code?: string } }).params?.code;
|
||||
return code === "invalid_query" || code === "invalid_target" || code === "invalid_path";
|
||||
});
|
||||
const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code;
|
||||
if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_query" });
|
||||
throw unprocessable("Workspace file list query is invalid", { code: "invalid_query" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
workspace: parsed.workspace ?? "auto",
|
||||
projectId: parsed.projectId ?? null,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
path: parsed.path ?? null,
|
||||
mode: parsed.mode ?? "all",
|
||||
q: parsed.q?.trim() || null,
|
||||
limit: parsed.limit,
|
||||
offset: parsed.offset,
|
||||
};
|
||||
}
|
||||
|
||||
function activityDetails(input: {
|
||||
outcome: "success" | "denied" | "unavailable";
|
||||
workspaceKind?: string | null;
|
||||
workspaceId?: string | null;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
displayPath?: string | null;
|
||||
denialReason?: string | null;
|
||||
byteSize?: number | null;
|
||||
contentType?: string | null;
|
||||
}) {
|
||||
return {
|
||||
outcome: input.outcome,
|
||||
...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}),
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.projectName ? { projectName: input.projectName } : {}),
|
||||
...(input.displayPath ? { displayPath: input.displayPath } : {}),
|
||||
...(input.denialReason ? { denialReason: input.denialReason } : {}),
|
||||
...(typeof input.byteSize === "number" ? { byteSize: input.byteSize } : {}),
|
||||
...(input.contentType ? { contentType: input.contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function listActivityDetails(input: {
|
||||
outcome: "success" | "denied" | "unavailable";
|
||||
workspaceSelector: "auto" | "execution" | "project";
|
||||
mode: "all" | "recent" | "changed";
|
||||
workspaceKind?: string | null;
|
||||
workspaceId?: string | null;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
resultCount?: number | null;
|
||||
scannedCount?: number | null;
|
||||
truncated?: boolean | null;
|
||||
denialReason?: string | null;
|
||||
}) {
|
||||
return {
|
||||
outcome: input.outcome,
|
||||
workspaceSelector: input.workspaceSelector,
|
||||
mode: input.mode,
|
||||
...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}),
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.projectName ? { projectName: input.projectName } : {}),
|
||||
...(typeof input.resultCount === "number" ? { resultCount: input.resultCount } : {}),
|
||||
...(typeof input.scannedCount === "number" ? { scannedCount: input.scannedCount } : {}),
|
||||
...(typeof input.truncated === "boolean" ? { truncated: input.truncated } : {}),
|
||||
...(input.denialReason ? { denialReason: input.denialReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeListAuditQuery(query: unknown): {
|
||||
workspace: "auto" | "execution" | "project";
|
||||
mode: "all" | "recent" | "changed";
|
||||
} {
|
||||
if (!query || typeof query !== "object") return { workspace: "auto", mode: "all" };
|
||||
const record = query as Record<string, unknown>;
|
||||
const workspace = typeof record.workspace === "string" && ["auto", "execution", "project"].includes(record.workspace)
|
||||
? (record.workspace as "auto" | "execution" | "project")
|
||||
: "auto";
|
||||
const mode = typeof record.mode === "string" && ["all", "recent", "changed"].includes(record.mode)
|
||||
? (record.mode as "all" | "recent" | "changed")
|
||||
: "all";
|
||||
return { workspace, mode };
|
||||
}
|
||||
|
||||
function safeAuditTarget(query: unknown): { projectId: string | null; workspaceId: string | null } {
|
||||
if (!query || typeof query !== "object") return { projectId: null, workspaceId: null };
|
||||
const record = query as Record<string, unknown>;
|
||||
return {
|
||||
projectId: typeof record.projectId === "string" ? record.projectId : null,
|
||||
workspaceId: typeof record.workspaceId === "string" ? record.workspaceId : null,
|
||||
};
|
||||
}
|
||||
|
||||
function safeAuditDisplayPath(query: unknown): string {
|
||||
if (!query || typeof query !== "object") return "";
|
||||
const path = (query as Record<string, unknown>).path;
|
||||
if (typeof path !== "string") return "";
|
||||
if (/[\x00-\x1f\x7f]/.test(path)) return "";
|
||||
return path;
|
||||
}
|
||||
|
||||
function denialReasonFromError(error: unknown) {
|
||||
if (!(error instanceof HttpError)) return "unknown";
|
||||
const details = error.details;
|
||||
if (details && typeof details === "object" && "code" in details) {
|
||||
const code = (details as { code?: unknown }).code;
|
||||
if (typeof code === "string" && code.length > 0) return code;
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
export function fileResourceRoutes(db: Db, opts: {
|
||||
service?: WorkspaceFileResourceService;
|
||||
limiter?: FileResourceLimiter;
|
||||
listLimiter?: FileResourceLimiter;
|
||||
} = {}) {
|
||||
const router = Router();
|
||||
const svc = opts.service ?? workspaceFileResourceService(db);
|
||||
const limiter = opts.limiter ?? createFileResourceLimiter();
|
||||
const listLimiter = opts.listLimiter ?? createFileResourceListLimiter();
|
||||
|
||||
async function logDeniedAttempt(input: {
|
||||
companyId: string;
|
||||
actor: ReturnType<typeof getActorInfo>;
|
||||
issueId: string;
|
||||
displayPath: string;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
error: unknown;
|
||||
action?: "issue.file_resource_content_denied" | "issue.file_resource_resolve_denied";
|
||||
}) {
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
action: input.action ?? "issue.file_resource_content_denied",
|
||||
entityType: "issue",
|
||||
entityId: input.issueId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
details: activityDetails({
|
||||
outcome: "denied",
|
||||
displayPath: input.displayPath,
|
||||
projectId: input.projectId,
|
||||
workspaceId: input.workspaceId,
|
||||
denialReason: denialReasonFromError(input.error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function logListDeniedAttempt(input: {
|
||||
companyId: string;
|
||||
actor: ReturnType<typeof getActorInfo>;
|
||||
issueId: string;
|
||||
query: { workspace: "auto" | "execution" | "project"; mode: "all" | "recent" | "changed" };
|
||||
target?: { projectId: string | null; workspaceId: string | null };
|
||||
error: unknown;
|
||||
}) {
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: input.actor.actorType,
|
||||
actorId: input.actor.actorId,
|
||||
action: "issue.file_resource_list_denied",
|
||||
entityType: "issue",
|
||||
entityId: input.issueId,
|
||||
agentId: input.actor.agentId,
|
||||
runId: input.actor.runId,
|
||||
details: listActivityDetails({
|
||||
outcome: "denied",
|
||||
workspaceSelector: input.query.workspace,
|
||||
mode: input.query.mode,
|
||||
projectId: input.target?.projectId ?? null,
|
||||
workspaceId: input.target?.workspaceId ?? null,
|
||||
denialReason: denialReasonFromError(input.error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/issues/:issueId/file-resources/list", async (req, res) => {
|
||||
const auditQuery = safeListAuditQuery(req.query);
|
||||
const auditTarget = safeAuditTarget(req.query);
|
||||
try {
|
||||
assertBoard(req);
|
||||
} catch (error) {
|
||||
if (req.actor.type === "agent" && req.actor.companyId) {
|
||||
await logListDeniedAttempt({
|
||||
companyId: req.actor.companyId,
|
||||
actor: getActorInfo(req),
|
||||
issueId: req.params.issueId,
|
||||
query: auditQuery,
|
||||
target: auditTarget,
|
||||
error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
try {
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
} catch (error) {
|
||||
await logListDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
query: auditQuery,
|
||||
target: auditTarget,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
let query: ReturnType<typeof readListQuery>;
|
||||
try {
|
||||
query = readListQuery(req.query);
|
||||
} catch (error) {
|
||||
await logListDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
query: auditQuery,
|
||||
target: auditTarget,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
let release: (() => void) | null = null;
|
||||
try {
|
||||
release = listLimiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
|
||||
} catch (error) {
|
||||
await logListDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
query,
|
||||
target: { projectId: query.projectId, workspaceId: query.workspaceId },
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await svc.list(req.params.issueId, query, { issue });
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "issue.file_resource_list",
|
||||
entityType: "issue",
|
||||
entityId: req.params.issueId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: listActivityDetails({
|
||||
outcome: result.state === "available" ? "success" : "unavailable",
|
||||
workspaceSelector: result.query.workspace,
|
||||
mode: result.query.mode,
|
||||
workspaceKind: result.workspace?.workspaceKind ?? null,
|
||||
workspaceId: result.workspace?.workspaceId ?? null,
|
||||
projectId: result.workspace?.projectId ?? null,
|
||||
projectName: result.workspace?.projectName ?? null,
|
||||
resultCount: result.items.length,
|
||||
scannedCount: result.scannedCount,
|
||||
truncated: result.truncated,
|
||||
denialReason: result.unavailableReason ?? null,
|
||||
}),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
await logListDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
query,
|
||||
target: { projectId: query.projectId, workspaceId: query.workspaceId },
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/issues/:issueId/file-resources/resolve", async (req, res) => {
|
||||
const auditTarget = safeAuditTarget(req.query);
|
||||
try {
|
||||
assertBoard(req);
|
||||
} catch (error) {
|
||||
if (req.actor.type === "agent" && req.actor.companyId) {
|
||||
await logDeniedAttempt({
|
||||
companyId: req.actor.companyId,
|
||||
actor: getActorInfo(req),
|
||||
issueId: req.params.issueId,
|
||||
displayPath: safeAuditDisplayPath(req.query),
|
||||
projectId: auditTarget.projectId,
|
||||
workspaceId: auditTarget.workspaceId,
|
||||
error,
|
||||
action: "issue.file_resource_resolve_denied",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
try {
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: safeAuditDisplayPath(req.query),
|
||||
projectId: auditTarget.projectId,
|
||||
workspaceId: auditTarget.workspaceId,
|
||||
error,
|
||||
action: "issue.file_resource_resolve_denied",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let query: ReturnType<typeof readQuery>;
|
||||
try {
|
||||
query = readQuery(req.query);
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: safeAuditDisplayPath(req.query),
|
||||
projectId: auditTarget.projectId,
|
||||
workspaceId: auditTarget.workspaceId,
|
||||
error,
|
||||
action: "issue.file_resource_resolve_denied",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let release: (() => void) | null = null;
|
||||
try {
|
||||
release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: query.path,
|
||||
projectId: query.projectId,
|
||||
workspaceId: query.workspaceId,
|
||||
error,
|
||||
action: "issue.file_resource_resolve_denied",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = await svc.resolve(req.params.issueId, query, { issue });
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "issue.file_resource_resolve",
|
||||
entityType: "issue",
|
||||
entityId: req.params.issueId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: activityDetails({
|
||||
outcome: "success",
|
||||
workspaceKind: result.workspaceKind,
|
||||
workspaceId: result.workspaceId,
|
||||
projectId: result.projectId ?? null,
|
||||
projectName: result.projectName ?? null,
|
||||
displayPath: result.displayPath,
|
||||
byteSize: result.byteSize ?? null,
|
||||
contentType: result.contentType ?? null,
|
||||
denialReason: result.denialReason ?? null,
|
||||
}),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: query.path,
|
||||
projectId: query.projectId,
|
||||
workspaceId: query.workspaceId,
|
||||
error,
|
||||
action: "issue.file_resource_resolve_denied",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/issues/:issueId/file-resources/content", async (req, res) => {
|
||||
const auditTarget = safeAuditTarget(req.query);
|
||||
try {
|
||||
assertBoard(req);
|
||||
} catch (error) {
|
||||
if (req.actor.type === "agent" && req.actor.companyId) {
|
||||
await logDeniedAttempt({
|
||||
companyId: req.actor.companyId,
|
||||
actor: getActorInfo(req),
|
||||
issueId: req.params.issueId,
|
||||
displayPath: safeAuditDisplayPath(req.query),
|
||||
projectId: auditTarget.projectId,
|
||||
workspaceId: auditTarget.workspaceId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const issue = await svc.getIssue(req.params.issueId);
|
||||
const actor = getActorInfo(req);
|
||||
try {
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: safeAuditDisplayPath(req.query),
|
||||
projectId: auditTarget.projectId,
|
||||
workspaceId: auditTarget.workspaceId,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let query: ReturnType<typeof readQuery>;
|
||||
try {
|
||||
query = readQuery(req.query);
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: safeAuditDisplayPath(req.query),
|
||||
projectId: auditTarget.projectId,
|
||||
workspaceId: auditTarget.workspaceId,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let release: (() => void) | null = null;
|
||||
try {
|
||||
release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId));
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: query.path,
|
||||
projectId: query.projectId,
|
||||
workspaceId: query.workspaceId,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
let result: WorkspaceFileContent | null = null;
|
||||
try {
|
||||
result = await svc.readContent(req.params.issueId, query, { issue });
|
||||
} catch (error) {
|
||||
await logDeniedAttempt({
|
||||
companyId: issue.companyId,
|
||||
actor,
|
||||
issueId: req.params.issueId,
|
||||
displayPath: query.path,
|
||||
projectId: query.projectId,
|
||||
workspaceId: query.workspaceId,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!result) throw unprocessable("Workspace file cannot be previewed");
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "issue.file_resource_content_read",
|
||||
entityType: "issue",
|
||||
entityId: req.params.issueId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: activityDetails({
|
||||
outcome: "success",
|
||||
workspaceKind: result.resource.workspaceKind,
|
||||
workspaceId: result.resource.workspaceId,
|
||||
projectId: result.resource.projectId ?? null,
|
||||
projectName: result.resource.projectName ?? null,
|
||||
displayPath: result.resource.displayPath,
|
||||
byteSize: result.resource.byteSize ?? null,
|
||||
contentType: result.resource.contentType ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
res.json(result);
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export { agentRoutes } from "./agents.js";
|
|||
export { projectRoutes } from "./projects.js";
|
||||
export { issueRoutes } from "./issues.js";
|
||||
export { issueTreeControlRoutes } from "./issue-tree-control.js";
|
||||
export { fileResourceRoutes, createFileResourceLimiter } from "./file-resources.js";
|
||||
export { routineRoutes } from "./routines.js";
|
||||
export { goalRoutes } from "./goals.js";
|
||||
export { approvalRoutes } from "./approvals.js";
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ import {
|
|||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
remoteSecretImportPreviewSchema,
|
||||
remoteSecretImportSchema,
|
||||
workspaceFileListQuerySchema,
|
||||
workspaceFileResourceQuerySchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
|
@ -430,6 +432,10 @@ const responses = {
|
|||
description: "Internal server error",
|
||||
content: { "application/json": { schema: ErrorSchema } },
|
||||
},
|
||||
tooManyRequests: {
|
||||
description: "Too many requests",
|
||||
content: { "application/json": { schema: ErrorSchema } },
|
||||
},
|
||||
};
|
||||
|
||||
const jsonBody = (schema: z.ZodTypeAny) => ({
|
||||
|
|
@ -566,6 +572,9 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"GET /api/secrets/{id}/usage",
|
||||
"GET /api/secrets/{id}/access-events",
|
||||
"POST /api/health/dev-server/restart",
|
||||
"GET /api/issues/{issueId}/file-resources/content",
|
||||
"GET /api/issues/{issueId}/file-resources/list",
|
||||
"GET /api/issues/{issueId}/file-resources/resolve",
|
||||
"POST /api/issues/{id}/interactions/{interactionId}/accept",
|
||||
"POST /api/issues/{id}/interactions/{interactionId}/reject",
|
||||
"POST /api/issues/{id}/interactions/{interactionId}/respond",
|
||||
|
|
@ -1638,6 +1647,60 @@ registry.registerPath({
|
|||
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{issueId}/file-resources/list",
|
||||
tags: ["issues"],
|
||||
summary: "List workspace files for an issue",
|
||||
request: {
|
||||
params: z.object({ issueId: z.string() }),
|
||||
query: workspaceFileListQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
404: r.notFound,
|
||||
422: r.unprocessable,
|
||||
429: r.tooManyRequests,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{issueId}/file-resources/resolve",
|
||||
tags: ["issues"],
|
||||
summary: "Resolve an issue workspace file",
|
||||
request: {
|
||||
params: z.object({ issueId: z.string() }),
|
||||
query: workspaceFileResourceQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
404: r.notFound,
|
||||
422: r.unprocessable,
|
||||
429: r.tooManyRequests,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{issueId}/file-resources/content",
|
||||
tags: ["issues"],
|
||||
summary: "Read issue workspace file content",
|
||||
request: {
|
||||
params: z.object({ issueId: z.string() }),
|
||||
query: workspaceFileResourceQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
404: r.notFound,
|
||||
422: r.unprocessable,
|
||||
429: r.tooManyRequests,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/attachments",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export { teamsCatalogService } from "./teams-catalog.js";
|
|||
export { environmentService } from "./environments.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workspaceOperationService } from "./workspace-operations.js";
|
||||
export { workspaceFileResourceService } from "./workspace-file-resources.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
|
||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
||||
|
|
@ -58,6 +59,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
|
|
|
|||
|
|
@ -249,12 +249,17 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
|||
|
||||
export async function findAdoptableLocalService(input: {
|
||||
serviceKey: string;
|
||||
profileKind?: string | null;
|
||||
serviceName?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
envFingerprint?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
}) {
|
||||
const record = await readLocalServiceRegistryRecord(input.serviceKey);
|
||||
const record =
|
||||
await readLocalServiceRegistryRecord(input.serviceKey)
|
||||
?? await adoptLocalServiceFromPortOwner(input);
|
||||
if (!record) return null;
|
||||
|
||||
if (!isPidAlive(record.pid)) {
|
||||
|
|
@ -272,6 +277,59 @@ export async function findAdoptableLocalService(input: {
|
|||
return record;
|
||||
}
|
||||
|
||||
async function readProcessGroupId(pid: number) {
|
||||
if (process.platform === "win32") return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(pid)]);
|
||||
const parsed = Number.parseInt(stdout.trim(), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function adoptLocalServiceFromPortOwner(input: {
|
||||
serviceKey: string;
|
||||
profileKind?: string | null;
|
||||
serviceName?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
envFingerprint?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
}) {
|
||||
if (!input.port) return null;
|
||||
const ownerPid = await readLocalServicePortOwner(input.port);
|
||||
if (!ownerPid) return null;
|
||||
|
||||
const processGroupId = await readProcessGroupId(ownerPid);
|
||||
const pid = processGroupId && isPidAlive(processGroupId) ? processGroupId : ownerPid;
|
||||
const now = new Date().toISOString();
|
||||
const record: LocalServiceRegistryRecord = {
|
||||
version: 1,
|
||||
serviceKey: input.serviceKey,
|
||||
profileKind: input.profileKind ?? "workspace-runtime",
|
||||
serviceName: input.serviceName ?? "service",
|
||||
command: input.command ?? input.serviceName ?? "service",
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
envFingerprint: input.envFingerprint ?? "",
|
||||
port: input.port,
|
||||
url: input.url ?? null,
|
||||
pid,
|
||||
processGroupId: processGroupId ?? pid,
|
||||
provider: "local_process",
|
||||
runtimeServiceId: null,
|
||||
reuseKey: input.envFingerprint ?? null,
|
||||
startedAt: now,
|
||||
lastSeenAt: now,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
if (!(await isLikelyMatchingCommand(record))) return null;
|
||||
await writeLocalServiceRegistryRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function touchLocalServiceRegistryRecord(
|
||||
serviceKey: string,
|
||||
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
|
||||
|
|
@ -334,7 +392,7 @@ export async function terminateLocalService(
|
|||
export async function readLocalServicePortOwner(port: number) {
|
||||
if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]);
|
||||
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
||||
const firstPid = stdout
|
||||
.split("\n")
|
||||
.map((line) => Number.parseInt(line.trim(), 10))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1901,10 +1901,44 @@ async function waitForReadiness(input: {
|
|||
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
|
||||
}
|
||||
|
||||
async function isRuntimeServiceUrlHealthy(url: string | null) {
|
||||
if (!url) return true;
|
||||
function isPaperclipDevRuntimeService(input: { serviceName?: string | null; command?: string | null }) {
|
||||
const serviceName = (input.serviceName ?? "").trim().toLowerCase();
|
||||
const command = (input.command ?? "").trim().toLowerCase();
|
||||
return (
|
||||
serviceName === "paperclip-dev"
|
||||
|| serviceName === "paperclip-dev-once"
|
||||
|| (command.includes("dev:once") && command.includes("tailscale-auth"))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeServiceHealthUrl(
|
||||
url: string | null,
|
||||
input?: { serviceName?: string | null; command?: string | null },
|
||||
) {
|
||||
if (!url || !isPaperclipDevRuntimeService(input ?? {})) return url;
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname === "/" || parsed.pathname === "") {
|
||||
parsed.pathname = "/api/health";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function isRuntimeServiceUrlHealthy(
|
||||
url: string | null,
|
||||
input?: { serviceName?: string | null; command?: string | null },
|
||||
) {
|
||||
if (!url) return true;
|
||||
const healthUrl = resolveRuntimeServiceHealthUrl(url, input);
|
||||
if (!healthUrl) return true;
|
||||
try {
|
||||
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -2123,12 +2157,11 @@ async function startLocalRuntimeService(input: {
|
|||
companyId: input.agent.companyId,
|
||||
reuseKey: input.reuseKey,
|
||||
});
|
||||
const reusableStoppedPort =
|
||||
asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port
|
||||
? (await readLocalServicePortOwner(stoppedReuseCandidate.port))
|
||||
? null
|
||||
: stoppedReuseCandidate.port
|
||||
: null;
|
||||
let reusableStoppedPort: number | null = null;
|
||||
if (asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port) {
|
||||
const ownerPid = await readLocalServicePortOwner(stoppedReuseCandidate.port);
|
||||
reusableStoppedPort = ownerPid ? null : stoppedReuseCandidate.port;
|
||||
}
|
||||
const port =
|
||||
asString(portConfig.type, "") === "auto"
|
||||
? (reusableStoppedPort ?? await allocatePort())
|
||||
|
|
@ -2181,48 +2214,57 @@ async function startLocalRuntimeService(input: {
|
|||
});
|
||||
const adoptedRecord = await findAdoptableLocalService({
|
||||
serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
envFingerprint: serviceIdentityFingerprint,
|
||||
port: identityPort,
|
||||
port: port ?? identityPort,
|
||||
url,
|
||||
});
|
||||
if (adoptedRecord) {
|
||||
return {
|
||||
id: adoptedRecord.runtimeServiceId ?? randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
issueId: input.issue?.id ?? null,
|
||||
serviceName,
|
||||
status: "running",
|
||||
lifecycle,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
reuseKey: input.reuseKey,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
port: adoptedRecord.port ?? port,
|
||||
url: adoptedRecord.url ?? url,
|
||||
provider: "local_process",
|
||||
providerRef: String(adoptedRecord.pid),
|
||||
ownerAgentId: input.agent.id ?? null,
|
||||
startedByRunId,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: adoptedRecord.startedAt,
|
||||
stoppedAt: null,
|
||||
stopPolicy,
|
||||
healthStatus: "healthy",
|
||||
reused: true,
|
||||
db: input.db,
|
||||
child: null,
|
||||
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
|
||||
idleTimer: null,
|
||||
envFingerprint,
|
||||
serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||
};
|
||||
const adoptedUrl = adoptedRecord.url ?? url;
|
||||
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName, command }))) {
|
||||
await terminateLocalService(adoptedRecord);
|
||||
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
||||
} else {
|
||||
return {
|
||||
id: adoptedRecord.runtimeServiceId ?? randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
issueId: input.issue?.id ?? null,
|
||||
serviceName,
|
||||
status: "running",
|
||||
lifecycle,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
reuseKey: input.reuseKey,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
port: adoptedRecord.port ?? port,
|
||||
url: adoptedRecord.url ?? url,
|
||||
provider: "local_process",
|
||||
providerRef: String(adoptedRecord.pid),
|
||||
ownerAgentId: input.agent.id ?? null,
|
||||
startedByRunId,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: adoptedRecord.startedAt,
|
||||
stoppedAt: null,
|
||||
stopPolicy,
|
||||
healthStatus: "healthy",
|
||||
reused: true,
|
||||
db: input.db,
|
||||
child: null,
|
||||
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
|
||||
idleTimer: null,
|
||||
envFingerprint,
|
||||
serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (identityPort) {
|
||||
const ownerPid = await readLocalServicePortOwner(identityPort);
|
||||
|
|
@ -2840,13 +2882,38 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
|||
let adopted = 0;
|
||||
let stopped = 0;
|
||||
for (const row of rows) {
|
||||
const adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
|
||||
let adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
|
||||
runtimeServiceId: row.id,
|
||||
profileKind: "workspace-runtime",
|
||||
});
|
||||
if (!adoptedRecord && row.command && row.cwd) {
|
||||
adoptedRecord = await findAdoptableLocalService({
|
||||
serviceKey: createLocalServiceKey({
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName: row.serviceName,
|
||||
cwd: row.cwd,
|
||||
command: row.command,
|
||||
envFingerprint: row.reuseKey ?? "",
|
||||
port: null,
|
||||
scope: {
|
||||
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
},
|
||||
}),
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName: row.serviceName,
|
||||
command: row.command,
|
||||
cwd: row.cwd,
|
||||
envFingerprint: row.reuseKey ?? "",
|
||||
port: row.port ?? null,
|
||||
url: row.url ?? null,
|
||||
});
|
||||
}
|
||||
if (adoptedRecord) {
|
||||
const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
|
||||
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
|
||||
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName: row.serviceName, command: row.command }))) {
|
||||
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
||||
} else {
|
||||
const record: RuntimeServiceRecord = {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,9 @@ If `currentParticipant` does not match you, do not try to advance the stage —
|
|||
|
||||
### Generated Artifacts and Work Products
|
||||
|
||||
When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
|
||||
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition and create an artifact work product. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
|
||||
|
||||
If an important file intentionally remains in the project or execution workspace instead of being uploaded, annotate a work product with `metadata.resourceRef.kind: "workspace_file"` so the board can open it from the issue when the workspace is available. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path for deliverables.
|
||||
|
||||
For technical upload instructions, read `references/artifacts.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Generated Artifacts and Work Products
|
||||
|
||||
When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
|
||||
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
|
||||
|
||||
Use the helper bundled with this skill. From an installed `paperclip` skill directory, the helper lives at `scripts/paperclip-upload-artifact.sh`:
|
||||
|
||||
|
|
@ -12,6 +12,54 @@ scripts/paperclip-upload-artifact.sh path/to/output.webm \
|
|||
|
||||
The helper uses `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_TASK_ID`, and `PAPERCLIP_RUN_ID`. It uploads the file as an issue attachment, creates an attachment-backed artifact work product by default, and prints issue-safe markdown links for your final comment.
|
||||
|
||||
## Workspace-Only File References
|
||||
|
||||
Use a workspace-only reference only when the file should stay in the project or
|
||||
execution workspace, such as a source file, committed report, generated index,
|
||||
or other file whose value is tied to the checkout. This is not a substitute for
|
||||
uploading a deliverable file that a board user should be able to inspect outside
|
||||
the workspace.
|
||||
|
||||
Annotate the work product with `metadata.resourceRef`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "document",
|
||||
"provider": "workspace",
|
||||
"title": "Regression test plan",
|
||||
"status": "ready_for_review",
|
||||
"reviewState": "needs_board_review",
|
||||
"summary": "Markdown plan committed in the execution workspace.",
|
||||
"metadata": {
|
||||
"resourceRef": {
|
||||
"kind": "workspace_file",
|
||||
"issueId": "<issue-id>",
|
||||
"workspaceKind": "execution_workspace",
|
||||
"workspaceId": "<execution-workspace-id>",
|
||||
"relativePath": "doc/plans/regression-test-plan.md",
|
||||
"line": 1,
|
||||
"displayPath": "doc/plans/regression-test-plan.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`workspaceKind` is `execution_workspace` for the current issue checkout or
|
||||
`project_workspace` for a shared project workspace. `line` and `column` are
|
||||
optional positive integers. `relativePath` must be relative to the selected
|
||||
workspace root; do not use host-local absolute paths in `resourceRef`.
|
||||
|
||||
Create the work product with:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @workspace-file-work-product.json
|
||||
```
|
||||
|
||||
If the helper is unavailable, use the Paperclip API directly:
|
||||
|
||||
```bash
|
||||
|
|
@ -41,4 +89,10 @@ curl -sS -X POST \
|
|||
}'
|
||||
```
|
||||
|
||||
In your final issue comment, link the uploaded attachment or work product and describe what it contains. Do not leave artifact-producing work `in_progress` with only a local path or a `Remaining` note.
|
||||
In your final issue comment, link the uploaded attachment or work product and
|
||||
describe what it contains. If the output is workspace-only, name the work
|
||||
product and the relative path that was recorded in `metadata.resourceRef`.
|
||||
Browse/search is the fallback for recovering a workspace file when the issue
|
||||
chip or link cannot open it; it is not the preferred deliverable path. Do not
|
||||
leave artifact-producing work `in_progress` with only a local path or a
|
||||
`Remaining` note.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileListMode,
|
||||
WorkspaceFileListResponse,
|
||||
WorkspaceFileSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface FileResourceQuery {
|
||||
path: string;
|
||||
workspace?: WorkspaceFileSelector;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
}
|
||||
|
||||
export interface FileResourceListQuery {
|
||||
workspace?: WorkspaceFileSelector;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
path?: string | null;
|
||||
mode?: WorkspaceFileListMode;
|
||||
q?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildQuery(query: FileResourceQuery | FileResourceListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query.projectId && query.workspaceId) {
|
||||
params.set("projectId", query.projectId);
|
||||
params.set("workspaceId", query.workspaceId);
|
||||
}
|
||||
if ("path" in query && query.path) params.set("path", query.path);
|
||||
if (query.workspace && query.workspace !== "auto") {
|
||||
params.set("workspace", query.workspace);
|
||||
}
|
||||
if ("mode" in query && query.mode && query.mode !== "all") params.set("mode", query.mode);
|
||||
if ("q" in query && query.q) params.set("q", query.q);
|
||||
if ("limit" in query && query.limit) params.set("limit", String(query.limit));
|
||||
if ("offset" in query && query.offset) params.set("offset", String(query.offset));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export const fileResourcesApi = {
|
||||
list(issueId: string, query: FileResourceListQuery = {}): Promise<WorkspaceFileListResponse> {
|
||||
const search = buildQuery(query);
|
||||
const suffix = search ? `?${search}` : "";
|
||||
return api.get<WorkspaceFileListResponse>(
|
||||
`/issues/${encodeURIComponent(issueId)}/file-resources/list${suffix}`,
|
||||
);
|
||||
},
|
||||
|
||||
resolve(issueId: string, query: FileResourceQuery): Promise<ResolvedWorkspaceResource> {
|
||||
return api.get<ResolvedWorkspaceResource>(
|
||||
`/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`,
|
||||
);
|
||||
},
|
||||
|
||||
content(issueId: string, query: FileResourceQuery): Promise<WorkspaceFileContent> {
|
||||
return api.get<WorkspaceFileContent>(
|
||||
`/issues/${encodeURIComponent(issueId)}/file-resources/content?${buildQuery(query)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import type { MouseEvent, ReactNode } from "react";
|
||||
import { FileCode2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkspaceFileRef } from "@paperclipai/shared";
|
||||
import { useFileViewer } from "@/context/FileViewerContext";
|
||||
|
||||
export interface ArtifactFileChipProps {
|
||||
workspaceFileRef: WorkspaceFileRef;
|
||||
/** Override the rendered label. Defaults to the display path. */
|
||||
label?: ReactNode;
|
||||
className?: string;
|
||||
/** Optional override if the consumer wants to customize activation. */
|
||||
onOpen?: (ref: WorkspaceFileRef) => void;
|
||||
showIcon?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function artifactFileDisplay(ref: WorkspaceFileRef) {
|
||||
if (!ref.projectName) return ref.displayPath;
|
||||
const prefix = `${ref.projectName} / `;
|
||||
return ref.displayPath.startsWith(prefix) ? ref.displayPath : `${prefix}${ref.displayPath}`;
|
||||
}
|
||||
|
||||
export function ArtifactFileChip({
|
||||
workspaceFileRef,
|
||||
label,
|
||||
className,
|
||||
onOpen,
|
||||
showIcon = true,
|
||||
title,
|
||||
}: ArtifactFileChipProps) {
|
||||
const viewer = useFileViewer();
|
||||
const display = typeof label !== "undefined" ? label : artifactFileDisplay(workspaceFileRef);
|
||||
const canOpen = !!(onOpen || viewer);
|
||||
const lineSuffix = workspaceFileRef.line
|
||||
? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}`
|
||||
: "";
|
||||
const ariaLabel = canOpen
|
||||
? `Open ${workspaceFileRef.displayPath}${lineSuffix} in the file viewer`
|
||||
: `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`;
|
||||
const tooltip = title ?? (canOpen
|
||||
? `Open ${workspaceFileRef.displayPath}${lineSuffix} in the file viewer`
|
||||
: `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`);
|
||||
|
||||
const classNames = cn(
|
||||
"paperclip-artifact-file-chip inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
canOpen ? "cursor-pointer" : null,
|
||||
className,
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{showIcon ? <FileCode2 aria-hidden="true" className="h-3 w-3 shrink-0 opacity-70" /> : null}
|
||||
<span className="max-w-full whitespace-normal break-all text-left">{display}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) return;
|
||||
if (onOpen) onOpen(workspaceFileRef);
|
||||
else {
|
||||
const workspace = workspaceFileRef.workspaceKind === "execution_workspace" ? "execution" : "project";
|
||||
viewer?.open({
|
||||
path: workspaceFileRef.relativePath,
|
||||
line: workspaceFileRef.line ?? null,
|
||||
column: workspaceFileRef.column ?? null,
|
||||
workspace,
|
||||
projectId: workspaceFileRef.projectId ?? null,
|
||||
workspaceId: workspaceFileRef.projectId ? workspaceFileRef.workspaceId : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!canOpen) {
|
||||
return (
|
||||
<span
|
||||
data-artifact-file-chip="true"
|
||||
data-workspace-file-path={workspaceFileRef.relativePath}
|
||||
aria-label={ariaLabel}
|
||||
title={tooltip}
|
||||
className={classNames}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-artifact-file-chip="true"
|
||||
data-workspace-file-path={workspaceFileRef.relativePath}
|
||||
aria-label={ariaLabel}
|
||||
title={tooltip}
|
||||
className={classNames}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,20 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { KeyboardEventHandler, ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const companyState = vi.hoisted(() => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -33,6 +42,10 @@ const mockProjectsApi = vi.hoisted(() => ({
|
|||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => companyState,
|
||||
}));
|
||||
|
|
@ -49,9 +62,13 @@ vi.mock("../context/SidebarContext", () => ({
|
|||
const navigateState = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
const locationState = vi.hoisted(() => ({
|
||||
location: { pathname: "/", search: "", hash: "" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigateState.navigate,
|
||||
useLocation: () => locationState.location,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
|
|
@ -66,6 +83,10 @@ vi.mock("../api/projects", () => ({
|
|||
projectsApi: mockProjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
|
@ -133,7 +154,11 @@ async function waitForAssertion(assertion: () => void, attempts = 20) {
|
|||
throw lastError;
|
||||
}
|
||||
|
||||
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
||||
function renderWithQueryClient(
|
||||
node: ReactNode,
|
||||
container: HTMLDivElement,
|
||||
seedQueryClient?: (queryClient: QueryClient) => void,
|
||||
) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -142,6 +167,7 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
|||
},
|
||||
},
|
||||
});
|
||||
seedQueryClient?.(queryClient);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
|
|
@ -166,10 +192,17 @@ describe("CommandPalette", () => {
|
|||
mockIssuesApi.list.mockReset();
|
||||
mockAgentsApi.list.mockReset();
|
||||
mockProjectsApi.list.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
navigateState.navigate.mockReset();
|
||||
locationState.location.pathname = "/";
|
||||
locationState.location.search = "";
|
||||
locationState.location.hash = "";
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableExperimentalFileViewer: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -203,6 +236,52 @@ describe("CommandPalette", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hides the issue file viewer command by default", async () => {
|
||||
locationState.location.pathname = "/issues/PAP-1";
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Create new task");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Open file in this issue");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the issue file viewer command when the experimental flag is enabled", async () => {
|
||||
locationState.location.pathname = "/issues/PAP-1";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableExperimentalFileViewer: true,
|
||||
});
|
||||
const { root } = renderWithQueryClient(
|
||||
<CommandPalette />,
|
||||
container,
|
||||
(queryClient) => {
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, {
|
||||
enableExperimentalFileViewer: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Open file in this issue");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a Search-all command when the query is non-empty and routes Enter to /search when no issues match", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
|
|
@ -7,6 +7,7 @@ import { useSidebar } from "../context/SidebarContext";
|
|||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
CommandDialog,
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
DollarSign,
|
||||
History,
|
||||
SquarePen,
|
||||
FileCode2,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
|
@ -40,14 +42,28 @@ export function buildFullSearchPath(query: string) {
|
|||
return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
|
||||
const ISSUE_DETAIL_PATH_RE = /\/issues\/[^/?#]+(?:$|\?|#|\/)/;
|
||||
|
||||
function isOnIssueDetail(pathname: string): boolean {
|
||||
return ISSUE_DETAIL_PATH_RE.test(pathname);
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue, openNewAgent } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const searchQuery = query.trim();
|
||||
const onIssueDetail = isOnIssueDetail(location.pathname);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const fileViewerEnabled = experimentalSettings?.enableExperimentalFileViewer === true;
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
|
|
@ -177,6 +193,18 @@ export function CommandPalette() {
|
|||
Create new task
|
||||
<span className="ml-auto text-xs text-muted-foreground">C</span>
|
||||
</CommandItem>
|
||||
{onIssueDetail && fileViewerEnabled && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
window.dispatchEvent(new CustomEvent("paperclip:open-file-viewer"));
|
||||
}}
|
||||
>
|
||||
<FileCode2 className="mr-2 h-4 w-4" />
|
||||
Open file in this issue...
|
||||
<span className="ml-auto text-xs text-muted-foreground">g f</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedWorkspaceResource, WorkspaceFileContent } from "@paperclipai/shared";
|
||||
import { FileViewerSheet } from "./FileViewerSheet";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const viewerMock = {
|
||||
state: null,
|
||||
browse: false,
|
||||
query: null,
|
||||
folderPath: null,
|
||||
browseProjectId: null,
|
||||
browseWorkspaceId: null,
|
||||
open: vi.fn(),
|
||||
updateBrowseState: vi.fn(),
|
||||
close: vi.fn(),
|
||||
backToFiles: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: unknown) => useQueryMock(options),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/context/FileViewerContext", () => ({
|
||||
useRequiredFileViewer: () => viewerMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/WorkspaceFileBrowser", () => ({
|
||||
WorkspaceFileBrowser: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/WorkspaceFileMarkdownBody", () => ({
|
||||
WorkspaceFileMarkdownBody: ({ children }: { children: string }) => (
|
||||
<div data-testid="mock-rendered-markdown">Rendered Markdown: {children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function ok<T>(data: T) {
|
||||
return { data, isFetching: false, isError: false, error: null, refetch: vi.fn(async () => ({ data })) };
|
||||
}
|
||||
|
||||
const resolvedResource: ResolvedWorkspaceResource = {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: "tweet.md",
|
||||
displayPath: "videos/90-days-paperclip/tweet.md",
|
||||
workspaceLabel: "Isolated workspace",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
byteSize: 42,
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
};
|
||||
|
||||
const content: WorkspaceFileContent = {
|
||||
resource: resolvedResource,
|
||||
content: {
|
||||
encoding: "utf8",
|
||||
data: "hello from the file",
|
||||
},
|
||||
};
|
||||
|
||||
describe("FileViewerSheet copy actions", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
useQueryMock.mockImplementation((options: { queryKey?: readonly unknown[] }) => {
|
||||
const key = JSON.stringify(options.queryKey ?? []);
|
||||
if (key.includes('"content"')) return ok(content);
|
||||
return ok(resolvedResource);
|
||||
});
|
||||
writeText = vi.fn(async () => undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
window.history.pushState({}, "", "/PAP/issues/PAP-10629?file=videos%2F90-days-paperclip%2Ftweet.md");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderSheet() {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<FileViewerSheet
|
||||
issueId="issue-1"
|
||||
state={{
|
||||
path: "videos/90-days-paperclip/tweet.md",
|
||||
workspace: "auto",
|
||||
line: null,
|
||||
column: null,
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
}}
|
||||
open
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function click(label: string) {
|
||||
const button = document.body.querySelector(`button[aria-label="${label}"]`) as HTMLButtonElement | null;
|
||||
expect(button).not.toBeNull();
|
||||
flushSync(() => {
|
||||
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
it("copies file contents and shows confirmation", async () => {
|
||||
renderSheet();
|
||||
|
||||
await click("Copy file contents");
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("hello from the file");
|
||||
expect(document.body.textContent).toContain("Copied contents");
|
||||
});
|
||||
|
||||
it("copies the current file view link and shows confirmation", async () => {
|
||||
renderSheet();
|
||||
|
||||
await click("Copy link to this file view");
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(window.location.href);
|
||||
expect(document.body.textContent).toContain("Copied link");
|
||||
});
|
||||
|
||||
it("renders a keyboard-addressable file tree resize separator", () => {
|
||||
renderSheet();
|
||||
const separator = document.body.querySelector('[role="separator"][aria-label="Resize file tree"]');
|
||||
expect(separator).not.toBeNull();
|
||||
expect(separator?.getAttribute("aria-valuenow")).toBe("288");
|
||||
});
|
||||
|
||||
it("keeps split panes unframed so file selection does not shift the browser", () => {
|
||||
renderSheet();
|
||||
const separator = document.body.querySelector('[role="separator"][aria-label="Resize file tree"]');
|
||||
const browserPane = separator?.previousElementSibling;
|
||||
const previewPane = separator?.nextElementSibling;
|
||||
|
||||
expect(browserPane?.className).not.toContain("border");
|
||||
expect(browserPane?.className).not.toContain("rounded");
|
||||
expect(previewPane?.className).not.toContain("border");
|
||||
expect(previewPane?.className).not.toContain("rounded");
|
||||
});
|
||||
|
||||
it("defaults Markdown files to rendered mode and switches back to raw source", async () => {
|
||||
useQueryMock.mockImplementation((options: { queryKey?: readonly unknown[] }) => {
|
||||
const key = JSON.stringify(options.queryKey ?? []);
|
||||
if (key.includes('"content"')) {
|
||||
return ok({
|
||||
...content,
|
||||
resource: {
|
||||
...resolvedResource,
|
||||
title: "launch.md",
|
||||
displayPath: "docs/launch.md",
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
},
|
||||
content: { encoding: "utf8", data: "# Launch note\n\nRendered body" },
|
||||
});
|
||||
}
|
||||
return ok({
|
||||
...resolvedResource,
|
||||
title: "launch.md",
|
||||
displayPath: "docs/launch.md",
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
});
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
expect(document.body.querySelector('[aria-label="launch.md rendered Markdown"]')).not.toBeNull();
|
||||
expect(document.body.querySelector('[aria-label="launch.md source"]')).toBeNull();
|
||||
expect(document.body.querySelector('button[aria-label="Show rendered Markdown"]')).not.toBeNull();
|
||||
expect(document.body.querySelector('button[aria-label="Show raw Markdown"]')).not.toBeNull();
|
||||
expect(document.body.textContent).toContain("Rendered Markdown: # Launch note");
|
||||
|
||||
await click("Show raw Markdown");
|
||||
|
||||
expect(document.body.querySelector('[aria-label="launch.md source"]')).not.toBeNull();
|
||||
expect(document.body.querySelector('[aria-label="launch.md rendered Markdown"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { WorkspaceFileContent } from "@paperclipai/shared";
|
||||
import type { FileViewerUrlState } from "@/context/FileViewerContext";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { describeDenial, FileContentViewer, FileViewerMetadataRow } from "./FileViewerSheet";
|
||||
|
||||
describe("describeDenial", () => {
|
||||
it("returns the curated body for too_large regardless of fallback", () => {
|
||||
expect(describeDenial("too_large", "").body).toBe(
|
||||
"This file exceeds the supported preview size.",
|
||||
);
|
||||
expect(describeDenial("too_large", "ignored fallback").body).toBe(
|
||||
"This file exceeds the supported preview size.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leak the raw denial code as body when fallback is empty", () => {
|
||||
for (const code of [
|
||||
"denied_by_policy_sensitive",
|
||||
"outside_workspace_root",
|
||||
"workspace_archived",
|
||||
"binary_unsupported",
|
||||
"remote_preview_unsupported",
|
||||
]) {
|
||||
const { body } = describeDenial(code, "");
|
||||
expect(body).not.toBe(code);
|
||||
expect(body).not.toMatch(/^[a-z_]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("describes unsupported previews in terms of text, image, and video", () => {
|
||||
expect(describeDenial("unsupported_content", "").body).toBe(
|
||||
"This file does not have a text, image, or video preview available.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic message for unknown codes with empty fallback", () => {
|
||||
const { body, title } = describeDenial("", "");
|
||||
expect(title).toBe("Can't preview this file");
|
||||
expect(body).toBe("The viewer was unable to load this file.");
|
||||
});
|
||||
|
||||
it("prefers a human-readable server message for unknown codes", () => {
|
||||
const { body } = describeDenial("unknown_code", "Server refused the request.");
|
||||
expect(body).toBe("Server refused the request.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileContentViewer", () => {
|
||||
function content(overrides: Partial<WorkspaceFileContent> = {}): WorkspaceFileContent {
|
||||
return {
|
||||
resource: {
|
||||
kind: "file",
|
||||
provider: "local_fs",
|
||||
title: "notes.txt",
|
||||
displayPath: "notes.txt",
|
||||
workspaceLabel: "Workspace",
|
||||
workspaceKind: "project_workspace",
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 18,
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
},
|
||||
content: {
|
||||
encoding: "utf8",
|
||||
data: "one very long line",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("wraps text content instead of forcing horizontal preformatted scrolling", () => {
|
||||
const markup = renderToStaticMarkup(<FileContentViewer content={content()} highlightedLine={null} />);
|
||||
expect(markup).toContain("whitespace-pre-wrap");
|
||||
expect(markup).toContain("break-words");
|
||||
});
|
||||
|
||||
it("reserves code gutter space for at least four digit line numbers", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileContentViewer
|
||||
content={content({
|
||||
content: {
|
||||
encoding: "utf8",
|
||||
data: "one\ntwo\nthree",
|
||||
},
|
||||
})}
|
||||
highlightedLine={null}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain("calc(4ch + 2rem)");
|
||||
});
|
||||
|
||||
it("renders video previews with native controls", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileContentViewer
|
||||
content={content({
|
||||
resource: {
|
||||
...content().resource,
|
||||
title: "demo.mp4",
|
||||
displayPath: "demo.mp4",
|
||||
contentType: "video/mp4",
|
||||
previewKind: "video",
|
||||
},
|
||||
content: {
|
||||
encoding: "base64",
|
||||
data: "AAAA",
|
||||
},
|
||||
})}
|
||||
highlightedLine={null}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain("<video");
|
||||
expect(markup).toContain("controls");
|
||||
expect(markup).toContain("data:video/mp4;base64,AAAA");
|
||||
});
|
||||
|
||||
it("shows an icon toggle for Markdown files and defaults to rendered Markdown", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<FileContentViewer
|
||||
content={content({
|
||||
resource: {
|
||||
...content().resource,
|
||||
title: "README.md",
|
||||
displayPath: "docs/README.md",
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
},
|
||||
content: {
|
||||
encoding: "utf8",
|
||||
data: "# Heading\n\nBody",
|
||||
},
|
||||
})}
|
||||
highlightedLine={null}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Markdown preview mode");
|
||||
expect(markup).toContain("Show rendered Markdown");
|
||||
expect(markup).toContain("Show raw Markdown");
|
||||
expect(markup).toContain("README.md rendered Markdown");
|
||||
expect(markup).not.toContain("README.md source");
|
||||
});
|
||||
|
||||
it("does not show the Markdown toggle for non-Markdown text files", () => {
|
||||
const markup = renderToStaticMarkup(<FileContentViewer content={content()} highlightedLine={null} />);
|
||||
|
||||
expect(markup).not.toContain("Markdown preview mode");
|
||||
expect(markup).toContain("notes.txt source");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileViewerMetadataRow", () => {
|
||||
const state: FileViewerUrlState = {
|
||||
path: "videos/90-days-paperclip/tweet.md",
|
||||
workspace: "auto",
|
||||
line: null,
|
||||
column: null,
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
|
||||
it("reserves metadata row height while file details load", () => {
|
||||
const markup = renderToStaticMarkup(<FileViewerMetadataRow state={state} />);
|
||||
expect(markup).toContain("min-h-[18px]");
|
||||
expect(markup).toContain("Loading file details");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -89,6 +89,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { Identity } from "./Identity";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
|
|
@ -669,14 +670,14 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
|
|||
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
|
||||
}
|
||||
return (
|
||||
<MarkdownBody
|
||||
<WorkspaceFileMarkdownBody
|
||||
className="text-sm leading-6"
|
||||
style={recessed ? { opacity: 0.55 } : undefined}
|
||||
softBreaks
|
||||
onImageClick={onImageClick}
|
||||
>
|
||||
{text}
|
||||
</MarkdownBody>
|
||||
</WorkspaceFileMarkdownBody>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||
import { Check, Copy, FileSearch, FolderOpen, FolderSearch, GitBranch, Pencil, X } from "lucide-react";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility helpers (mirrored from IssueProperties for self-containment) */
|
||||
|
|
@ -192,6 +192,10 @@ interface IssueWorkspaceCardProps {
|
|||
initialEditing?: boolean;
|
||||
livePreview?: boolean;
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void;
|
||||
/** Opens the workspace file browser sheet. When omitted, the browse row is hidden. */
|
||||
onBrowseFiles?: () => void;
|
||||
/** Opens the same browser sheet focused for path entry. */
|
||||
onOpenFileByPath?: () => void;
|
||||
}
|
||||
|
||||
export function IssueWorkspaceCard({
|
||||
|
|
@ -201,6 +205,8 @@ export function IssueWorkspaceCard({
|
|||
initialEditing = false,
|
||||
livePreview = false,
|
||||
onDraftChange,
|
||||
onBrowseFiles,
|
||||
onOpenFileByPath,
|
||||
}: IssueWorkspaceCardProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
|
|
@ -572,6 +578,28 @@ export function IssueWorkspaceCard({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workspace file discovery — calm row under the workspace identity. */}
|
||||
{!showEditingControls && onBrowseFiles && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border/50 pt-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowseFiles}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FolderSearch className="h-3.5 w-3.5 shrink-0" />
|
||||
Browse files…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFileByPath ?? onBrowseFiles}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileSearch className="h-3.5 w-3.5 shrink-0" />
|
||||
Open file by path…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ vi.mock("@/lib/router", () => ({
|
|||
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({
|
||||
pathname: "/PAP/issues/PAP-10306",
|
||||
search: "",
|
||||
hash: "",
|
||||
state: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
|
|
@ -267,6 +273,21 @@ describe("MarkdownBody", () => {
|
|||
expect(html).toContain("paperclip-markdown-issue-ref");
|
||||
});
|
||||
|
||||
it("renders linked inline-code workspace paths as file viewer links before issue links", () => {
|
||||
const html = renderMarkdown(
|
||||
"- **MP4**: [`videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4`](/PAP/issues/PAP-10306 \"Publish handoff\")",
|
||||
[{ identifier: "PAP-10306", status: "in_review", title: "Publish handoff" }],
|
||||
{ linkWorkspaceFileRefs: true },
|
||||
);
|
||||
|
||||
expect(html).toContain('data-workspace-file-link="true"');
|
||||
expect(html).toContain('data-workspace-file-path="videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4"');
|
||||
expect(html).toContain("videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4");
|
||||
expect(html).not.toContain("max-w-[38ch]");
|
||||
expect(html).not.toContain("paperclip-markdown-issue-ref");
|
||||
expect(html).not.toContain('href="/issues/PAP-10306"');
|
||||
});
|
||||
|
||||
it("keeps trailing punctuation outside auto-linked issue references", () => {
|
||||
const html = renderMarkdown("See PAP-1271: /issues/PAP-1272] and issue://PAP-1273.", [
|
||||
{ identifier: "PAP-1271", status: "done" },
|
||||
|
|
@ -532,4 +553,5 @@ describe("MarkdownBody", () => {
|
|||
|
||||
expect(html).toContain('href="/issues/ACME-1"');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chi
|
|||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
||||
import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PREFIX } from "../lib/remark-workspace-file-refs";
|
||||
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { WorkspaceFileLink } from "./WorkspaceFileLink";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
|
|
@ -30,6 +32,8 @@ interface MarkdownBodyProps {
|
|||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
onImageClick?: (src: string) => void;
|
||||
/** Link inline-code workspace file paths to the issue file viewer. */
|
||||
linkWorkspaceFileRefs?: boolean;
|
||||
}
|
||||
|
||||
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
||||
|
|
@ -160,6 +164,7 @@ function extractMermaidSource(children: ReactNode): string | null {
|
|||
}
|
||||
|
||||
function safeMarkdownUrlTransform(url: string): string {
|
||||
if (url.startsWith(WORKSPACE_FILE_HREF_PREFIX)) return url;
|
||||
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
|
|
@ -569,6 +574,7 @@ export function MarkdownBody({
|
|||
resolveWikiLinkHref,
|
||||
resolveImageSrc,
|
||||
onImageClick,
|
||||
linkWorkspaceFileRefs = false,
|
||||
}: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
// Read company prefixes non-throwingly: MarkdownBody renders in surfaces that
|
||||
|
|
@ -582,6 +588,9 @@ export function MarkdownBody({
|
|||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkWorkspaceFileRefs) {
|
||||
remarkPlugins.push(remarkWorkspaceFileRefs);
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
remarkPlugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
|
||||
}
|
||||
|
|
@ -634,6 +643,17 @@ export function MarkdownBody({
|
|||
</code>
|
||||
),
|
||||
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
|
||||
const workspaceFileRef = parseWorkspaceFileHref(href);
|
||||
if (workspaceFileRef) {
|
||||
return (
|
||||
<WorkspaceFileLink
|
||||
workspaceFileRef={workspaceFileRef}
|
||||
label={linkChildren}
|
||||
className={typeof anchorProps.className === "string" ? anchorProps.className : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dataProps = anchorProps as Record<string, unknown>;
|
||||
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
|
||||
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,826 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { Project, ProjectWorkspace, WorkspaceFileListDirectoryItem, WorkspaceFileListFileItem, WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceFileBrowser, describeUnavailable } from "./WorkspaceFileBrowser";
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function waitForExpectation(assertion: () => void) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const LIST_LIMIT = 100;
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: unknown) => useQueryMock(options),
|
||||
useQueries: ({ queries }: { queries: unknown[] }) => queries.map((options) => useQueryMock(options)),
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createItem(overrides: Partial<WorkspaceFileListFileItem> = {}): WorkspaceFileListFileItem {
|
||||
return {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: "IssueDetail.tsx",
|
||||
relativePath: "ui/src/pages/IssueDetail.tsx",
|
||||
displayPath: "ui/src/pages/IssueDetail.tsx",
|
||||
workspaceLabel: "Isolated workspace",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 2048,
|
||||
modifiedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDirectoryItem(overrides: Partial<WorkspaceFileListDirectoryItem> = {}): WorkspaceFileListDirectoryItem {
|
||||
return {
|
||||
kind: "directory",
|
||||
provider: "git_worktree",
|
||||
title: "src",
|
||||
relativePath: "ui/src",
|
||||
displayPath: "ui/src/",
|
||||
workspaceLabel: "Isolated workspace",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: null,
|
||||
byteSize: null,
|
||||
modifiedAt: null,
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function availableResponse(items: WorkspaceFileListItem[], truncated = false): WorkspaceFileListResponse {
|
||||
return {
|
||||
kind: "workspace_file_list",
|
||||
state: "available",
|
||||
workspace: {
|
||||
provider: "git_worktree",
|
||||
workspaceLabel: "Isolated workspace",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
},
|
||||
query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 },
|
||||
items,
|
||||
scannedCount: items.length,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function availableAllResponse(
|
||||
items: WorkspaceFileListItem[],
|
||||
path: string | null,
|
||||
truncated = false,
|
||||
offset = 0,
|
||||
): WorkspaceFileListResponse {
|
||||
const response = availableResponse(items, truncated);
|
||||
response.query = { ...response.query, mode: "all", path, offset };
|
||||
return response;
|
||||
}
|
||||
|
||||
function createWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
|
||||
return {
|
||||
id: "workspace-content",
|
||||
companyId: "company-1",
|
||||
projectId: "project-content",
|
||||
name: "Paperclip Content",
|
||||
sourceType: "local_path",
|
||||
cwd: "/srv/paperclip/home/paperclipai/paperclip-content",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date("2026-06-08T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-08T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProject(overrides: Partial<Project> = {}): Project {
|
||||
const workspace = createWorkspace();
|
||||
return {
|
||||
id: "project-content",
|
||||
companyId: "company-1",
|
||||
urlKey: "paperclip-content",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Paperclip Content",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: workspace.id,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: workspace.cwd,
|
||||
managedFolder: "",
|
||||
effectiveLocalFolder: workspace.cwd ?? "",
|
||||
origin: "local_folder",
|
||||
},
|
||||
workspaces: [workspace],
|
||||
primaryWorkspace: workspace,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-06-08T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-08T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableResponse(reason: string): WorkspaceFileListResponse {
|
||||
return {
|
||||
kind: "workspace_file_list",
|
||||
state: "unavailable",
|
||||
unavailableReason: reason,
|
||||
workspace: null,
|
||||
query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 },
|
||||
items: [],
|
||||
scannedCount: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function ok<T>(data: T) {
|
||||
return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() };
|
||||
}
|
||||
|
||||
describe("WorkspaceFileBrowser", () => {
|
||||
let container: HTMLDivElement;
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
useQueryMock.mockReset();
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderBrowser(onOpen = vi.fn(), props: Partial<ComponentProps<typeof WorkspaceFileBrowser>> = {}) {
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(<WorkspaceFileBrowser issueId="issue-1" onOpen={onOpen} {...props} />);
|
||||
});
|
||||
return { root, onOpen };
|
||||
}
|
||||
|
||||
it("renders the Recently changed files as a tree and opens a row with its relative path", () => {
|
||||
useQueryMock.mockReturnValue(
|
||||
ok(availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })])),
|
||||
);
|
||||
|
||||
const { onOpen } = renderBrowser();
|
||||
|
||||
expect(container.querySelector('[role="tree"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Isolated workspace");
|
||||
expect(container.textContent).not.toContain("Recently changed");
|
||||
expect(container.textContent).not.toContain("From Isolated workspace");
|
||||
|
||||
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
|
||||
);
|
||||
expect(option).not.toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
path: "ui/src/pages/IssueDetail.tsx",
|
||||
workspace: "auto",
|
||||
browseFolderPath: null,
|
||||
browseQuery: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("groups nested paths into collapsible folder rows instead of repeating full paths", () => {
|
||||
useQueryMock.mockReturnValue(
|
||||
ok(availableResponse([
|
||||
createItem({
|
||||
title: "tweet.md",
|
||||
relativePath: "videos/90-days-paperclip/tweet.md",
|
||||
displayPath: "videos/90-days-paperclip/tweet.md",
|
||||
}),
|
||||
createItem({
|
||||
title: "90-days-paperclip-1x1.mp4",
|
||||
relativePath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4",
|
||||
displayPath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4",
|
||||
previewKind: "video",
|
||||
}),
|
||||
])),
|
||||
);
|
||||
|
||||
renderBrowser();
|
||||
|
||||
expect(container.querySelector('[role="tree"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("videos");
|
||||
expect(container.textContent).toContain("90-days-paperclip");
|
||||
expect(container.textContent).toContain("tweet.md");
|
||||
expect(container.textContent).toContain("90-days-paperclip-1x1.mp4");
|
||||
expect(container.textContent).not.toContain("videos/90-days-paperclip/tweet.md");
|
||||
|
||||
const videosFolder = Array.from(container.querySelectorAll('button[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === "videos",
|
||||
);
|
||||
expect(videosFolder).not.toBeUndefined();
|
||||
act(() => {
|
||||
videosFolder!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(container.textContent).not.toContain("tweet.md");
|
||||
});
|
||||
|
||||
it("can render without autofocus when embedded beside a preview", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
renderBrowser(vi.fn(), { autoFocusSearch: false });
|
||||
expect(container.querySelector("input")?.hasAttribute("autofocus")).toBe(false);
|
||||
expect(container.querySelector("input")?.className).toContain("max-w-full");
|
||||
});
|
||||
|
||||
it("hides source controls, folder headings, workspace labels, and timestamps", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([
|
||||
createItem({
|
||||
relativePath: "videos/90-days-paperclip/tweet.md",
|
||||
displayPath: "videos/90-days-paperclip/tweet.md",
|
||||
}),
|
||||
])));
|
||||
|
||||
renderBrowser(vi.fn(), { compact: true, autoFocusSearch: false });
|
||||
|
||||
expect(container.textContent).not.toContain("Source");
|
||||
expect(container.textContent).not.toContain("Workspace");
|
||||
expect(container.textContent).not.toContain("Recently changed");
|
||||
expect(container.textContent).not.toContain("Files in folder");
|
||||
expect(container.textContent).not.toContain("From Isolated workspace");
|
||||
expect(container.querySelector(".tabular-nums")).toBeNull();
|
||||
expect(container.textContent).toContain("videos");
|
||||
expect(container.textContent).toContain("tweet.md");
|
||||
});
|
||||
|
||||
it("marks the selected file in the tree", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
renderBrowser(vi.fn(), { selectedPath: "ui/src/pages/IssueDetail.tsx" });
|
||||
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
|
||||
);
|
||||
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("lists the selected file's parent folder so deep-linked files appear selected", () => {
|
||||
const commandsItem = createItem({
|
||||
title: "commands.md",
|
||||
relativePath: "docs/reference/cli/commands.md",
|
||||
displayPath: "docs/reference/cli/commands.md",
|
||||
byteSize: 8192,
|
||||
});
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([commandsItem])));
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
selectedPath: "docs/reference/cli/commands.md",
|
||||
});
|
||||
|
||||
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
||||
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
||||
mode: "all",
|
||||
path: "docs/reference/cli",
|
||||
});
|
||||
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === "docs/reference/cli/commands.md",
|
||||
);
|
||||
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(container.textContent).toContain("commands.md");
|
||||
});
|
||||
|
||||
it("treats an explicit null initial folder as the workspace root", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
||||
title: "README.md",
|
||||
relativePath: "README.md",
|
||||
displayPath: "README.md",
|
||||
})])));
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialFolderPath: null,
|
||||
selectedPath: "docs/reference/cli/commands.md",
|
||||
});
|
||||
|
||||
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
||||
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
||||
mode: "all",
|
||||
path: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an initial search instead of narrowing to the selected file's parent folder", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
||||
title: "FileViewerSheet.tsx",
|
||||
relativePath: "ui/src/components/FileViewerSheet.tsx",
|
||||
displayPath: "ui/src/components/FileViewerSheet.tsx",
|
||||
})])));
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialQuery: "FileViewerSheet",
|
||||
selectedPath: "ui/src/components/FileViewerSheet.tsx",
|
||||
});
|
||||
|
||||
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
||||
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
||||
mode: "all",
|
||||
q: "FileViewerSheet",
|
||||
path: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark an unrelated highlighted row as selected", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([
|
||||
createItem({
|
||||
title: "vite.config.ts",
|
||||
relativePath: "ui/vite.config.ts",
|
||||
displayPath: "ui/vite.config.ts",
|
||||
}),
|
||||
])));
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
selectedPath: "docs/reference/cli/commands.md",
|
||||
});
|
||||
|
||||
const unrelated = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === "ui/vite.config.ts",
|
||||
);
|
||||
expect(unrelated?.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not render a Recent/All toggle", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
renderBrowser();
|
||||
expect(container.textContent).not.toContain("All files");
|
||||
expect(container.textContent).not.toContain("Recent changes / All");
|
||||
});
|
||||
|
||||
it("discloses truncation in the footer", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()], true)));
|
||||
renderBrowser();
|
||||
expect(container.textContent).toContain("refine the search to narrow");
|
||||
});
|
||||
|
||||
it("renders newly loaded current-folder rows after Load more", () => {
|
||||
const folderPath = "ui/src/components";
|
||||
const pageZero = availableAllResponse([
|
||||
createItem({
|
||||
title: "IssueLinkQuicklook.tsx",
|
||||
relativePath: `${folderPath}/IssueLinkQuicklook.tsx`,
|
||||
displayPath: `${folderPath}/IssueLinkQuicklook.tsx`,
|
||||
}),
|
||||
], folderPath, true, 0);
|
||||
const pageOne = availableAllResponse([
|
||||
createItem({
|
||||
title: "SourceTrustBadge.tsx",
|
||||
relativePath: `${folderPath}/SourceTrustBadge.tsx`,
|
||||
displayPath: `${folderPath}/SourceTrustBadge.tsx`,
|
||||
}),
|
||||
], folderPath, true, LIST_LIMIT);
|
||||
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
||||
const query = options.queryKey[4] as { path?: string | null; offset?: number } | undefined;
|
||||
if (query?.path === folderPath && query.offset === LIST_LIMIT) return ok(pageOne);
|
||||
return ok(pageZero);
|
||||
});
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialFolderPath: folderPath,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("IssueLinkQuicklook.tsx");
|
||||
expect(container.textContent).not.toContain("SourceTrustBadge.tsx");
|
||||
const loadMore = Array.from(container.querySelectorAll("button")).find(
|
||||
(el) => el.textContent === "Load more from this folder",
|
||||
);
|
||||
act(() => {
|
||||
loadMore!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("SourceTrustBadge.tsx");
|
||||
});
|
||||
|
||||
it("opens the highlighted row when Enter is pressed in the search field", () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
const { onOpen } = renderBrowser();
|
||||
const input = container.querySelector("input")!;
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
||||
});
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
path: "ui/src/pages/IssueDetail.tsx",
|
||||
workspace: "auto",
|
||||
browseFolderPath: null,
|
||||
browseQuery: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports live search state for URL-backed browse preservation", async () => {
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
|
||||
const onBrowseStateChange = vi.fn();
|
||||
renderBrowser(vi.fn(), {
|
||||
initialFolderPath: "ui/src/components",
|
||||
onBrowseStateChange,
|
||||
});
|
||||
onBrowseStateChange.mockClear();
|
||||
|
||||
const input = container.querySelector("input")!;
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
act(() => {
|
||||
nativeSetter?.call(input, "FileViewerSheet");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
expect(onBrowseStateChange).toHaveBeenLastCalledWith({
|
||||
q: "FileViewerSheet",
|
||||
folderPath: "ui/src/components",
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the remote-workspace state without file rows", () => {
|
||||
useQueryMock.mockReturnValue(ok(unavailableResponse("remote_workspace")));
|
||||
renderBrowser();
|
||||
expect(container.textContent).toContain("Remote workspace preview not supported");
|
||||
expect(container.querySelector('[role="tree"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the no-workspace state when the issue has no workspace", () => {
|
||||
useQueryMock.mockReturnValue(ok(unavailableResponse("no_workspace")));
|
||||
renderBrowser();
|
||||
expect(container.textContent).toContain("No workspace yet");
|
||||
});
|
||||
|
||||
it("opens a result from a selected other project workspace", () => {
|
||||
const contentItem = createItem({
|
||||
relativePath: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md",
|
||||
displayPath: "Paperclip Content / content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md",
|
||||
workspaceLabel: "Paperclip Content",
|
||||
workspaceKind: "project_workspace",
|
||||
workspaceId: "workspace-content",
|
||||
projectId: "project-content",
|
||||
projectName: "Paperclip Content",
|
||||
});
|
||||
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
||||
if (options.queryKey[0] === "projects") return ok([createProject()]);
|
||||
return ok(availableResponse([contentItem]));
|
||||
});
|
||||
|
||||
const { onOpen } = renderBrowser(vi.fn(), {
|
||||
companyId: "company-1",
|
||||
initialProjectId: "project-content",
|
||||
initialWorkspaceId: "workspace-content",
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Other project");
|
||||
expect(container.textContent).toContain("Paperclip Content / Paperclip Content");
|
||||
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
||||
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
||||
workspace: "project",
|
||||
projectId: "project-content",
|
||||
workspaceId: "workspace-content",
|
||||
});
|
||||
|
||||
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === contentItem.displayPath,
|
||||
)!;
|
||||
act(() => {
|
||||
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md",
|
||||
workspace: "project",
|
||||
projectId: "project-content",
|
||||
workspaceId: "workspace-content",
|
||||
browseFolderPath: null,
|
||||
browseQuery: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses an initial folder in a selected other project workspace", () => {
|
||||
const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
|
||||
const contentItem = createItem({
|
||||
relativePath: `${folderPath}README.md`,
|
||||
displayPath: `Paperclip Content / ${folderPath}README.md`,
|
||||
workspaceLabel: "Paperclip Content",
|
||||
workspaceKind: "project_workspace",
|
||||
workspaceId: "workspace-content",
|
||||
projectId: "project-content",
|
||||
projectName: "Paperclip Content",
|
||||
});
|
||||
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
||||
if (options.queryKey[0] === "projects") return ok([createProject()]);
|
||||
return ok(availableResponse([contentItem]));
|
||||
});
|
||||
|
||||
const { onOpen } = renderBrowser(vi.fn(), {
|
||||
companyId: "company-1",
|
||||
initialProjectId: "project-content",
|
||||
initialWorkspaceId: "workspace-content",
|
||||
initialFolderPath: folderPath,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("bundled-skills");
|
||||
expect(container.textContent).not.toContain(folderPath);
|
||||
expect(container.textContent).not.toContain("Files in folder");
|
||||
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
|
||||
expect(listCall?.[0].queryKey[4]).toMatchObject({
|
||||
workspace: "project",
|
||||
projectId: "project-content",
|
||||
workspaceId: "workspace-content",
|
||||
path: folderPath,
|
||||
});
|
||||
|
||||
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === contentItem.displayPath,
|
||||
)!;
|
||||
act(() => {
|
||||
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
path: `${folderPath}README.md`,
|
||||
workspace: "project",
|
||||
projectId: "project-content",
|
||||
workspaceId: "workspace-content",
|
||||
browseFolderPath: folderPath,
|
||||
browseQuery: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens sibling files with the current folder scope", () => {
|
||||
const folderPath = "docs/cli";
|
||||
const controlPlane = createItem({
|
||||
title: "control-plane-commands.md",
|
||||
relativePath: `${folderPath}/control-plane-commands.md`,
|
||||
displayPath: `${folderPath}/control-plane-commands.md`,
|
||||
});
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([
|
||||
createItem({
|
||||
title: "setup-commands.md",
|
||||
relativePath: `${folderPath}/setup-commands.md`,
|
||||
displayPath: `${folderPath}/setup-commands.md`,
|
||||
}),
|
||||
controlPlane,
|
||||
])));
|
||||
|
||||
const { onOpen } = renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialFolderPath: folderPath,
|
||||
selectedPath: `${folderPath}/setup-commands.md`,
|
||||
});
|
||||
|
||||
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === controlPlane.displayPath,
|
||||
)!;
|
||||
act(() => {
|
||||
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
path: `${folderPath}/control-plane-commands.md`,
|
||||
workspace: "auto",
|
||||
browseFolderPath: folderPath,
|
||||
browseQuery: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls the selected file into view after breadcrumb-scoped listings", () => {
|
||||
const folderPath = "docs";
|
||||
const selectedPath = "docs/cli/setup-commands.md";
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRect(this: Element) {
|
||||
if (this.getAttribute("aria-selected") === "true") {
|
||||
return { top: 1086, bottom: 1114, left: 0, right: 100, width: 100, height: 28, x: 0, y: 1086, toJSON: () => ({}) };
|
||||
}
|
||||
if (typeof this.className === "string" && this.className.includes("overflow-y-auto")) {
|
||||
return { top: 188, bottom: 546, left: 0, right: 320, width: 320, height: 358, x: 0, y: 188, toJSON: () => ({}) };
|
||||
}
|
||||
return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) };
|
||||
});
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
||||
title: "setup-commands.md",
|
||||
relativePath: selectedPath,
|
||||
displayPath: selectedPath,
|
||||
})])));
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialFolderPath: folderPath,
|
||||
selectedPath,
|
||||
});
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
||||
});
|
||||
|
||||
it("loads the selected file's ancestor folders when a parent breadcrumb is open", () => {
|
||||
const selectedPath = "docs/cli/setup-commands.md";
|
||||
const docsResponse = availableResponse([
|
||||
createDirectoryItem({
|
||||
title: "cli",
|
||||
relativePath: "docs/cli",
|
||||
displayPath: "docs/cli/",
|
||||
}),
|
||||
]);
|
||||
docsResponse.query = { ...docsResponse.query, mode: "all", path: "docs" };
|
||||
const cliResponse = availableResponse([
|
||||
createItem({
|
||||
title: "setup-commands.md",
|
||||
relativePath: selectedPath,
|
||||
displayPath: selectedPath,
|
||||
}),
|
||||
]);
|
||||
cliResponse.query = { ...cliResponse.query, mode: "all", path: "docs/cli" };
|
||||
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
||||
const query = options.queryKey[4] as { path?: string | null } | undefined;
|
||||
if (query?.path === "docs/cli") return ok(cliResponse);
|
||||
return ok(docsResponse);
|
||||
});
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialFolderPath: "docs",
|
||||
selectedPath,
|
||||
});
|
||||
|
||||
const childListCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[4]?.path === "docs/cli");
|
||||
expect(childListCall?.[0].queryKey[4]).toMatchObject({ path: "docs/cli", offset: 0 });
|
||||
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === selectedPath,
|
||||
);
|
||||
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(container.textContent).toContain("setup-commands.md");
|
||||
});
|
||||
|
||||
it("auto-pages the selected file's current folder until the selected row is loaded", async () => {
|
||||
const folderPath = "ui/src/components";
|
||||
const selectedPath = `${folderPath}/WorkspaceFileBrowser.tsx`;
|
||||
const pageZero = availableAllResponse([
|
||||
createItem({
|
||||
title: "ActivityCharts.tsx",
|
||||
relativePath: `${folderPath}/ActivityCharts.tsx`,
|
||||
displayPath: `${folderPath}/ActivityCharts.tsx`,
|
||||
}),
|
||||
], folderPath, true, 0);
|
||||
const pageOne = availableAllResponse([
|
||||
createItem({
|
||||
title: "SourceTrustBadge.tsx",
|
||||
relativePath: `${folderPath}/SourceTrustBadge.tsx`,
|
||||
displayPath: `${folderPath}/SourceTrustBadge.tsx`,
|
||||
}),
|
||||
], folderPath, true, LIST_LIMIT);
|
||||
const pageTwo = availableAllResponse([
|
||||
createItem({
|
||||
title: "WorkspaceFileBrowser.tsx",
|
||||
relativePath: selectedPath,
|
||||
displayPath: selectedPath,
|
||||
}),
|
||||
], folderPath, false, LIST_LIMIT * 2);
|
||||
useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => {
|
||||
const query = options.queryKey[4] as { path?: string | null; offset?: number } | undefined;
|
||||
if (query?.path === folderPath && query.offset === LIST_LIMIT) return ok(pageOne);
|
||||
if (query?.path === folderPath && query.offset === LIST_LIMIT * 2) return ok(pageTwo);
|
||||
return ok(pageZero);
|
||||
});
|
||||
|
||||
renderBrowser(vi.fn(), {
|
||||
compact: true,
|
||||
autoFocusSearch: false,
|
||||
initialFolderPath: folderPath,
|
||||
selectedPath,
|
||||
});
|
||||
|
||||
await waitForExpectation(() => {
|
||||
const pageTwoCall = useQueryMock.mock.calls.find(
|
||||
([options]) => options.queryKey?.[4]?.path === folderPath && options.queryKey[4].offset === LIST_LIMIT * 2,
|
||||
);
|
||||
expect(pageTwoCall?.[0].queryKey[4]).toMatchObject({ path: folderPath, offset: LIST_LIMIT * 2 });
|
||||
});
|
||||
await waitForExpectation(() => {
|
||||
const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
|
||||
(el) => el.getAttribute("title") === selectedPath,
|
||||
);
|
||||
expect(selected?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(container.textContent).toContain("WorkspaceFileBrowser.tsx");
|
||||
});
|
||||
});
|
||||
|
||||
it("lets breadcrumb folders navigate to parent directories", () => {
|
||||
const folderPath = "content-os/cases/active/";
|
||||
useQueryMock.mockReturnValue(ok(availableResponse([createItem({
|
||||
relativePath: `${folderPath}README.md`,
|
||||
displayPath: `${folderPath}README.md`,
|
||||
})])));
|
||||
|
||||
renderBrowser(vi.fn(), { initialFolderPath: folderPath });
|
||||
const contentOsCrumb = Array.from(container.querySelectorAll("button")).find(
|
||||
(el) => el.getAttribute("title") === "content-os",
|
||||
);
|
||||
expect(contentOsCrumb).not.toBeUndefined();
|
||||
act(() => {
|
||||
contentOsCrumb!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
const latestListCall = useQueryMock.mock.calls.filter(([options]) => options.queryKey?.[3] === "list").at(-1);
|
||||
expect(latestListCall?.[0].queryKey[4]).toMatchObject({ path: "content-os" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeUnavailable", () => {
|
||||
it("maps reasons to copy that matches the viewer's denial voice", () => {
|
||||
expect(describeUnavailable("remote_workspace").title).toBe("Remote workspace preview not supported");
|
||||
expect(describeUnavailable("no_workspace").title).toBe("No workspace yet");
|
||||
expect(describeUnavailable("no_local_workspace").title).toBe("No workspace yet");
|
||||
expect(describeUnavailable("workspace_unavailable").title).toBe("Workspace is no longer available");
|
||||
expect(describeUnavailable("archived").title).toBe("Workspace is no longer available");
|
||||
});
|
||||
|
||||
it("never leaks the raw reason code as the body", () => {
|
||||
for (const reason of ["remote_workspace", "no_workspace", "workspace_unavailable", "weird_unknown"]) {
|
||||
const { body } = describeUnavailable(reason);
|
||||
expect(body).not.toBe(reason);
|
||||
expect(body).not.toMatch(/^[a-z_]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,99 @@
|
|||
import type { MouseEvent, ReactNode } from "react";
|
||||
import { FileCode2, FolderOpen } from "lucide-react";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ParsedWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||||
import { formatWorkspaceFileRefDisplay } from "@/lib/workspace-file-parser";
|
||||
import {
|
||||
useFileViewer,
|
||||
writeFolderViewerStateToSearch,
|
||||
writeFileViewerStateToSearch,
|
||||
} from "@/context/FileViewerContext";
|
||||
|
||||
export interface WorkspaceFileLinkProps {
|
||||
workspaceFileRef: ParsedWorkspaceFileRef;
|
||||
/** Override the rendered label. Defaults to `path:line:col`. */
|
||||
label?: ReactNode;
|
||||
className?: string;
|
||||
/** Optional override if the consumer wants to customize activation. */
|
||||
onOpen?: (ref: ParsedWorkspaceFileRef) => void;
|
||||
showIcon?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceFileLink({
|
||||
workspaceFileRef,
|
||||
label,
|
||||
className,
|
||||
onOpen,
|
||||
showIcon = true,
|
||||
title,
|
||||
}: WorkspaceFileLinkProps) {
|
||||
const viewer = useFileViewer();
|
||||
const location = useLocation();
|
||||
const display = typeof label !== "undefined" ? label : formatWorkspaceFileRefDisplay(workspaceFileRef);
|
||||
const canOpen = !!(onOpen || viewer);
|
||||
const isDirectory = workspaceFileRef.resourceKind === "directory" || workspaceFileRef.path.endsWith("/");
|
||||
const lineSuffix = workspaceFileRef.line
|
||||
? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}`
|
||||
: "";
|
||||
const ariaLabel = canOpen
|
||||
? `Open ${workspaceFileRef.path}${lineSuffix} in the ${isDirectory ? "workspace browser" : "file viewer"}`
|
||||
: `Workspace ${isDirectory ? "folder" : "file"} ${workspaceFileRef.path}${lineSuffix}`;
|
||||
const tooltip = title ?? (canOpen
|
||||
? `Open ${workspaceFileRef.path}${lineSuffix} in the ${isDirectory ? "workspace browser" : "file viewer"}`
|
||||
: `Workspace ${isDirectory ? "folder" : "file"} ${workspaceFileRef.path}${lineSuffix}`);
|
||||
|
||||
const deepLinkSearch = isDirectory
|
||||
? writeFolderViewerStateToSearch(location.search, {
|
||||
path: workspaceFileRef.path,
|
||||
projectId: workspaceFileRef.projectId ?? null,
|
||||
workspaceId: workspaceFileRef.workspaceId ?? null,
|
||||
})
|
||||
: writeFileViewerStateToSearch(location.search, {
|
||||
path: workspaceFileRef.path,
|
||||
line: workspaceFileRef.line ?? null,
|
||||
column: workspaceFileRef.column ?? null,
|
||||
workspace: "auto",
|
||||
projectId: workspaceFileRef.projectId ?? null,
|
||||
workspaceId: workspaceFileRef.workspaceId ?? null,
|
||||
});
|
||||
const href = canOpen
|
||||
? `${location.pathname}${deepLinkSearch}${location.hash}`
|
||||
: "#";
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
if (!canOpen) return;
|
||||
if (onOpen) onOpen(workspaceFileRef);
|
||||
else if (isDirectory) viewer?.openFolder(workspaceFileRef);
|
||||
else viewer?.open(workspaceFileRef);
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
role={canOpen ? "button" : undefined}
|
||||
data-workspace-file-link="true"
|
||||
data-workspace-file-path={workspaceFileRef.path}
|
||||
aria-label={ariaLabel}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{showIcon ? (
|
||||
isDirectory
|
||||
? <FolderOpen aria-hidden="true" className="h-3 w-3 shrink-0 opacity-70" />
|
||||
: <FileCode2 aria-hidden="true" className="h-3 w-3 shrink-0 opacity-70" />
|
||||
) : null}
|
||||
<span className="max-w-full whitespace-normal break-all text-left">{display}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { linkWorkspaceFileInlineCode } from "./WorkspaceFileMarkdownBody";
|
||||
|
||||
describe("linkWorkspaceFileInlineCode", () => {
|
||||
it("links workspace file refs in inline code to the current issue file viewer", () => {
|
||||
const markdown = linkWorkspaceFileInlineCode(
|
||||
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
|
||||
"/issues/PAP-1",
|
||||
"?tab=chat",
|
||||
"#comment-1",
|
||||
);
|
||||
|
||||
expect(markdown).toContain("[`ui/src/pages/IssueDetail.tsx:42`](");
|
||||
expect(markdown).toContain("workspace-file:?path=ui%2Fsrc%2Fpages%2FIssueDetail.tsx&line=42");
|
||||
});
|
||||
|
||||
it("leaves non-file inline code unchanged", () => {
|
||||
expect(linkWorkspaceFileInlineCode("Run `pnpm test`.", "/issues/PAP-1", "", "")).toBe("Run `pnpm test`.");
|
||||
});
|
||||
|
||||
it("links trailing-slash folder refs to the workspace browser", () => {
|
||||
const markdown = linkWorkspaceFileInlineCode(
|
||||
"Open `content-os/cases/active/2026-06-06-pap-10199-bundled-skills/`.",
|
||||
"/issues/PAP-1",
|
||||
"",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(markdown).toContain("kind=directory");
|
||||
expect(markdown).toContain("path=content-os%2Fcases%2Factive%2F2026-06-06-pap-10199-bundled-skills%2F");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { MouseEvent } from "react";
|
||||
import { readFileViewerStateFromSearch, useFileViewer } from "@/context/FileViewerContext";
|
||||
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||||
import { buildWorkspaceFileHref } from "@/lib/remark-workspace-file-refs";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
type MarkdownBodyProps = Parameters<typeof MarkdownBody>[0];
|
||||
|
||||
const INLINE_CODE_RE = /`([^`\r\n]+)`/g;
|
||||
|
||||
function escapeMarkdownLinkLabel(value: string) {
|
||||
return value.replace(/([\\\]])/g, "\\$1");
|
||||
}
|
||||
|
||||
export function linkWorkspaceFileInlineCode(markdown: string, _currentPathname: string, _currentSearch: string, _currentHash: string) {
|
||||
return markdown.replace(INLINE_CODE_RE, (token, rawCode: string) => {
|
||||
const ref = parseWorkspaceFileRef(rawCode);
|
||||
if (!ref) return token;
|
||||
return `[\`${escapeMarkdownLinkLabel(ref.raw)}\`](${buildWorkspaceFileHref(ref)})`;
|
||||
});
|
||||
}
|
||||
|
||||
export function WorkspaceFileMarkdownBody({
|
||||
children,
|
||||
...props
|
||||
}: MarkdownBodyProps) {
|
||||
const viewer = useFileViewer();
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (!viewer) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const anchor = (event.target as HTMLElement | null)?.closest("a");
|
||||
if (!anchor) return;
|
||||
|
||||
const url = new URL(anchor.href, window.location.href);
|
||||
if (url.origin !== window.location.origin || url.pathname !== window.location.pathname) return;
|
||||
const next = readFileViewerStateFromSearch(url.search);
|
||||
if (!next) return;
|
||||
|
||||
event.preventDefault();
|
||||
viewer.open(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={handleClick}>
|
||||
<MarkdownBody {...props} linkWorkspaceFileRefs={!!viewer}>{children}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FILE_VIEWER_NAVIGATE_OPTIONS,
|
||||
readBrowseStateFromSearch,
|
||||
readFileViewerStateFromSearch,
|
||||
writeBrowseStateToSearch,
|
||||
writeFolderViewerStateToSearch,
|
||||
writeFileViewerStateToSearch,
|
||||
} from "./FileViewerContext";
|
||||
|
||||
describe("FILE_VIEWER_NAVIGATE_OPTIONS", () => {
|
||||
it("preserves page scroll when the viewer updates URL search params", () => {
|
||||
expect(FILE_VIEWER_NAVIGATE_OPTIONS.preventScrollReset).toBe(true);
|
||||
expect(FILE_VIEWER_NAVIGATE_OPTIONS.replace).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFileViewerStateFromSearch", () => {
|
||||
it("returns null when no file param is present", () => {
|
||||
expect(readFileViewerStateFromSearch("")).toBeNull();
|
||||
expect(readFileViewerStateFromSearch("?other=1")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads file, line, column, workspace from the search", () => {
|
||||
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts&line=42&column=3&workspace=project");
|
||||
expect(state).toEqual({
|
||||
path: "ui/src/a.ts",
|
||||
line: 42,
|
||||
column: 3,
|
||||
workspace: "project",
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to auto workspace when param missing", () => {
|
||||
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts");
|
||||
expect(state?.workspace).toBe("auto");
|
||||
});
|
||||
|
||||
it("clamps invalid workspace to auto", () => {
|
||||
const state = readFileViewerStateFromSearch("?file=ui/src/a.ts&workspace=bogus");
|
||||
expect(state?.workspace).toBe("auto");
|
||||
});
|
||||
|
||||
it("treats invalid line/column as null", () => {
|
||||
const state = readFileViewerStateFromSearch("?file=x.ts&line=abc&column=-1");
|
||||
expect(state?.line).toBeNull();
|
||||
expect(state?.column).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeFileViewerStateToSearch", () => {
|
||||
it("sets all params when opening", () => {
|
||||
const next = writeFileViewerStateToSearch(
|
||||
"?existing=1",
|
||||
{
|
||||
path: "ui/src/a.ts",
|
||||
line: 42,
|
||||
column: 3,
|
||||
workspace: "project",
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
},
|
||||
);
|
||||
const params = new URLSearchParams(next);
|
||||
expect(params.get("file")).toBe("ui/src/a.ts");
|
||||
expect(params.get("line")).toBe("42");
|
||||
expect(params.get("column")).toBe("3");
|
||||
expect(params.get("workspace")).toBe("project");
|
||||
expect(params.get("existing")).toBe("1");
|
||||
});
|
||||
|
||||
it("omits workspace when auto", () => {
|
||||
const next = writeFileViewerStateToSearch(
|
||||
"",
|
||||
{ path: "a.ts", line: null, column: null, workspace: "auto", projectId: null, workspaceId: null },
|
||||
);
|
||||
expect(next.includes("workspace")).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips explicit target project workspace params", () => {
|
||||
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md";
|
||||
const next = writeFileViewerStateToSearch(
|
||||
"?existing=1",
|
||||
{
|
||||
path: targetPath,
|
||||
line: 7,
|
||||
column: null,
|
||||
workspace: "auto",
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
},
|
||||
);
|
||||
const state = readFileViewerStateFromSearch(next);
|
||||
expect(state).toEqual({
|
||||
path: targetPath,
|
||||
line: 7,
|
||||
column: null,
|
||||
workspace: "auto",
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears viewer params when closing", () => {
|
||||
const next = writeFileViewerStateToSearch(
|
||||
"?file=a.ts&line=1&column=2&workspace=project&projectId=project-1&workspaceId=workspace-1&keep=yes",
|
||||
null,
|
||||
);
|
||||
const params = new URLSearchParams(next);
|
||||
expect(params.get("file")).toBeNull();
|
||||
expect(params.get("line")).toBeNull();
|
||||
expect(params.get("column")).toBeNull();
|
||||
expect(params.get("workspace")).toBeNull();
|
||||
expect(params.get("projectId")).toBeNull();
|
||||
expect(params.get("workspaceId")).toBeNull();
|
||||
expect(params.get("keep")).toBe("yes");
|
||||
});
|
||||
|
||||
it("returns empty string when no params remain", () => {
|
||||
const next = writeFileViewerStateToSearch("?file=a.ts", null);
|
||||
expect(next).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("folder browse search state", () => {
|
||||
it("round-trips explicit target folder browse params", () => {
|
||||
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
|
||||
const next = writeFolderViewerStateToSearch("?tab=thread", {
|
||||
path: targetPath,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
|
||||
expect(readFileViewerStateFromSearch(next)).toBeNull();
|
||||
expect(readBrowseStateFromSearch(next)).toEqual({
|
||||
q: null,
|
||||
folderPath: targetPath,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates browse params without closing the active file preview", () => {
|
||||
const next = writeBrowseStateToSearch(
|
||||
"?tab=thread&file=ui/src/components/FileViewerSheet.tsx&line=5&browse=1",
|
||||
{
|
||||
q: " FileViewerSheet ",
|
||||
folderPath: "ui/src/components",
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
},
|
||||
);
|
||||
|
||||
const params = new URLSearchParams(next);
|
||||
expect(params.get("file")).toBe("ui/src/components/FileViewerSheet.tsx");
|
||||
expect(params.get("line")).toBe("5");
|
||||
expect(params.get("browse")).toBe("1");
|
||||
expect(params.get("q")).toBe("FileViewerSheet");
|
||||
expect(params.get("folder")).toBe("ui/src/components");
|
||||
expect(params.get("tab")).toBe("thread");
|
||||
});
|
||||
|
||||
it("clears browse params while preserving the file preview", () => {
|
||||
const next = writeBrowseStateToSearch(
|
||||
"?file=ui/src/components/FileViewerSheet.tsx&browse=1&q=FileViewerSheet&folder=ui/src/components",
|
||||
{ q: null, folderPath: null },
|
||||
);
|
||||
|
||||
const params = new URLSearchParams(next);
|
||||
expect(params.get("file")).toBe("ui/src/components/FileViewerSheet.tsx");
|
||||
expect(params.get("browse")).toBe("1");
|
||||
expect(params.get("q")).toBeNull();
|
||||
expect(params.get("folder")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
import { createContext, useContext, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate, type NavigateOptions } from "@/lib/router";
|
||||
import type { WorkspaceFileSelector } from "@paperclipai/shared";
|
||||
import type { ParsedWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||||
|
||||
export interface FileViewerUrlState {
|
||||
path: string;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
workspace: WorkspaceFileSelector;
|
||||
projectId: string | null;
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
export interface FileViewerContextValue {
|
||||
issueId: string;
|
||||
/** Current viewer state derived from the URL, or null if closed. */
|
||||
state: FileViewerUrlState | null;
|
||||
/** True when the sheet is in browse mode (URL carries `browse=1`). */
|
||||
browse: boolean;
|
||||
/** The active browse search query (URL `q`), or null. */
|
||||
query: string | null;
|
||||
browseProjectId: string | null;
|
||||
browseWorkspaceId: string | null;
|
||||
folderPath: string | null;
|
||||
open(
|
||||
ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column" | "projectId" | "workspaceId"> & {
|
||||
workspace?: WorkspaceFileSelector;
|
||||
},
|
||||
opts?: {
|
||||
fromBrowse?: boolean;
|
||||
browseState?: Partial<FileViewerBrowseState>;
|
||||
},
|
||||
): void;
|
||||
/** Open (or stay in) browse mode, optionally seeding the search query. */
|
||||
openBrowse(opts?: { q?: string }): void;
|
||||
/** Update URL-backed browse state without closing the active preview. */
|
||||
updateBrowseState(opts: Partial<FileViewerBrowseState>): void;
|
||||
openFolder(
|
||||
ref: Pick<ParsedWorkspaceFileRef, "path" | "projectId" | "workspaceId"> & {
|
||||
workspace?: WorkspaceFileSelector;
|
||||
},
|
||||
): void;
|
||||
/** From a file opened via browse, return to the browse list. */
|
||||
backToFiles(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const FileViewerContext = createContext<FileViewerContextValue | null>(null);
|
||||
|
||||
export const FILE_VIEWER_NAVIGATE_OPTIONS = {
|
||||
replace: false,
|
||||
preventScrollReset: true,
|
||||
} satisfies NavigateOptions;
|
||||
|
||||
export function readFileViewerStateFromSearch(search: string): FileViewerUrlState | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const path = params.get("file");
|
||||
if (!path) return null;
|
||||
const lineRaw = params.get("line");
|
||||
const columnRaw = params.get("column");
|
||||
const workspaceRaw = params.get("workspace");
|
||||
const projectIdRaw = params.get("projectId");
|
||||
const workspaceIdRaw = params.get("workspaceId");
|
||||
const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
|
||||
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
|
||||
const column = columnRaw ? Number.parseInt(columnRaw, 10) : NaN;
|
||||
const workspace = (workspaceRaw === "execution" || workspaceRaw === "project")
|
||||
? workspaceRaw
|
||||
: "auto";
|
||||
return {
|
||||
path,
|
||||
line: Number.isFinite(line) && line > 0 ? line : null,
|
||||
column: Number.isFinite(column) && column > 0 ? column : null,
|
||||
workspace,
|
||||
projectId: hasExplicitTarget ? projectIdRaw : null,
|
||||
workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeFileViewerStateToSearch(current: string, next: FileViewerUrlState | null): string {
|
||||
const params = new URLSearchParams(current);
|
||||
// A direct file open/close is never a browse origin — clear browse params too.
|
||||
params.delete("browse");
|
||||
params.delete("q");
|
||||
params.delete("folder");
|
||||
if (!next) {
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
params.delete("column");
|
||||
params.delete("workspace");
|
||||
params.delete("projectId");
|
||||
params.delete("workspaceId");
|
||||
} else {
|
||||
params.set("file", next.path);
|
||||
if (next.line !== null) params.set("line", String(next.line));
|
||||
else params.delete("line");
|
||||
if (next.column !== null) params.set("column", String(next.column));
|
||||
else params.delete("column");
|
||||
if (next.workspace && next.workspace !== "auto") params.set("workspace", next.workspace);
|
||||
else params.delete("workspace");
|
||||
if (next.projectId) params.set("projectId", next.projectId);
|
||||
else params.delete("projectId");
|
||||
if (next.workspaceId) params.set("workspaceId", next.workspaceId);
|
||||
else params.delete("workspaceId");
|
||||
}
|
||||
const str = params.toString();
|
||||
return str ? `?${str}` : "";
|
||||
}
|
||||
|
||||
export interface FileViewerBrowseState {
|
||||
q: string | null;
|
||||
folderPath: string | null;
|
||||
projectId: string | null;
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
export function readBrowseStateFromSearch(search: string): FileViewerBrowseState | null {
|
||||
const params = new URLSearchParams(search);
|
||||
if (params.get("browse") !== "1") return null;
|
||||
const q = params.get("q");
|
||||
const folder = params.get("folder");
|
||||
const projectId = params.get("projectId");
|
||||
const workspaceId = params.get("workspaceId");
|
||||
return {
|
||||
q: q && q.length > 0 ? q : null,
|
||||
folderPath: folder && folder.length > 0 ? folder : null,
|
||||
projectId: projectId || null,
|
||||
workspaceId: workspaceId || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeFolderViewerStateToSearch(
|
||||
current: string,
|
||||
next: {
|
||||
path: string;
|
||||
projectId: string | null;
|
||||
workspaceId: string | null;
|
||||
},
|
||||
): string {
|
||||
const params = new URLSearchParams(current);
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
params.delete("column");
|
||||
params.delete("workspace");
|
||||
params.delete("q");
|
||||
params.set("browse", "1");
|
||||
params.set("folder", next.path);
|
||||
if (next.projectId) params.set("projectId", next.projectId);
|
||||
else params.delete("projectId");
|
||||
if (next.workspaceId) params.set("workspaceId", next.workspaceId);
|
||||
else params.delete("workspaceId");
|
||||
const str = params.toString();
|
||||
return str ? `?${str}` : "";
|
||||
}
|
||||
|
||||
export function writeBrowseStateToSearch(current: string, next: Partial<FileViewerBrowseState>): string {
|
||||
const params = new URLSearchParams(current);
|
||||
params.set("browse", "1");
|
||||
if ("q" in next) {
|
||||
const q = next.q?.trim() ?? "";
|
||||
if (q) params.set("q", q);
|
||||
else params.delete("q");
|
||||
}
|
||||
if ("folderPath" in next) {
|
||||
const folderPath = next.folderPath?.trim() ?? "";
|
||||
if (folderPath) params.set("folder", folderPath);
|
||||
else params.delete("folder");
|
||||
}
|
||||
if ("projectId" in next) {
|
||||
if (next.projectId) params.set("projectId", next.projectId);
|
||||
else params.delete("projectId");
|
||||
}
|
||||
if ("workspaceId" in next) {
|
||||
if (next.workspaceId) params.set("workspaceId", next.workspaceId);
|
||||
else params.delete("workspaceId");
|
||||
}
|
||||
const str = params.toString();
|
||||
return str ? `?${str}` : "";
|
||||
}
|
||||
|
||||
interface FileViewerProviderProps {
|
||||
issueId: string;
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function FileViewerProvider({ issueId, children, enabled = true }: FileViewerProviderProps) {
|
||||
if (!enabled) return <>{children}</>;
|
||||
return <EnabledFileViewerProvider issueId={issueId}>{children}</EnabledFileViewerProvider>;
|
||||
}
|
||||
|
||||
function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProviderProps, "enabled">) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
|
||||
const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]);
|
||||
|
||||
const navigateSearch = useCallback(
|
||||
(nextSearch: string, opts?: Partial<NavigateOptions>) => {
|
||||
if (nextSearch === location.search) return;
|
||||
navigate(
|
||||
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
|
||||
{ ...FILE_VIEWER_NAVIGATE_OPTIONS, ...opts, state: location.state },
|
||||
);
|
||||
},
|
||||
[location.hash, location.pathname, location.state, navigate],
|
||||
);
|
||||
|
||||
const open = useCallback<FileViewerContextValue["open"]>(
|
||||
(ref, opts) => {
|
||||
let nextSearch = writeFileViewerStateToSearch(location.search, {
|
||||
path: ref.path,
|
||||
line: ref.line ?? null,
|
||||
column: ref.column ?? null,
|
||||
workspace: ref.workspace ?? "auto",
|
||||
projectId: ref.projectId ?? null,
|
||||
workspaceId: ref.workspaceId ?? null,
|
||||
});
|
||||
if (opts?.fromBrowse) {
|
||||
const params = new URLSearchParams(nextSearch);
|
||||
params.set("browse", "1");
|
||||
const previousParams = new URLSearchParams(location.search);
|
||||
const prevQ = previousParams.get("q");
|
||||
const prevFolder = previousParams.get("folder");
|
||||
const nextQ = Object.prototype.hasOwnProperty.call(opts.browseState ?? {}, "q")
|
||||
? opts.browseState?.q
|
||||
: prevQ;
|
||||
const nextFolder = Object.prototype.hasOwnProperty.call(opts.browseState ?? {}, "folderPath")
|
||||
? opts.browseState?.folderPath
|
||||
: prevFolder;
|
||||
if (nextQ) params.set("q", nextQ);
|
||||
else params.delete("q");
|
||||
if (nextFolder) params.set("folder", nextFolder);
|
||||
else params.delete("folder");
|
||||
nextSearch = params.toString() ? `?${params.toString()}` : "";
|
||||
}
|
||||
navigateSearch(nextSearch);
|
||||
},
|
||||
[location.search, navigateSearch],
|
||||
);
|
||||
|
||||
const openBrowse = useCallback<FileViewerContextValue["openBrowse"]>(
|
||||
(opts) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
params.delete("column");
|
||||
params.delete("folder");
|
||||
params.set("browse", "1");
|
||||
if (typeof opts?.q === "string" && opts.q.length > 0) params.set("q", opts.q);
|
||||
else params.delete("q");
|
||||
navigateSearch(params.toString() ? `?${params.toString()}` : "");
|
||||
},
|
||||
[location.search, navigateSearch],
|
||||
);
|
||||
|
||||
const updateBrowseState = useCallback<FileViewerContextValue["updateBrowseState"]>(
|
||||
(opts) => {
|
||||
navigateSearch(writeBrowseStateToSearch(location.search, opts), { replace: true });
|
||||
},
|
||||
[location.search, navigateSearch],
|
||||
);
|
||||
|
||||
const openFolder = useCallback<FileViewerContextValue["openFolder"]>(
|
||||
(ref) => {
|
||||
const nextSearch = writeFolderViewerStateToSearch(location.search, {
|
||||
path: ref.path,
|
||||
projectId: ref.projectId ?? null,
|
||||
workspaceId: ref.workspaceId ?? null,
|
||||
});
|
||||
navigateSearch(nextSearch);
|
||||
},
|
||||
[location.search, navigateSearch],
|
||||
);
|
||||
|
||||
const backToFiles = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("file");
|
||||
params.delete("line");
|
||||
params.delete("column");
|
||||
params.delete("workspace");
|
||||
params.set("browse", "1");
|
||||
navigateSearch(params.toString() ? `?${params.toString()}` : "");
|
||||
}, [location.search, navigateSearch]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
const params = new URLSearchParams(writeFileViewerStateToSearch(location.search, null).replace(/^\?/, ""));
|
||||
params.delete("browse");
|
||||
params.delete("q");
|
||||
params.delete("folder");
|
||||
navigateSearch(params.toString() ? `?${params.toString()}` : "");
|
||||
}, [location.search, navigateSearch]);
|
||||
|
||||
const value = useMemo<FileViewerContextValue>(
|
||||
() => ({
|
||||
issueId,
|
||||
state,
|
||||
browse: browseState !== null,
|
||||
query: browseState?.q ?? null,
|
||||
browseProjectId: browseState?.projectId ?? null,
|
||||
browseWorkspaceId: browseState?.workspaceId ?? null,
|
||||
folderPath: browseState?.folderPath ?? null,
|
||||
open,
|
||||
openBrowse,
|
||||
updateBrowseState,
|
||||
openFolder,
|
||||
backToFiles,
|
||||
close,
|
||||
}),
|
||||
[issueId, state, browseState, open, openBrowse, updateBrowseState, openFolder, backToFiles, close],
|
||||
);
|
||||
|
||||
return <FileViewerContext.Provider value={value}>{children}</FileViewerContext.Provider>;
|
||||
}
|
||||
|
||||
export function useFileViewer(): FileViewerContextValue | null {
|
||||
return useContext(FileViewerContext);
|
||||
}
|
||||
|
||||
export function useRequiredFileViewer(): FileViewerContextValue {
|
||||
const ctx = useContext(FileViewerContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useRequiredFileViewer must be used within a FileViewerProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -257,6 +257,21 @@ describe("keyboardShortcuts helpers", () => {
|
|||
})).toBe("focus_comment");
|
||||
});
|
||||
|
||||
it("opens the file viewer on f after g", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveIssueDetailGoKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "f",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("open_file_viewer");
|
||||
});
|
||||
|
||||
it("disarms go-to-inbox instead of firing from an editor", () => {
|
||||
const input = document.createElement("textarea");
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
|||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
export type InboxUndoArchiveKeyAction = "ignore" | "undo_archive";
|
||||
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
|
||||
export type IssueDetailGoKeyAction =
|
||||
| "ignore"
|
||||
| "arm"
|
||||
| "navigate_inbox"
|
||||
| "focus_comment"
|
||||
| "open_file_viewer"
|
||||
| "disarm";
|
||||
|
||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
|
@ -162,6 +168,7 @@ export function resolveIssueDetailGoKeyAction({
|
|||
if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
|
||||
if (normalizedKey === "i") return "navigate_inbox";
|
||||
if (normalizedKey === "c") return "focus_comment";
|
||||
if (normalizedKey === "f") return "open_file_viewer";
|
||||
if (normalizedKey === "g") return "arm";
|
||||
return "disarm";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,30 @@ export const queryKeys = {
|
|||
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
|
||||
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
|
||||
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
||||
fileResources: (
|
||||
issueId: string,
|
||||
options: {
|
||||
workspace?: string;
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
path?: string | null;
|
||||
mode?: string;
|
||||
q?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) =>
|
||||
["issues", "file-resources", issueId, "list", options] as const,
|
||||
fileResource: (
|
||||
issueId: string,
|
||||
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
|
||||
) =>
|
||||
["issues", "file-resources", issueId, "resolve", query] as const,
|
||||
fileResourceContent: (
|
||||
issueId: string,
|
||||
query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
|
||||
) =>
|
||||
["issues", "file-resources", issueId, "content", query] as const,
|
||||
},
|
||||
routines: {
|
||||
list: (companyId: string, filters?: { projectId?: string | null }) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorkspaceFileHref,
|
||||
parseWorkspaceFileHref,
|
||||
remarkWorkspaceFileRefs,
|
||||
} from "./remark-workspace-file-refs";
|
||||
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MarkdownNode[];
|
||||
};
|
||||
|
||||
function textNode(value: string): MarkdownNode {
|
||||
return { type: "text", value };
|
||||
}
|
||||
|
||||
function inlineCode(value: string): MarkdownNode {
|
||||
return { type: "inlineCode", value };
|
||||
}
|
||||
|
||||
function paragraph(children: MarkdownNode[]): MarkdownNode {
|
||||
return { type: "paragraph", children };
|
||||
}
|
||||
|
||||
function runPlugin(tree: MarkdownNode): MarkdownNode {
|
||||
const transform = remarkWorkspaceFileRefs();
|
||||
transform(tree);
|
||||
return tree;
|
||||
}
|
||||
|
||||
describe("remarkWorkspaceFileRefs", () => {
|
||||
it("converts a matching inline code span into a workspace-file link", () => {
|
||||
const tree = paragraph([
|
||||
textNode("Check "),
|
||||
inlineCode("ui/src/pages/IssueDetail.tsx:42"),
|
||||
textNode(" please."),
|
||||
]);
|
||||
runPlugin(tree);
|
||||
expect(tree.children).toHaveLength(3);
|
||||
const link = tree.children![1];
|
||||
expect(link.type).toBe("link");
|
||||
expect(link.url?.startsWith("workspace-file:")).toBe(true);
|
||||
const parsed = parseWorkspaceFileHref(link.url);
|
||||
expect(parsed?.path).toBe("ui/src/pages/IssueDetail.tsx");
|
||||
expect(parsed?.resourceKind).toBe("file");
|
||||
expect(parsed?.line).toBe(42);
|
||||
});
|
||||
|
||||
it("does not linkify plain text path mentions outside inline code", () => {
|
||||
const tree = paragraph([textNode("see ui/src/pages/IssueDetail.tsx for details")]);
|
||||
runPlugin(tree);
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children![0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("does not linkify prose words that look like filenames", () => {
|
||||
const tree = paragraph([inlineCode("README.md")]);
|
||||
runPlugin(tree);
|
||||
expect(tree.children![0].type).toBe("inlineCode");
|
||||
});
|
||||
|
||||
it("round-trips workspace file hrefs", () => {
|
||||
const href = buildWorkspaceFileHref({ path: "a/b.ts", line: 5, column: 2, raw: "a/b.ts:5:2" });
|
||||
const parsed = parseWorkspaceFileHref(href);
|
||||
expect(parsed?.path).toBe("a/b.ts");
|
||||
expect(parsed?.line).toBe(5);
|
||||
expect(parsed?.column).toBe(2);
|
||||
});
|
||||
|
||||
it("round-trips workspace folder hrefs", () => {
|
||||
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
|
||||
const href = buildWorkspaceFileHref({
|
||||
path: targetPath,
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: targetPath,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
const parsed = parseWorkspaceFileHref(href);
|
||||
expect(parsed).toMatchObject({
|
||||
path: targetPath,
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts trailing-slash inline code into workspace folder links", () => {
|
||||
const tree = paragraph([
|
||||
textNode("Open "),
|
||||
inlineCode("content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"),
|
||||
textNode("."),
|
||||
]);
|
||||
runPlugin(tree);
|
||||
const link = tree.children![1];
|
||||
expect(link.type).toBe("link");
|
||||
expect(parseWorkspaceFileHref(link.url)).toMatchObject({
|
||||
path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/",
|
||||
resourceKind: "directory",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips explicit project workspace identity", () => {
|
||||
const targetPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md";
|
||||
const href = buildWorkspaceFileHref({
|
||||
path: targetPath,
|
||||
line: 5,
|
||||
column: null,
|
||||
raw: `${targetPath}:5`,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
projectName: "Paperclip Content",
|
||||
});
|
||||
const parsed = parseWorkspaceFileHref(href);
|
||||
expect(parsed).toMatchObject({
|
||||
path: targetPath,
|
||||
line: 5,
|
||||
column: null,
|
||||
projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
|
||||
workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
|
||||
projectName: "Paperclip Content",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts linked inline-code file paths into workspace-file links", () => {
|
||||
const tree: MarkdownNode = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "link",
|
||||
url: "/PAP/issues/PAP-10306",
|
||||
children: [inlineCode("ui/src/a.ts:1")],
|
||||
},
|
||||
],
|
||||
};
|
||||
runPlugin(tree);
|
||||
const link = tree.children![0];
|
||||
expect(link.type).toBe("link");
|
||||
expect(link.url?.startsWith("workspace-file:")).toBe(true);
|
||||
expect(parseWorkspaceFileHref(link.url)?.path).toBe("ui/src/a.ts");
|
||||
});
|
||||
|
||||
it("does not descend into existing links with mixed labels", () => {
|
||||
const tree: MarkdownNode = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "link",
|
||||
url: "https://example.com",
|
||||
children: [textNode("see "), inlineCode("ui/src/a.ts:1")],
|
||||
},
|
||||
],
|
||||
};
|
||||
runPlugin(tree);
|
||||
const link = tree.children![0];
|
||||
expect(link.url).toBe("https://example.com");
|
||||
expect(link.children![1]?.type).toBe("inlineCode");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { parseWorkspaceFileRef, type ParsedWorkspaceFileRef } from "./workspace-file-parser";
|
||||
|
||||
const WORKSPACE_FILE_HREF_SCHEME = "workspace-file:";
|
||||
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MarkdownNode[];
|
||||
};
|
||||
|
||||
export function buildWorkspaceFileHref(ref: ParsedWorkspaceFileRef): string {
|
||||
const params = new URLSearchParams();
|
||||
if (ref.projectId) params.set("projectId", ref.projectId);
|
||||
if (ref.workspaceId) params.set("workspaceId", ref.workspaceId);
|
||||
if (ref.resourceKind === "directory") params.set("kind", "directory");
|
||||
params.set("path", ref.path);
|
||||
if (ref.line !== null) params.set("line", String(ref.line));
|
||||
if (ref.column !== null) params.set("column", String(ref.column));
|
||||
if (ref.projectName) params.set("projectName", ref.projectName);
|
||||
return `${WORKSPACE_FILE_HREF_SCHEME}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parseWorkspaceFileHref(href: string | null | undefined): ParsedWorkspaceFileRef | null {
|
||||
if (!href || typeof href !== "string") return null;
|
||||
if (!href.startsWith(WORKSPACE_FILE_HREF_SCHEME)) return null;
|
||||
const rest = href.slice(WORKSPACE_FILE_HREF_SCHEME.length);
|
||||
const withoutLeadingQuestion = rest.startsWith("?") ? rest.slice(1) : rest;
|
||||
const params = new URLSearchParams(withoutLeadingQuestion);
|
||||
const path = params.get("path");
|
||||
if (!path) return null;
|
||||
const projectIdRaw = params.get("projectId");
|
||||
const workspaceIdRaw = params.get("workspaceId");
|
||||
const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
|
||||
const projectName = params.get("projectName");
|
||||
const kindRaw = params.get("kind");
|
||||
const lineRaw = params.get("line");
|
||||
const columnRaw = params.get("column");
|
||||
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
|
||||
const column = columnRaw ? Number.parseInt(columnRaw, 10) : NaN;
|
||||
return {
|
||||
path,
|
||||
resourceKind: kindRaw === "directory" || path.endsWith("/") ? "directory" : "file",
|
||||
line: Number.isFinite(line) && line > 0 ? line : null,
|
||||
column: Number.isFinite(column) && column > 0 ? column : null,
|
||||
projectId: hasExplicitTarget ? projectIdRaw : null,
|
||||
workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
|
||||
projectName: projectName || null,
|
||||
raw: path,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceFileLinkNode(ref: ParsedWorkspaceFileRef): MarkdownNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: buildWorkspaceFileHref(ref),
|
||||
children: [{ type: "inlineCode", value: ref.raw }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseSingleInlineCodeFileRef(node: MarkdownNode): ParsedWorkspaceFileRef | null {
|
||||
if (!Array.isArray(node.children) || node.children.length !== 1) return null;
|
||||
const [child] = node.children;
|
||||
if (child?.type !== "inlineCode" || typeof child.value !== "string") return null;
|
||||
return parseWorkspaceFileRef(child.value);
|
||||
}
|
||||
|
||||
function rewriteMarkdownTree(node: MarkdownNode) {
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
||||
// Existing links whose whole label is a workspace-file code span should become
|
||||
// file-viewer links instead of issue/external links.
|
||||
if (node.type === "link") {
|
||||
const ref = parseSingleInlineCodeFileRef(node);
|
||||
if (ref) {
|
||||
node.url = buildWorkspaceFileHref(ref);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Don't descend into other link-like or code blocks; only rewrite inlineCode within flowing text.
|
||||
if (node.type === "linkReference" || node.type === "code" || node.type === "definition" || node.type === "html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChildren: MarkdownNode[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type === "inlineCode" && typeof child.value === "string") {
|
||||
const ref = parseWorkspaceFileRef(child.value);
|
||||
if (ref) {
|
||||
nextChildren.push(createWorkspaceFileLinkNode(ref));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rewriteMarkdownTree(child);
|
||||
nextChildren.push(child);
|
||||
}
|
||||
node.children = nextChildren;
|
||||
}
|
||||
|
||||
export function remarkWorkspaceFileRefs() {
|
||||
return (tree: MarkdownNode) => {
|
||||
rewriteMarkdownTree(tree);
|
||||
};
|
||||
}
|
||||
|
||||
export const WORKSPACE_FILE_HREF_PREFIX = WORKSPACE_FILE_HREF_SCHEME;
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkspaceFileRef, formatWorkspaceFileRefDisplay } from "./workspace-file-parser";
|
||||
|
||||
describe("parseWorkspaceFileRef", () => {
|
||||
it("parses a simple workspace-relative path", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/src/pages/IssueDetail.tsx");
|
||||
expect(ref).toEqual({
|
||||
path: "ui/src/pages/IssueDetail.tsx",
|
||||
resourceKind: "file",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: "ui/src/pages/IssueDetail.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses path:line suffixes", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/src/pages/IssueDetail.tsx:42");
|
||||
expect(ref?.path).toBe("ui/src/pages/IssueDetail.tsx");
|
||||
expect(ref?.line).toBe(42);
|
||||
expect(ref?.column).toBe(null);
|
||||
});
|
||||
|
||||
it("parses path:line:column suffixes", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/src/pages/IssueDetail.tsx:42:3");
|
||||
expect(ref?.line).toBe(42);
|
||||
expect(ref?.column).toBe(3);
|
||||
});
|
||||
|
||||
it("parses #L42 style anchors", () => {
|
||||
const ref = parseWorkspaceFileRef("packages/shared/src/index.ts#L42");
|
||||
expect(ref?.path).toBe("packages/shared/src/index.ts");
|
||||
expect(ref?.line).toBe(42);
|
||||
});
|
||||
|
||||
it("parses #L42C3 style anchors", () => {
|
||||
const ref = parseWorkspaceFileRef("packages/shared/src/index.ts#L42C3");
|
||||
expect(ref?.line).toBe(42);
|
||||
expect(ref?.column).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects absolute unix paths", () => {
|
||||
expect(parseWorkspaceFileRef("/etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects absolute windows paths", () => {
|
||||
expect(parseWorkspaceFileRef("C:\\Users\\foo\\file.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths that escape with ..", () => {
|
||||
expect(parseWorkspaceFileRef("../secrets/file.env")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo/../secrets.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects home-relative paths", () => {
|
||||
expect(parseWorkspaceFileRef("~/.ssh/id_rsa")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths with null bytes or backslashes", () => {
|
||||
expect(parseWorkspaceFileRef("foo\\bar.txt")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo\0bar.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects plain prose words with an extension", () => {
|
||||
expect(parseWorkspaceFileRef("README.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts paths without extension when deeply nested", () => {
|
||||
const ref = parseWorkspaceFileRef("scripts/setup/install");
|
||||
expect(ref?.path).toBe("scripts/setup/install");
|
||||
});
|
||||
|
||||
it("parses trailing-slash directory refs", () => {
|
||||
const ref = parseWorkspaceFileRef("content-os/cases/active/2026-06-06-pap-10199-bundled-skills/");
|
||||
expect(ref).toEqual({
|
||||
path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/",
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects one-segment directory refs because they are too ambiguous to route", () => {
|
||||
expect(parseWorkspaceFileRef("sources/")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsafe directory refs", () => {
|
||||
expect(parseWorkspaceFileRef("../secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo/../secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("~/secrets/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("C:/Users/foo/")).toBeNull();
|
||||
expect(parseWorkspaceFileRef("foo\\bar/")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats the display string with line and column", () => {
|
||||
expect(formatWorkspaceFileRefDisplay({ path: "a/b.ts", resourceKind: "file", line: 5, column: 10, raw: "a/b.ts:5:10" })).toBe("a/b.ts:5:10");
|
||||
expect(formatWorkspaceFileRefDisplay({ path: "a/b.ts", resourceKind: "file", line: 5, column: null, raw: "a/b.ts:5" })).toBe("a/b.ts:5");
|
||||
expect(formatWorkspaceFileRefDisplay({ path: "a/b.ts", resourceKind: "file", line: null, column: null, raw: "a/b.ts" })).toBe("a/b.ts");
|
||||
});
|
||||
|
||||
it("rejects line numbers that are zero or negative", () => {
|
||||
const ref = parseWorkspaceFileRef("ui/a.ts:0");
|
||||
expect(ref).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty or whitespace input", () => {
|
||||
expect(parseWorkspaceFileRef("")).toBeNull();
|
||||
expect(parseWorkspaceFileRef(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
export interface ParsedWorkspaceFileRef {
|
||||
path: string;
|
||||
resourceKind?: "file" | "directory";
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
workspaceId?: string | null;
|
||||
/** The original matched text (useful for rendering) */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a workspace file reference inside an inline code span.
|
||||
*
|
||||
* Accepts POSIX-style relative paths with at least one slash or a recognizable
|
||||
* file extension. Supports optional line/column suffixes:
|
||||
*
|
||||
* - `path/to/file.ext`
|
||||
* - `path/to/file.ext:42`
|
||||
* - `path/to/file.ext:42:3`
|
||||
* - `path/to/file.ext#L42`
|
||||
* - `path/to/file.ext#L42C3`
|
||||
*/
|
||||
const WORKSPACE_FILE_REF_RE =
|
||||
/^([A-Za-z0-9_.\-+][A-Za-z0-9_./\-+]*\.[A-Za-z0-9_+\-]{1,10})(?::([1-9]\d*)(?::([1-9]\d*))?|#L([1-9]\d*)(?:C([1-9]\d*))?)?$/;
|
||||
|
||||
const BARE_NO_EXT_RE = /^([A-Za-z0-9_.\-+][A-Za-z0-9_./\-+]+\/[A-Za-z0-9_.\-+]+)(?::([1-9]\d*)(?::([1-9]\d*))?|#L([1-9]\d*)(?:C([1-9]\d*))?)?$/;
|
||||
|
||||
const WORKSPACE_DIRECTORY_REF_RE = /^([A-Za-z0-9_.\-+][A-Za-z0-9_./\-+]*\/)$/;
|
||||
|
||||
const INVALID_PREFIXES = ["/", "./", "../", "~/"];
|
||||
|
||||
function toPositiveInt(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function looksLikeWorkspacePath(input: string, opts: { allowTrailingSlash: boolean }): boolean {
|
||||
if (!input || input.length > 512) return false;
|
||||
if (input.includes("\\") || input.includes("\0")) return false;
|
||||
if (input.startsWith("/") || input.startsWith("~") || /^[A-Za-z]:/.test(input)) return false;
|
||||
if (input.includes("//")) return false;
|
||||
if (INVALID_PREFIXES.some((prefix) => input === prefix.slice(0, -1))) return false;
|
||||
const path = opts.allowTrailingSlash && input.endsWith("/") ? input.slice(0, -1) : input;
|
||||
if (!path || /^\.+$/.test(path)) return false;
|
||||
const segments = path.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "" || segment === "." || segment === "..") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse the given text as a workspace file reference.
|
||||
* Returns null if the text does not look like one.
|
||||
*/
|
||||
export function parseWorkspaceFileRef(input: string): ParsedWorkspaceFileRef | null {
|
||||
if (typeof input !== "string") return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const directoryMatch = trimmed.match(WORKSPACE_DIRECTORY_REF_RE);
|
||||
if (directoryMatch) {
|
||||
const [, rawPath] = directoryMatch;
|
||||
if (!rawPath || !looksLikeWorkspacePath(rawPath, { allowTrailingSlash: true })) return null;
|
||||
if (!rawPath.slice(0, -1).includes("/")) return null;
|
||||
return {
|
||||
path: rawPath,
|
||||
resourceKind: "directory",
|
||||
line: null,
|
||||
column: null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const match = trimmed.match(WORKSPACE_FILE_REF_RE) ?? trimmed.match(BARE_NO_EXT_RE);
|
||||
if (!match) return null;
|
||||
const [, rawPath, colonLine, colonCol, hashLine, hashCol] = match;
|
||||
if (!rawPath) return null;
|
||||
if (!looksLikeWorkspacePath(rawPath, { allowTrailingSlash: false })) return null;
|
||||
|
||||
const line = toPositiveInt(colonLine) ?? toPositiveInt(hashLine);
|
||||
const column = toPositiveInt(colonCol) ?? toPositiveInt(hashCol);
|
||||
|
||||
// Disambiguate against plain prose filenames like `README.md`:
|
||||
// require either a slash in the path or a line anchor.
|
||||
if (!rawPath.includes("/") && line === null) return null;
|
||||
|
||||
return {
|
||||
path: rawPath,
|
||||
resourceKind: "file",
|
||||
line,
|
||||
column,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWorkspaceFileRefDisplay(ref: ParsedWorkspaceFileRef): string {
|
||||
const path = ref.line && ref.column
|
||||
? `${ref.path}:${ref.line}:${ref.column}`
|
||||
: ref.line
|
||||
? `${ref.path}:${ref.line}`
|
||||
: ref.path;
|
||||
return ref.projectName ? `${ref.projectName} / ${path}` : path;
|
||||
}
|
||||
|
|
@ -210,6 +210,8 @@ export function InstanceExperimentalSettings() {
|
|||
experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
|
||||
const enableIssuePlanDecompositions =
|
||||
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
||||
const enableExperimentalFileViewer =
|
||||
experimentalQuery.data?.enableExperimentalFileViewer === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
const enableIssueGraphLivenessAutoRecovery =
|
||||
|
|
@ -286,6 +288,27 @@ export function InstanceExperimentalSettings() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Experimental File Viewer</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Show task detail controls for browsing and previewing workspace files relative to a task.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableExperimentalFileViewer}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({
|
||||
enableExperimentalFileViewer: !enableExperimentalFileViewer,
|
||||
})
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle experimental file viewer setting"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent, Issue, IssueAttachment, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { NavigationType } from "react-router-dom";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail";
|
||||
import { canBoardResolveRecoveryAction, IssueDetail, shouldScrollIssueDetailToTopOnNavigation } from "./IssueDetail";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
|
|
@ -74,6 +75,7 @@ const mockPushToast = vi.hoisted(() => vi.fn());
|
|||
const mockIssuesListRender = vi.hoisted(() => vi.fn());
|
||||
const mockIssueChatThreadRender = vi.hoisted(() => vi.fn());
|
||||
const mockImageGalleryRender = vi.hoisted(() => vi.fn());
|
||||
const mockIssueWorkspaceCardRender = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
|
|
@ -281,7 +283,10 @@ vi.mock("../components/IssueRunLedger", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../components/IssueWorkspaceCard", () => ({
|
||||
IssueWorkspaceCard: () => <div>Workspace</div>,
|
||||
IssueWorkspaceCard: (props: { onBrowseFiles?: () => void; onOpenFileByPath?: () => void }) => {
|
||||
mockIssueWorkspaceCardRender(props);
|
||||
return <div>Workspace</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/ImageGalleryModal", () => ({
|
||||
|
|
@ -355,6 +360,7 @@ vi.mock("@/components/ui/sheet", () => ({
|
|||
Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
|
||||
SheetContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
SheetHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
SheetDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
|
||||
SheetTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
|
|
@ -932,11 +938,13 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
});
|
||||
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
|
||||
mockIssuesListRender.mockClear();
|
||||
mockIssueChatThreadRender.mockClear();
|
||||
mockImageGalleryRender.mockClear();
|
||||
mockIssueWorkspaceCardRender.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -992,10 +1000,55 @@ describe("IssueDetail", () => {
|
|||
expect(mockIssuesApi.listAcceptedPlanDecompositions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides file viewer entry points by default", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('[aria-label="Open file in this issue"]')).toBeNull();
|
||||
const latestWorkspaceProps = mockIssueWorkspaceCardRender.mock.calls.at(-1)?.[0];
|
||||
expect(latestWorkspaceProps?.onBrowseFiles).toBeUndefined();
|
||||
expect(latestWorkspaceProps?.onOpenFileByPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows file viewer entry points when the experimental flag is enabled", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('[aria-label="Open file in this issue"]')).not.toBeNull();
|
||||
const latestWorkspaceProps = mockIssueWorkspaceCardRender.mock.calls.at(-1)?.[0];
|
||||
expect(latestWorkspaceProps?.onBrowseFiles).toEqual(expect.any(Function));
|
||||
expect(latestWorkspaceProps?.onOpenFileByPath).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("shows the plan decomposition panel when the experimental flag is enabled", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: false,
|
||||
});
|
||||
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([
|
||||
{
|
||||
|
|
@ -1960,3 +2013,29 @@ describe("canBoardResolveRecoveryAction", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldScrollIssueDetailToTopOnNavigation", () => {
|
||||
it("does not scroll when only URL search params changed for the same issue", () => {
|
||||
expect(shouldScrollIssueDetailToTopOnNavigation({
|
||||
previousIssueId: "PAP-10306",
|
||||
nextIssueId: "PAP-10306",
|
||||
navigationType: NavigationType.Push,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("scrolls on forward navigation to a different issue", () => {
|
||||
expect(shouldScrollIssueDetailToTopOnNavigation({
|
||||
previousIssueId: "PAP-1",
|
||||
nextIssueId: "PAP-2",
|
||||
navigationType: NavigationType.Push,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("does not scroll on browser back or forward restoration", () => {
|
||||
expect(shouldScrollIssueDetailToTopOnNavigation({
|
||||
previousIssueId: "PAP-1",
|
||||
nextIssueId: "PAP-2",
|
||||
navigationType: NavigationType.Pop,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ import { IssueRunLedger } from "../components/IssueRunLedger";
|
|||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||
import { FileViewerProvider, useRequiredFileViewer } from "../context/FileViewerContext";
|
||||
import { FileViewerSheet } from "../components/FileViewerSheet";
|
||||
import { ArtifactFileChip } from "../components/ArtifactFileChip";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
|
|
@ -132,6 +135,7 @@ import {
|
|||
Eye,
|
||||
EyeOff,
|
||||
Flag,
|
||||
FileCode2,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
MessageSquare,
|
||||
|
|
@ -164,6 +168,8 @@ import {
|
|||
type RequestConfirmationInteraction,
|
||||
type SuggestTasksInteraction,
|
||||
type IssueTreeControlMode,
|
||||
type WorkspaceFileRef,
|
||||
workspaceFileRefSchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type StopAndFinalizeRunError = Error & {
|
||||
|
|
@ -261,6 +267,15 @@ export function canBoardResolveRecoveryAction(
|
|||
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
||||
}
|
||||
|
||||
export function shouldScrollIssueDetailToTopOnNavigation(input: {
|
||||
previousIssueId: string | undefined;
|
||||
nextIssueId: string | undefined;
|
||||
navigationType: ReturnType<typeof useNavigationType>;
|
||||
}): boolean {
|
||||
if (input.navigationType === "POP") return false;
|
||||
return input.previousIssueId !== input.nextIssueId;
|
||||
}
|
||||
|
||||
function resolveRunningIssueRun(
|
||||
activeRun: ActiveRunForIssue | null | undefined,
|
||||
liveRuns: readonly LiveRunForIssue[] | undefined,
|
||||
|
|
@ -298,6 +313,15 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractWorkspaceFileRefFromWorkProduct(
|
||||
workProduct: { metadata: Record<string, unknown> | null },
|
||||
): WorkspaceFileRef | null {
|
||||
const metadata = asRecord(workProduct.metadata);
|
||||
if (!metadata) return null;
|
||||
const parsed = workspaceFileRefSchema.safeParse(metadata.resourceRef);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
|
||||
if (!usage) return 0;
|
||||
for (const key of keys) {
|
||||
|
|
@ -1276,6 +1300,7 @@ export function IssueDetail() {
|
|||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("chat");
|
||||
const [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
|
||||
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||
|
|
@ -1296,6 +1321,7 @@ export function IssueDetail() {
|
|||
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
const lastScrollIssueIdRef = useRef<string | undefined>(undefined);
|
||||
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
|
||||
const cancelledQueuedOptimisticCommentIdsRef = useRef(new Set<string>());
|
||||
const resolvedIssueDetailState = useMemo(
|
||||
|
|
@ -1494,6 +1520,7 @@ export function IssueDetail() {
|
|||
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
|
||||
const showPlanDecompositionsSection =
|
||||
instanceExperimentalSettings?.enableIssuePlanDecompositions === true;
|
||||
const fileViewerEnabled = instanceExperimentalSettings?.enableExperimentalFileViewer === true;
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
companyId: selectedCompanyId,
|
||||
|
|
@ -2759,7 +2786,9 @@ export function IssueDetail() {
|
|||
// Scroll to top on forward navigation (PUSH/REPLACE) so issue doesn't
|
||||
// inherit the inbox/issues-list scroll position on mobile.
|
||||
useEffect(() => {
|
||||
if (navigationType === "POP") return;
|
||||
const previousIssueId = lastScrollIssueIdRef.current;
|
||||
lastScrollIssueIdRef.current = issueId;
|
||||
if (!shouldScrollIssueDetailToTopOnNavigation({ previousIssueId, nextIssueId: issueId, navigationType })) return;
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
const main = document.getElementById("main-content");
|
||||
if (main) main.scrollTop = 0;
|
||||
|
|
@ -2923,6 +2952,12 @@ export function IssueDetail() {
|
|||
setDetailTab("chat");
|
||||
setPendingCommentComposerFocusKey((current) => current + 1);
|
||||
}
|
||||
if (action === "open_file_viewer") {
|
||||
if (!fileViewerEnabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFileViewerPromptOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
|
|
@ -2934,7 +2969,7 @@ export function IssueDetail() {
|
|||
document.removeEventListener("focusin", handleFocusIn, true);
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
|
||||
}, [fileViewerEnabled, keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
|
|
@ -2982,6 +3017,20 @@ export function IssueDetail() {
|
|||
commentComposerRef.current?.focus();
|
||||
}, [detailTab, pendingCommentComposerFocusKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileViewerEnabled) return;
|
||||
const handleOpenFileViewer = () => {
|
||||
setFileViewerPromptOpen(true);
|
||||
};
|
||||
window.addEventListener("paperclip:open-file-viewer", handleOpenFileViewer as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"paperclip:open-file-viewer",
|
||||
handleOpenFileViewer as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fileViewerEnabled]);
|
||||
|
||||
const promotedOutputAttachmentIds = useMemo(() => getPromotedOutputAttachmentIds(workProducts), [workProducts]);
|
||||
const attachmentList = useMemo(
|
||||
() => (attachments ?? []).filter((attachment) => !promotedOutputAttachmentIds.has(attachment.id)),
|
||||
|
|
@ -3423,6 +3472,7 @@ export function IssueDetail() {
|
|||
);
|
||||
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<div className="max-w-3xl space-y-6">
|
||||
{/* Parent chain breadcrumb */}
|
||||
{ancestors.length > 0 && (
|
||||
|
|
@ -3672,6 +3722,17 @@ export function IssueDetail() {
|
|||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{fileViewerEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setFileViewerPromptOpen(true)}
|
||||
title="Open file... (g f)"
|
||||
aria-label="Open file in this issue"
|
||||
>
|
||||
<FileCode2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
|
|
@ -3996,8 +4057,35 @@ export function IssueDetail() {
|
|||
issue={issue}
|
||||
project={resolvedProject}
|
||||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
|
||||
onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
{fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
|
||||
const workProductsWithFileRefs = issue.workProducts
|
||||
.map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) }))
|
||||
.filter(({ fileRef }) => fileRef !== null);
|
||||
|
||||
if (workProductsWithFileRefs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Artifacts</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{workProductsWithFileRefs.map(({ product, fileRef }) => (
|
||||
<ArtifactFileChip
|
||||
key={product.id}
|
||||
workspaceFileRef={fileRef!}
|
||||
title={product.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
|
|
@ -4314,7 +4402,47 @@ export function IssueDetail() {
|
|||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{fileViewerEnabled ? (
|
||||
<IssueFileViewer
|
||||
issueId={issue.id}
|
||||
companyId={issue.companyId}
|
||||
promptOpen={fileViewerPromptOpen}
|
||||
onPromptOpenChange={setFileViewerPromptOpen}
|
||||
/>
|
||||
) : null}
|
||||
<ScrollToBottom />
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueFileViewer({
|
||||
issueId,
|
||||
companyId,
|
||||
promptOpen,
|
||||
onPromptOpenChange,
|
||||
}: {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
promptOpen: boolean;
|
||||
onPromptOpenChange: (next: boolean) => void;
|
||||
}) {
|
||||
const viewer = useRequiredFileViewer();
|
||||
const open = viewer.state !== null || viewer.browse || promptOpen;
|
||||
const showPromptWhenEmpty = (promptOpen || viewer.browse) && viewer.state === null;
|
||||
return (
|
||||
<FileViewerSheet
|
||||
issueId={issueId}
|
||||
companyId={companyId}
|
||||
open={open}
|
||||
showPromptWhenEmpty={showPromptWhenEmpty}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
onPromptOpenChange(false);
|
||||
// Clears any file view and browse state from the URL.
|
||||
viewer.close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,523 @@
|
|||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import { FileViewerProvider } from "@/context/FileViewerContext";
|
||||
import { FileViewerSheet } from "@/components/FileViewerSheet";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const ISSUE_ID = "issue-storybook-file-viewer";
|
||||
|
||||
const SAMPLE_CODE = `import { useMemo } from "react";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FileViewerSheetProps {
|
||||
issueId: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a right-side sheet that previews a workspace file.
|
||||
* The viewer is URL-addressable via ?file=&line=&column=&workspace=.
|
||||
*/
|
||||
export function FileViewerSheet({
|
||||
issueId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FileViewerSheetProps) {
|
||||
const isOpen = typeof open === "boolean" ? open : true;
|
||||
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
"flex h-full w-full flex-col gap-0 p-0",
|
||||
"sm:max-w-[min(900px,90vw)]",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className={classes}>
|
||||
{/* header, metadata chips, content region */}
|
||||
<p>Workspace file contents would render here.</p>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
function buildResource(
|
||||
overrides: Partial<ResolvedWorkspaceResource> = {},
|
||||
): ResolvedWorkspaceResource {
|
||||
return {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: "FileViewerSheet.tsx",
|
||||
displayPath: "ui/src/components/FileViewerSheet.tsx",
|
||||
workspaceLabel: "PAP-1953 isolated worktree",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-exec-pap-1953",
|
||||
contentType: "text/tsx",
|
||||
byteSize: SAMPLE_CODE.length,
|
||||
previewKind: "text",
|
||||
denialReason: null,
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContent(
|
||||
resource: ResolvedWorkspaceResource,
|
||||
data = SAMPLE_CODE,
|
||||
): WorkspaceFileContent {
|
||||
return {
|
||||
resource,
|
||||
content: { encoding: "utf8", data },
|
||||
};
|
||||
}
|
||||
|
||||
function StoryFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background text-foreground">
|
||||
<div className="mx-auto max-w-3xl space-y-4 p-6 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Host issue page (preview)
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
PAP-1963 — Phase 4: UX visual review file viewer implementation
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This frame represents the underlying issue detail page so the sheet
|
||||
overlay can be evaluated in context.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 p-4 text-xs text-muted-foreground">
|
||||
The workspace file viewer opens as a right-side sheet, pushing a URL
|
||||
entry. Use browser back to close.
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HydratorProps {
|
||||
seed?: (client: ReturnType<typeof useQueryClient>) => void;
|
||||
stubFetch?: () => (() => void);
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FileViewerHydrator({ seed, stubFetch, children }: HydratorProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [ready] = useState(() => {
|
||||
seed?.(queryClient);
|
||||
return true;
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!stubFetch) return;
|
||||
const teardown = stubFetch();
|
||||
return () => teardown();
|
||||
}, [stubFetch]);
|
||||
|
||||
return ready ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
type ViewerRenderProps = {
|
||||
path: string;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
workspace?: WorkspaceFileSelector;
|
||||
showPromptWhenEmpty?: boolean;
|
||||
};
|
||||
|
||||
function FileViewerShell({
|
||||
path,
|
||||
line = null,
|
||||
column = null,
|
||||
workspace = "auto",
|
||||
showPromptWhenEmpty = false,
|
||||
seed,
|
||||
stubFetch,
|
||||
}: ViewerRenderProps & Omit<HydratorProps, "children">) {
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<FileViewerHydrator seed={seed} stubFetch={stubFetch}>
|
||||
<StoryFrame>
|
||||
<FileViewerSheet
|
||||
issueId={ISSUE_ID}
|
||||
state={
|
||||
showPromptWhenEmpty
|
||||
? null
|
||||
: { path, line, column, workspace, projectId: null, workspaceId: null }
|
||||
}
|
||||
showPromptWhenEmpty={showPromptWhenEmpty}
|
||||
open
|
||||
/>
|
||||
</StoryFrame>
|
||||
</FileViewerHydrator>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function seedContent(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
path: string,
|
||||
workspace: WorkspaceFileSelector,
|
||||
resource: ResolvedWorkspaceResource,
|
||||
content: WorkspaceFileContent | null,
|
||||
) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResource(ISSUE_ID, { path, workspace }),
|
||||
resource,
|
||||
);
|
||||
if (content) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResourceContent(ISSUE_ID, { path, workspace }),
|
||||
content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchStub(
|
||||
resolver: (url: URL) => Response | Promise<Response> | null,
|
||||
) {
|
||||
return () => {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const rawUrl =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
const url = new URL(rawUrl, window.location.origin);
|
||||
const maybe = resolver(url);
|
||||
if (maybe) return maybe;
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Components/File Viewer Sheet",
|
||||
component: FileViewerSheet,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
a11y: { test: "todo" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const TextContentWithHighlightedLine: Story = {
|
||||
name: "Text content — highlighted line",
|
||||
render: () => {
|
||||
const resource = buildResource();
|
||||
const content = buildContent(resource);
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
line={17}
|
||||
column={3}
|
||||
workspace="execution"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/src/components/FileViewerSheet.tsx",
|
||||
"execution",
|
||||
resource,
|
||||
content,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TextContentLongPathTruncation: Story = {
|
||||
name: "Text content — very long path",
|
||||
render: () => {
|
||||
const longPath =
|
||||
"packages/backend/src/features/workspaces/resolvers/resolve-workspace-file-resource-from-issue-context.ts";
|
||||
const resource = buildResource({
|
||||
title: "resolve-workspace-file-resource-from-issue-context.ts",
|
||||
displayPath: longPath,
|
||||
});
|
||||
const content = buildContent(resource, SAMPLE_CODE);
|
||||
return (
|
||||
<FileViewerShell
|
||||
path={longPath}
|
||||
line={42}
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(client, longPath, "auto", resource, content)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenFilePrompt: Story = {
|
||||
name: "Open file prompt (no selection)",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path=""
|
||||
workspace="auto"
|
||||
showPromptWhenEmpty
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const LoadingSpinner: Story = {
|
||||
name: "Loading — 400ms+ spinner",
|
||||
render: () => {
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/")) {
|
||||
return new Promise<Response>(() => {
|
||||
/* never resolves — freezes query in pending */
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorNotFoundWithFallback: Story = {
|
||||
name: "Error — file not found (project fallback)",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="missing/area/not-found.tsx"
|
||||
workspace="execution"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{ error: "No file exists at that path.", code: "not_found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorNoWorkspace: Story = {
|
||||
name: "Error — no workspace",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "This issue has no project or execution workspace.",
|
||||
code: "no_workspace",
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorOutsideWorkspace: Story = {
|
||||
name: "Error — path outside workspace",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="../../../etc/passwd"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Path escapes workspace root.",
|
||||
code: "outside_workspace_root",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorDeniedSensitive: Story = {
|
||||
name: "Error — denied by policy",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path=".env.production"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Blocked by workspace file policy.",
|
||||
code: "denied_by_policy_sensitive",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RemoteWorkspace: Story = {
|
||||
name: "Remote workspace (preview unsupported)",
|
||||
render: () => {
|
||||
const resource = buildResource({
|
||||
kind: "remote_resource",
|
||||
provider: "remote_managed",
|
||||
workspaceLabel: "Cloud worker (remote)",
|
||||
workspaceKind: "execution_workspace",
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: false },
|
||||
});
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/src/components/FileViewerSheet.tsx",
|
||||
"auto",
|
||||
resource,
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceArchived: Story = {
|
||||
name: "Workspace archived / cleaned up",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="execution"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "This worktree has been cleaned up.",
|
||||
code: "workspace_archived",
|
||||
},
|
||||
{ status: 410 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const BinaryUnsupported: Story = {
|
||||
name: "Binary / unsupported file type",
|
||||
render: () => {
|
||||
const resource = buildResource({
|
||||
title: "logo.woff2",
|
||||
displayPath: "ui/public/fonts/logo.woff2",
|
||||
contentType: "font/woff2",
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: false },
|
||||
denialReason: "binary_unsupported",
|
||||
});
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/public/fonts/logo.woff2"
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/public/fonts/logo.woff2",
|
||||
"auto",
|
||||
resource,
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TooLargeToPreview: Story = {
|
||||
name: "Too large to preview",
|
||||
render: () => {
|
||||
const resource = buildResource({
|
||||
title: "dataset.csv",
|
||||
displayPath: "data/exports/dataset.csv",
|
||||
contentType: "text/csv",
|
||||
byteSize: 48_000_000,
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: false },
|
||||
denialReason: "too_large",
|
||||
});
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="data/exports/dataset.csv"
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(client, "data/exports/dataset.csv", "auto", resource, null)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileView: Story = {
|
||||
name: "Mobile — 390×844 highlighted line",
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: "mobile",
|
||||
},
|
||||
},
|
||||
render: () => {
|
||||
const resource = buildResource();
|
||||
const content = buildContent(resource);
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
line={17}
|
||||
column={3}
|
||||
workspace="execution"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/src/components/FileViewerSheet.tsx",
|
||||
"execution",
|
||||
resource,
|
||||
content,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileListFileItem,
|
||||
WorkspaceFileListResponse,
|
||||
} from "@paperclipai/shared";
|
||||
import { FileViewerProvider, useRequiredFileViewer } from "@/context/FileViewerContext";
|
||||
import { FileViewerSheet } from "@/components/FileViewerSheet";
|
||||
import { IssueWorkspaceCard } from "@/components/IssueWorkspaceCard";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Screenshot-review surface for PAP-10511, rendering the REAL FileViewerSheet /
|
||||
* IssueWorkspaceCard (not a static frame) so the captures are visual truth.
|
||||
* Stories seed the react-query cache so the components render without a backend.
|
||||
* Capture each at desktop + 360px and in both themes via the Storybook theme global.
|
||||
*/
|
||||
|
||||
const ISSUE_ID = "issue-browse-demo";
|
||||
|
||||
function listKey(issueId: string) {
|
||||
return queryKeys.issues.fileResources(issueId, { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 });
|
||||
}
|
||||
|
||||
function item(relativePath: string, minutesAgo: number, overrides: Partial<WorkspaceFileListFileItem> = {}): WorkspaceFileListFileItem {
|
||||
return {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: relativePath.split("/").pop() ?? relativePath,
|
||||
relativePath,
|
||||
displayPath: relativePath,
|
||||
workspaceLabel: "issue execution workspace · PAP-1953",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 2048,
|
||||
modifiedAt: new Date(Date.now() - minutesAgo * 60_000).toISOString(),
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const recentList: WorkspaceFileListResponse = {
|
||||
kind: "workspace_file_list",
|
||||
state: "available",
|
||||
workspace: {
|
||||
provider: "git_worktree",
|
||||
workspaceLabel: "issue execution workspace · PAP-1953",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
},
|
||||
query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 },
|
||||
items: [
|
||||
item("ui/src/components/WorkspaceFileBrowser.tsx", 2),
|
||||
item("ui/src/components/FileViewerSheet.tsx", 2),
|
||||
item("server/src/routes/file-resources.ts", 14),
|
||||
item("doc/PRODUCT.md", 60),
|
||||
item("packages/shared/src/types/workspace-file-resource.ts", 64),
|
||||
item("server/src/services/very/deeply/nested/structure/workspace-file-resources.ts", 180),
|
||||
],
|
||||
scannedCount: 412,
|
||||
truncated: true,
|
||||
};
|
||||
|
||||
function unavailable(reason: string): WorkspaceFileListResponse {
|
||||
return {
|
||||
kind: "workspace_file_list",
|
||||
state: "unavailable",
|
||||
unavailableReason: reason,
|
||||
workspace: null,
|
||||
query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 },
|
||||
items: [],
|
||||
scannedCount: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const viewedResource: ResolvedWorkspaceResource = {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: "FileViewerSheet.tsx",
|
||||
displayPath: "ui/src/components/FileViewerSheet.tsx",
|
||||
workspaceLabel: "issue execution workspace · PAP-1953",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-1",
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
byteSize: 2048,
|
||||
previewKind: "text",
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
};
|
||||
|
||||
const viewedContent: WorkspaceFileContent = {
|
||||
resource: viewedResource,
|
||||
content: {
|
||||
encoding: "utf8",
|
||||
data: [
|
||||
"export function FileViewerSheet({ issueId, state, open }: FileViewerSheetProps) {",
|
||||
" const viewer = useRequiredFileViewer();",
|
||||
" const browseMode = state === null && (showPromptWhenEmpty || viewer.browse);",
|
||||
" const cameFromBrowse = state !== null && viewer.browse;",
|
||||
" // … single sheet, two modes: browse ⇄ view",
|
||||
" return <Sheet open={open}>…</Sheet>;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
|
||||
function BrowseSheet({ data }: { data: WorkspaceFileListResponse }) {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(listKey(ISSUE_ID), data);
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<FileViewerSheet issueId={ISSUE_ID} showPromptWhenEmpty />
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewFromBrowseInner() {
|
||||
const viewer = useRequiredFileViewer();
|
||||
useEffect(() => {
|
||||
viewer.open(
|
||||
{ path: "ui/src/components/FileViewerSheet.tsx", line: 3, column: null, workspace: "auto" },
|
||||
{ fromBrowse: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <FileViewerSheet issueId={ISSUE_ID} />;
|
||||
}
|
||||
|
||||
function ViewFromBrowse() {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(listKey(ISSUE_ID), recentList);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResource(ISSUE_ID, {
|
||||
path: "ui/src/components/FileViewerSheet.tsx",
|
||||
workspace: "auto",
|
||||
}),
|
||||
viewedResource,
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResourceContent(ISSUE_ID, {
|
||||
path: "ui/src/components/FileViewerSheet.tsx",
|
||||
workspace: "auto",
|
||||
}),
|
||||
viewedContent,
|
||||
);
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<ViewFromBrowseInner />
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Placement() {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<div className="mx-auto max-w-3xl space-y-3 p-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Workspace</h3>
|
||||
<IssueWorkspaceCard
|
||||
issue={{
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "pw-1",
|
||||
executionWorkspaceId: "ws-1",
|
||||
executionWorkspacePreference: "isolated_workspace",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace", environmentId: null },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
currentExecutionWorkspace: {
|
||||
id: "ws-1",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "PAP-1953-plan-a-file-viewer",
|
||||
cwd: "/srv/paperclip/.../worktrees/PAP-1953",
|
||||
repoUrl: null,
|
||||
projectWorkspaceId: "pw-1",
|
||||
name: "PAP-1953",
|
||||
config: {},
|
||||
} as any,
|
||||
}}
|
||||
project={{ id: "project-1", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } }}
|
||||
onUpdate={() => {}}
|
||||
onBrowseFiles={() => {}}
|
||||
onOpenFileByPath={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Issue/Workspace File Browser",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const BrowseRecent: Story = { render: () => <BrowseSheet data={recentList} /> };
|
||||
export const BrowseNoWorkspace: Story = { render: () => <BrowseSheet data={unavailable("no_workspace")} /> };
|
||||
export const BrowseRemoteWorkspace: Story = { render: () => <BrowseSheet data={unavailable("remote_workspace")} /> };
|
||||
export const BrowseCleanedUpWorkspace: Story = { render: () => <BrowseSheet data={unavailable("workspace_unavailable")} /> };
|
||||
export const ViewModeFromBrowse: Story = { render: () => <ViewFromBrowse /> };
|
||||
export const CardPlacement: Story = { render: () => <Placement /> };
|
||||
Loading…
Reference in New Issue