Files
PaperClipAI/ui/src/hooks/useWorkspaceFileAvailability.ts
T
DottaandPaperclip 7ea2068ef8 fix(files): only highlight accessible workspace file links (#11090)
## 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>
2026-08-11 12:11:45 -04:00

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