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
@@ -63,6 +63,40 @@ function outranks(candidate: ResolvableAsset, incumbent: ResolvableAsset): boole
return candidate.id > incumbent.id;
}
/**
* Grows the set of Assets an evidence key can resolve against, never shrinks
* it.
*
* Alignment follow-up, item 2. The editor screen's Asset array feeds two
* consumers whose needs are opposite: the Picker's *displayed* list, which a
* search must narrow, and Instant Preview's *resolution catalog*, which a
* search must never narrow -- `instantPreviewEvidenceKeyGate` rejects any key
* no loaded Asset backs, so an Asset dropped from the array turns a directive
* that rendered a moment ago into an error panel. Handing the Picker's search
* results straight to `setAssets` would do exactly that: type a query that
* excludes an already-inserted figure and the live preview blanks, purely
* because the author touched a search box.
*
* Making the screen's callback *additive* is what keeps the two apart, and it
* has to be additive by construction rather than by convention -- the
* replacing version was one `setAssets` reference away and read as correct.
* `incoming` is placed first and `current` deduped behind it, so a re-fetched
* Asset's fresher fields win while every previously observed Asset survives;
* that is also exactly the expression a single freshly uploaded Asset needs,
* so both callers share one rule.
*
* Order is not load-bearing for resolution -- `findResolvableAsset` is
* explicitly order-independent -- so this only has to be total and stable.
*/
export function mergeAssetCatalog(
current: readonly Asset[],
incoming: readonly Asset[],
): readonly Asset[] {
if (incoming.length === 0) return current;
const arriving = new Set(incoming.map((asset) => asset.id));
return [...incoming, ...current.filter((asset) => !arriving.has(asset.id))];
}
/**
* The evidence-key gate every caller uses. A key is referenceable when a real
* Asset resolves it, or when it is the caller's own legacy static key -- the
@@ -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>;
}
@@ -37,13 +37,17 @@ function insertAtCursor(
export function CaseFields({
draft,
onChange,
onAssetsLoaded,
onAssetsObserved,
onAssetUploaded,
}: {
draft: CaseInput;
onChange(draft: CaseInput): void;
/** The editor screen owns the loaded Asset list so the Picker and Instant Preview read the same array. */
onAssetsLoaded?: (assets: readonly Asset[]) => void;
/**
* The editor screen owns the resolution catalog Instant Preview reads, and
* merges what the Picker observes into it -- it never adopts the Picker's
* (searchable, and so narrowable) displayed list wholesale.
*/
onAssetsObserved?: (assets: readonly Asset[]) => void;
onAssetUploaded?: (asset: Asset) => void;
}) {
// Asset UI must use the throwing accessor, not the nullable `assetGateway`
@@ -103,7 +107,7 @@ export function CaseFields({
Asset
</button>
</div>
<AssetPicker gateway={assetGateway} onLoaded={onAssetsLoaded} onInsert={insertDirective} />
<AssetPicker gateway={assetGateway} onAssetsObserved={onAssetsObserved} onInsert={insertDirective} />
</div>
{uploadOpen ? (
<AssetUploadDialog
@@ -7,6 +7,7 @@ import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import { mergeAssetCatalog } from "../../../domain/content-format/asset-evidence-catalog.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { DocumentEditor } from "./document-editor.tsx";
@@ -38,7 +39,21 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
// Owned here (not by CaseFields/AssetPicker) so the Picker, the upload
// dialog, and Instant Preview all read the same array -- a freshly
// uploaded asset appears in the preview without a refetch.
//
// Alignment follow-up, item 2: this array is Instant Preview's *resolution
// catalog*, not the Picker's displayed list. The Picker now searches, so its
// displayed list narrows; adopting that narrowed list here would make an
// already-inserted `:::evidence` figure stop resolving the moment the author
// typed a query. Both writers therefore go through `mergeAssetCatalog`,
// which can only grow the catalog -- the one legitimate reset is the effect
// below, when the screen switches to a different document.
const [assets, setAssets] = useState<readonly Asset[]>([]);
const observeAssets = useCallback((observed: readonly Asset[]) => {
setAssets((current) => mergeAssetCatalog(current, observed));
}, []);
const observeUploadedAsset = useCallback((asset: Asset) => {
setAssets((current) => mergeAssetCatalog(current, [asset]));
}, []);
useEffect(() => {
const request = new AbortController();
@@ -156,10 +171,8 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
controller={controller!}
catalog={currentResult.catalog}
assets={assets}
onAssetsLoaded={setAssets}
onAssetUploaded={(asset) =>
setAssets((current) => [asset, ...current.filter((existing) => existing.id !== asset.id)])
}
onAssetsObserved={observeAssets}
onAssetUploaded={observeUploadedAsset}
/>
);
}
@@ -17,14 +17,14 @@ export function DocumentEditor({
controller,
catalog,
assets,
onAssetsLoaded,
onAssetsObserved,
onAssetUploaded,
}: {
controller: DocumentEditorController;
catalog: CatalogEntry[];
/** Owned by `DocumentEditorScreen` so the CASE editor's Picker/Upload and Instant Preview share one list. */
assets: readonly Asset[];
onAssetsLoaded: (assets: readonly Asset[]) => void;
onAssetsObserved: (assets: readonly Asset[]) => void;
onAssetUploaded: (asset: Asset) => void;
}) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
@@ -64,7 +64,7 @@ export function DocumentEditor({
</header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsLoaded={onAssetsLoaded} onAssetUploaded={onAssetUploaded} />
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
: controller.draft.kind === "QUESTION"
@@ -78,6 +78,14 @@
.studio-app .studio-asset-panel-actions button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .asset-picker ul { display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 0; list-style: none; }
.studio-app .asset-picker button { min-height: 36px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--paper); color: var(--ink); font-size: 13px; }
/* Declared after `.asset-picker button` on purpose: the pill rule above also
matches this form's submit button, and both selectors carry the same
specificity, so only source order tells them apart. */
.studio-app .asset-picker-search { display: grid; max-width: 420px; gap: 8px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .asset-picker-search div { display: flex; gap: 8px; }
.studio-app .asset-picker-search input { min-width: 0; flex: 1; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-size: 14px; }
.studio-app .asset-picker-search button { min-height: 44px; padding-inline: 16px; border-radius: 5px; font-size: 13px; font-weight: 650; }
.studio-app .asset-picker ul:not(:empty) { margin-top: 12px; }
.studio-app .studio-error,
.studio-app .studio-asset-picker-empty { margin: 0; color: var(--muted); font-size: 13px; }
.studio-app .studio-instant-preview { min-width: 0; overflow: clip; border: 1px solid var(--line); }