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:
Dotta 2026-06-09 17:17:43 -05:00 committed by GitHub
parent bf62e3fbf1
commit 468edd8b22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 10816 additions and 82 deletions

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -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,

View File

@ -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,

View File

@ -31,6 +31,7 @@ export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;

View File

@ -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;
}

View File

@ -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,

View File

@ -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),

View File

@ -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(),
});

View File

@ -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>;

View File

@ -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();
});
});

View File

@ -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);
});

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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",

View File

@ -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();

View File

@ -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));

View File

@ -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.

View File

@ -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;
}

View File

@ -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";

View File

@ -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",

View File

@ -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";

View File

@ -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,

View File

@ -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

View File

@ -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 = {

View File

@ -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`.

View File

@ -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.

View File

@ -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)}`,
);
},
};

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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

View File

@ -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>
);
});

View File

@ -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 () => {

View File

@ -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>
);
}

View File

@ -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"');
});
});

View File

@ -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("//")) {

View File

@ -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

View File

@ -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>
);
}

View File

@ -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");
});
});

View File

@ -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>
);
}

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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");

View File

@ -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";
}

View File

@ -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 }) =>

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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">

View File

@ -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);
});
});

View File

@ -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();
}
}}
/>
);
}

View File

@ -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,
)
}
/>
);
},
};

View File

@ -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 /> };