mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02: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>
201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|
import { fileResourcesApi } from "@/api/file-resources";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import {
|
|
chunkWorkspaceFileAvailabilityRefs,
|
|
workspaceFileAvailabilityFromResult,
|
|
workspaceFileAvailabilityKey,
|
|
WORKSPACE_FILE_AVAILABILITY_PENDING,
|
|
WORKSPACE_FILE_AVAILABILITY_STALE_MS,
|
|
type WorkspaceFileAvailability,
|
|
type WorkspaceFileAvailabilityRef,
|
|
} from "@/lib/workspace-file-availability";
|
|
|
|
type QueuedRef = readonly [key: string, ref: WorkspaceFileAvailabilityRef];
|
|
type QueuedBatch = {
|
|
chunk: QueuedRef[];
|
|
issueId: string;
|
|
generation: number;
|
|
};
|
|
|
|
/** Matches the server's per-actor, per-issue availability concurrency limit. */
|
|
const WORKSPACE_FILE_AVAILABILITY_MAX_CONCURRENT = 2;
|
|
|
|
export interface WorkspaceFileAvailabilityRegistry {
|
|
/**
|
|
* Bumped whenever known results change. Consumers that memoize on the
|
|
* registry (remark plugins) must include it so a completed batch re-renders.
|
|
*/
|
|
version: number;
|
|
/**
|
|
* Read the availability of a reference, queueing a batched check the first
|
|
* time it is seen. Safe to call during render: it only mutates internal
|
|
* queues and schedules the request for the next macrotask.
|
|
*/
|
|
check(ref: WorkspaceFileAvailabilityRef): WorkspaceFileAvailability;
|
|
}
|
|
|
|
function isFileResourceKeyForIssue(queryKey: readonly unknown[], issueId: string) {
|
|
return queryKey[0] === "issues" && queryKey[1] === "file-resources" && queryKey[2] === issueId;
|
|
}
|
|
|
|
/**
|
|
* Issue-scoped registry of workspace-file availability results.
|
|
*
|
|
* References discovered while rendering markdown are deduplicated by key,
|
|
* coalesced into a single request per event-loop burst, chunked only above the
|
|
* server's 100-query cap, and cached on the shared file-resource query key so
|
|
* an invalidation of the issue's file resources forces a recheck.
|
|
*/
|
|
export function useWorkspaceFileAvailability(issueId: string): WorkspaceFileAvailabilityRegistry {
|
|
const queryClient = useQueryClient();
|
|
const [version, setVersion] = useState(0);
|
|
const resultsRef = useRef<Map<string, WorkspaceFileAvailability>>(new Map());
|
|
const queueRef = useRef<Map<string, WorkspaceFileAvailabilityRef>>(new Map());
|
|
const inFlightRef = useRef<Set<string>>(new Set());
|
|
const batchQueueRef = useRef<QueuedBatch[]>([]);
|
|
const activeBatchCountRef = useRef(0);
|
|
const flushHandleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const activeRef = useRef(true);
|
|
const generationRef = useRef(0);
|
|
const clientRef = useRef<QueryClient>(queryClient);
|
|
clientRef.current = queryClient;
|
|
|
|
// Reset during render rather than in an effect: children call `check()` while
|
|
// rendering, so an effect-based reset would discard the queue they just
|
|
// filled. A new issue scope must not inherit another issue's confirmations.
|
|
const issueIdRef = useRef(issueId);
|
|
if (issueIdRef.current !== issueId) {
|
|
issueIdRef.current = issueId;
|
|
generationRef.current += 1;
|
|
if (flushHandleRef.current !== null) {
|
|
clearTimeout(flushHandleRef.current);
|
|
flushHandleRef.current = null;
|
|
}
|
|
resultsRef.current.clear();
|
|
queueRef.current.clear();
|
|
inFlightRef.current.clear();
|
|
batchQueueRef.current = [];
|
|
}
|
|
|
|
const bump = useCallback(() => {
|
|
if (!activeRef.current) return;
|
|
setVersion((current) => current + 1);
|
|
}, []);
|
|
|
|
const runBatch = useCallback(
|
|
async ({ chunk, issueId: batchIssueId, generation }: QueuedBatch) => {
|
|
// Sorted keys give identical batches one cache entry regardless of the
|
|
// order the refs happened to be rendered in.
|
|
const refKeys = chunk.map(([key]) => key).sort();
|
|
try {
|
|
const response = await clientRef.current.fetchQuery({
|
|
queryKey: queryKeys.issues.fileResourceAvailability(batchIssueId, refKeys),
|
|
queryFn: () => fileResourcesApi.availability(batchIssueId, chunk.map(([, ref]) => ref)),
|
|
staleTime: WORKSPACE_FILE_AVAILABILITY_STALE_MS,
|
|
});
|
|
if (generation !== generationRef.current) return;
|
|
const byKey = new Map(
|
|
response.results.map((result) => [
|
|
workspaceFileAvailabilityKey(result.query),
|
|
workspaceFileAvailabilityFromResult(result),
|
|
]),
|
|
);
|
|
for (const [key] of chunk) {
|
|
// A reference the server did not echo back stays non-openable rather
|
|
// than falling through to a provisional link.
|
|
resultsRef.current.set(key, byKey.get(key) ?? { state: "unavailable", reason: "unmatched_reference" });
|
|
inFlightRef.current.delete(key);
|
|
}
|
|
bump();
|
|
} catch {
|
|
if (generation !== generationRef.current) return;
|
|
// Fail closed: the refs stay unresolved and render as plain inline code.
|
|
// Releasing them from the in-flight set lets a later render burst retry
|
|
// without looping, since a failure never triggers a re-render itself.
|
|
for (const [key] of chunk) inFlightRef.current.delete(key);
|
|
}
|
|
},
|
|
[bump],
|
|
);
|
|
|
|
const drainBatchQueue = useCallback(() => {
|
|
while (
|
|
activeBatchCountRef.current < WORKSPACE_FILE_AVAILABILITY_MAX_CONCURRENT
|
|
&& batchQueueRef.current.length > 0
|
|
) {
|
|
const batch = batchQueueRef.current.shift()!;
|
|
activeBatchCountRef.current += 1;
|
|
void runBatch(batch).finally(() => {
|
|
activeBatchCountRef.current -= 1;
|
|
drainBatchQueue();
|
|
});
|
|
}
|
|
}, [runBatch]);
|
|
|
|
const flush = useCallback(() => {
|
|
flushHandleRef.current = null;
|
|
const queued = [...queueRef.current.entries()] as QueuedRef[];
|
|
queueRef.current.clear();
|
|
const fresh = queued.filter(([key]) => !inFlightRef.current.has(key) && !resultsRef.current.has(key));
|
|
if (fresh.length === 0) return;
|
|
for (const [key] of fresh) inFlightRef.current.add(key);
|
|
batchQueueRef.current.push(
|
|
...chunkWorkspaceFileAvailabilityRefs(fresh).map((chunk) => ({
|
|
chunk,
|
|
issueId,
|
|
generation: generationRef.current,
|
|
})),
|
|
);
|
|
drainBatchQueue();
|
|
}, [drainBatchQueue, issueId]);
|
|
|
|
const check = useCallback<WorkspaceFileAvailabilityRegistry["check"]>(
|
|
(ref) => {
|
|
const key = workspaceFileAvailabilityKey(ref);
|
|
const known = resultsRef.current.get(key);
|
|
if (known) return known;
|
|
if (!inFlightRef.current.has(key) && !queueRef.current.has(key)) {
|
|
queueRef.current.set(key, ref);
|
|
if (flushHandleRef.current === null) {
|
|
flushHandleRef.current = setTimeout(flush, 0);
|
|
}
|
|
}
|
|
return WORKSPACE_FILE_AVAILABILITY_PENDING;
|
|
},
|
|
[flush],
|
|
);
|
|
|
|
useEffect(() => {
|
|
activeRef.current = true;
|
|
// A remount (StrictMode, suspense replay) tears down the timer scheduled by
|
|
// the render that filled the queue; reschedule so those refs aren't stranded.
|
|
if (queueRef.current.size > 0 && flushHandleRef.current === null) {
|
|
flushHandleRef.current = setTimeout(flush, 0);
|
|
}
|
|
return () => {
|
|
activeRef.current = false;
|
|
if (flushHandleRef.current !== null) {
|
|
clearTimeout(flushHandleRef.current);
|
|
flushHandleRef.current = null;
|
|
}
|
|
};
|
|
}, [flush]);
|
|
|
|
// Recheck when the issue's file resources are invalidated (workspace changed,
|
|
// run finished, viewer refreshed). Only `invalidate` actions are handled so
|
|
// writing our own batch results cannot loop.
|
|
useEffect(() => {
|
|
return queryClient.getQueryCache().subscribe((event) => {
|
|
if (event.type !== "updated" || event.action.type !== "invalidate") return;
|
|
if (!isFileResourceKeyForIssue(event.query.queryKey, issueId)) return;
|
|
if (resultsRef.current.size === 0) return;
|
|
resultsRef.current.clear();
|
|
bump();
|
|
});
|
|
}, [bump, issueId, queryClient]);
|
|
|
|
return useMemo(() => ({ version, check }), [version, check]);
|
|
}
|