mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task comments can contain references to files in project and execution workspaces. > - Paperclip detected path-shaped inline code and showed it as an actionable file chip. > - The UI did not first confirm that the current board session could open the file. > - Missing, denied, ambiguous, remote, and unsupported files therefore looked actionable and failed after a click. > - This pull request adds an issue-scoped availability check and promotes only confirmed files to chips. > - The benefit is that the task thread shows a file action only when that action can succeed. ## Linked Issues or Issue Description **What happened?** Task comments promoted path-shaped inline code to file chips before Paperclip checked the file. A chip could point to a missing, denied, ambiguous, remote, or non-previewable file. The action then failed after the user selected it. **Expected behavior** Paperclip must show a file chip only after the server confirms that the current board session can open the exact file reference. All other path-shaped text must stay ordinary inline code. **Steps to reproduce** 1. Add a task comment that contains inline code with a missing or inaccessible workspace path. 2. Open the task thread as a board user. 3. Observe that the path looks like an actionable file chip. 4. Select the chip and observe that the file cannot open. **Paperclip version or commit** `19be4cf927` and earlier. **Deployment mode** Local dev and self-hosted server. **Access context** Board user. ## What Changed - Added shared request, response, and validation contracts for batched workspace-file availability checks. - Added an issue-scoped server endpoint that resolves file references with company, issue, workspace, and preview-access checks. - Added bounded batch concurrency and tests for missing, denied, ambiguous, remote, unsupported, and available files. - Added an issue-scoped UI availability registry that deduplicates, batches, caches, and invalidates file checks. - Changed task-comment markdown rendering so only confirmed files get chip styling and file-viewer behavior. - Bound each chip to the exact workspace target that passed the availability check. ## Verification - `pnpm exec vitest run packages/shared/src/workspace-file-resource.test.ts server/src/__tests__/file-resources.test.ts ui/src/components/MarkdownBody.test.tsx ui/src/components/WorkspaceFileMarkdownBody.availability.test.tsx ui/src/lib/remark-workspace-file-refs.test.ts ui/src/lib/workspace-file-availability.test.ts` — 93 passed, 35 skipped. - `pnpm check:token-gates` — clean. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — all server and UI groups passed. One unchanged CLI test saw the run-injected static AWS credentials and expected only its local `AWS_PROFILE`. The same test passed, 8 of 8, after removing only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` from its process environment. ## Risks - File chips now appear after an asynchronous availability check, so path-shaped text can briefly render as inline code. - Availability results use the existing 30-second file-resource cache window. File-resource invalidation forces a new check. - The endpoint limits each request to 100 references and the client chunks larger sets. > 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 with GPT-5. The service did not expose a more specific model ID or context-window size. The agent used high-reasoning mode, repository tools, command execution, and test execution. ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [ ] 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>
161 lines
6.2 KiB
TypeScript
161 lines
6.2 KiB
TypeScript
import type { WorkspaceFileSelector } from "@paperclipai/shared";
|
|
import { parseWorkspaceFileRef, type ParsedWorkspaceFileRef } from "./workspace-file-parser";
|
|
import type { WorkspaceFileAvailabilityTarget } from "./workspace-file-availability";
|
|
|
|
const WORKSPACE_FILE_HREF_SCHEME = "workspace-file:";
|
|
|
|
/**
|
|
* Decides whether a syntactically path-shaped reference may be promoted to a
|
|
* workspace-file link. Returning null keeps the original markdown node, which
|
|
* is the fail-closed default for pending, unavailable, and errored references.
|
|
*/
|
|
export type WorkspaceFileRefResolver = (
|
|
ref: ParsedWorkspaceFileRef,
|
|
) => WorkspaceFileAvailabilityTarget | null;
|
|
|
|
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.workspace && ref.workspace !== "auto") params.set("workspace", ref.workspace);
|
|
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 workspaceRaw = params.get("workspace");
|
|
const workspace: WorkspaceFileSelector = workspaceRaw === "execution" || workspaceRaw === "project"
|
|
? workspaceRaw
|
|
: "auto";
|
|
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,
|
|
workspace,
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Bind a parsed reference to the workspace that passed preflight so the click
|
|
* reuses that exact target instead of re-running auto discovery.
|
|
*/
|
|
function applyResolvedTarget(
|
|
ref: ParsedWorkspaceFileRef,
|
|
target: WorkspaceFileAvailabilityTarget,
|
|
): ParsedWorkspaceFileRef {
|
|
return {
|
|
...ref,
|
|
workspace: target.workspace,
|
|
projectId: target.projectId ?? ref.projectId ?? null,
|
|
workspaceId: target.workspaceId ?? ref.workspaceId ?? null,
|
|
projectName: ref.projectName ?? null,
|
|
};
|
|
}
|
|
|
|
/** Returns the target-bound ref when the resolver confirms it is openable. */
|
|
function openableRef(
|
|
ref: ParsedWorkspaceFileRef,
|
|
resolve: WorkspaceFileRefResolver,
|
|
): ParsedWorkspaceFileRef | null {
|
|
const target = resolve(ref);
|
|
return target ? applyResolvedTarget(ref, target) : null;
|
|
}
|
|
|
|
function rewriteMarkdownTree(node: MarkdownNode, resolve: WorkspaceFileRefResolver) {
|
|
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
|
// Existing links whose whole label is a workspace-file code span become
|
|
// file-viewer links instead of issue/external links — but only when the
|
|
// viewer can actually open them. Otherwise the ordinary link is preserved.
|
|
if (node.type === "link") {
|
|
const ref = parseSingleInlineCodeFileRef(node);
|
|
const resolved = ref ? openableRef(ref, resolve) : null;
|
|
if (resolved) {
|
|
node.url = buildWorkspaceFileHref(resolved);
|
|
}
|
|
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);
|
|
const resolved = ref ? openableRef(ref, resolve) : null;
|
|
if (resolved) {
|
|
nextChildren.push(createWorkspaceFileLinkNode(resolved));
|
|
continue;
|
|
}
|
|
}
|
|
rewriteMarkdownTree(child, resolve);
|
|
nextChildren.push(child);
|
|
}
|
|
node.children = nextChildren;
|
|
}
|
|
|
|
/**
|
|
* Promote path-shaped inline code to workspace-file links, gated on `resolve`.
|
|
*
|
|
* The resolver doubles as the registration point: it is called exactly once per
|
|
* candidate reference per parse, which is the set the availability registry
|
|
* needs to check.
|
|
*/
|
|
export function createRemarkWorkspaceFileRefs(resolve: WorkspaceFileRefResolver) {
|
|
return function remarkWorkspaceFileRefs() {
|
|
return (tree: MarkdownNode) => {
|
|
rewriteMarkdownTree(tree, resolve);
|
|
};
|
|
};
|
|
}
|
|
|
|
export const WORKSPACE_FILE_HREF_PREFIX = WORKSPACE_FILE_HREF_SCHEME;
|