feat: search the server from the Asset Picker without shrinking preview's catalog

The Picker asked for `{ managementStatus: "READY", limit: 50 }` and
ignored `nextCursor`, so the 51st-oldest READY asset onward could not be
inserted at all. It sits inside the editing flow, where scrolling a long
list is the wrong interaction, so it gets search rather than a "더 보기"
control -- and it still loads a first page, because an empty panel until
you type is hostile to an author reaching for the asset they uploaded a
minute ago.

The trap this creates is the substance of the change. The editor screen's
Asset array feeds two consumers with opposite needs: the Picker's
*displayed* list, which a search must narrow, and Instant Preview's
*resolution catalog*, which a search must never narrow -- its gate
rejects any key no loaded asset backs. Handing search results straight to
the screen's `setAssets` would blank previously-inserted evidence figures
the moment the author typed a query.

They are kept apart by making the screen's callback additive by
construction rather than by convention: `mergeAssetCatalog` (domain,
beside `findResolvableAsset`) can only grow the set, and both writers --
observed pages and fresh uploads -- go through it. The prop is renamed
`onAssetsObserved` so the contract reads as "what the Picker saw", not
"what to show"; the replacing version was one `setAssets` reference away
and looked correct.

A key backed by neither the first page nor the current results is still
unresolvable. That is the known deferred limitation, not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 13:16:31 +09:00
co-authored by Claude Opus 5
parent a5825c18b5
commit f69edb633d
7 changed files with 428 additions and 33 deletions
@@ -1,8 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useId, useState, type FormEvent } from "react";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
const PAGE_SIZE = 50;
/**
* A directive attribute value is delimited by double quotes (see
* `parse-case-content.ts`'s `attributesOf`), so a value containing one would
@@ -15,6 +18,10 @@ function attributeValue(raw: string): string {
return raw.replaceAll('"', "").replace(/\s+/gu, " ").trim();
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function buildEvidenceDirective(
input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>,
): string {
@@ -27,45 +34,100 @@ export function buildEvidenceDirective(
export function AssetPicker({
gateway,
onInsert,
onLoaded,
onAssetsObserved,
}: Readonly<{
gateway: StudioAssetGateway;
onInsert: (directive: string) => void;
/**
* Optional so Task 10 Step 1's original test (which renders `AssetPicker`
* without it) keeps passing. When supplied, the editor screen uses this to
* own the same Asset list the Instant Preview resolver reads -- so a
* directive this Picker just inserted renders immediately instead of as a
* placeholder.
* Every Asset this Picker has *seen* -- the first page, and each search's
* results -- reported as it arrives. Deliberately not "the Assets to show":
* the editor screen merges these into a resolution catalog that only grows
* (`mergeAssetCatalog`), because Instant Preview resolves `:::evidence`
* directives against that catalog and a narrowing search must never make an
* already-inserted figure stop rendering. Optional so Task 10 Step 1's
* original test (which renders `AssetPicker` on its own) keeps passing.
*/
onLoaded?: (assets: readonly Asset[]) => void;
onAssetsObserved?: (assets: readonly Asset[]) => void;
}>) {
const [assets, setAssets] = useState<readonly Asset[]>([]);
const [failed, setFailed] = useState(false);
const [status, setStatus] = useState<"LOADING" | "ERROR" | "READY">("LOADING");
// Alignment follow-up, item 2. `searchDraft` is what the author is typing,
// `q` the query actually submitted -- only `q` is an effect dependency. That
// is a stronger guarantee than debouncing (zero requests while typing rather
// than fewer, and no trailing request after the author stops), and it is the
// pair `document-list.tsx` already uses for the same job.
const [searchDraft, setSearchDraft] = useState("");
const [q, setQ] = useState("");
const searchId = useId();
useEffect(() => {
const controller = new AbortController();
// `RequestOptions.signal` is advisory: an adapter that ignores it still
// resolves, and an abandoned query's response would then replace the newer
// one's results. This flag flips synchronously when `q` changes, so an
// older search can never land last.
let active = true;
setStatus("LOADING");
gateway
.listAssets({ managementStatus: "READY", limit: 50 }, { signal: controller.signal })
.listAssets(
{ managementStatus: "READY", ...(q ? { q } : {}), limit: PAGE_SIZE },
{ signal: controller.signal },
)
.then((page) => {
if (!active) return;
setAssets(page.items);
onLoaded?.(page.items);
setStatus("READY");
onAssetsObserved?.(page.items);
})
.catch(() => setFailed(true));
return () => controller.abort();
}, [gateway, onLoaded]);
.catch((error: unknown) => {
if (!active || isAbortError(error)) return;
setStatus("ERROR");
});
return () => {
active = false;
controller.abort();
};
}, [gateway, onAssetsObserved, q]);
// READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다.
// A query does not relax this: an asset under review is never insertable,
// however it was found.
const selectable = assets.filter((asset) => asset.managementStatus === "READY");
if (failed) return <p className="studio-error">Asset .</p>;
const submitSearch = (event: FormEvent) => {
event.preventDefault();
setQ(searchDraft.trim());
};
if (selectable.length === 0) {
return <p className="studio-asset-picker-empty"> Asset이 . .</p>;
}
const listMessage = status === "LOADING"
? "Asset 목록을 불러오는 중입니다."
: selectable.length > 0
? `삽입할 수 있는 Asset ${selectable.length}`
: q
? "검색 결과가 없습니다. 다른 검색어를 입력하세요."
: "삽입할 수 있는 Asset이 없습니다. 먼저 업로드하세요.";
return <div className="asset-picker">
<ul>
{/* The search box stays mounted through the error and empty states: it is
the only way to reach an asset outside the first page, so removing it
with the list would strand the author on whatever went wrong. */}
<form className="asset-picker-search" role="search" onSubmit={submitSearch}>
<label htmlFor={searchId}>Asset </label>
<div>
<input
id={searchId}
type="search"
value={searchDraft}
placeholder="Asset 키, 파일 이름"
onChange={(event) => setSearchDraft(event.target.value)}
/>
<button type="submit"></button>
</div>
</form>
{status === "ERROR"
? <p className="studio-error" role="alert">Asset .</p>
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
{selectable.length > 0 ? <ul>
{selectable.map((asset) => <li key={asset.id}>
<button
type="button"
@@ -79,6 +141,6 @@ export function AssetPicker({
{asset.assetKey}
</button>
</li>)}
</ul>
</ul> : null}
</div>;
}