Export template source from 11bc3ae35b0034c113daca4c35d1b341aa31ef70:examples/local-first/packages/language-reader-presentation
This commit is contained in:
+224
@@ -0,0 +1,224 @@
|
||||
import { sameCacheConfiguration } from "./cache.js";
|
||||
import { intersects, type Range } from "./canvas/types.js";
|
||||
export type Feature = {
|
||||
processor: string;
|
||||
generation: string;
|
||||
scope: string;
|
||||
reveal: string;
|
||||
configRevision: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
export type Job = { sourceRevision: string; featureRevision: number; start: number; end: number; status: string };
|
||||
export function processorMatches(job: { key: string }, revision: string, previousRevisions: readonly string[] = []) {
|
||||
return [revision, ...previousRevisions].some((revision) => job.key.startsWith(revision + ":"));
|
||||
}
|
||||
export function sameScope(
|
||||
job: Job & { configuration: string },
|
||||
sourceRevision: string,
|
||||
configuration: string,
|
||||
scope: Range,
|
||||
) {
|
||||
return (
|
||||
job.sourceRevision === sourceRevision &&
|
||||
sameCacheConfiguration(job.configuration, configuration) &&
|
||||
job.start === scope.start &&
|
||||
job.end === scope.end
|
||||
);
|
||||
}
|
||||
type SemanticJob = {
|
||||
configuration: string;
|
||||
data: {
|
||||
key: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
sourceRevision: string;
|
||||
start: number;
|
||||
end: number;
|
||||
createdAt: string;
|
||||
};
|
||||
};
|
||||
export type AnnotationRetryFeature<F> = {
|
||||
feature: F;
|
||||
configuration: string;
|
||||
revision: string;
|
||||
aliases?: readonly string[];
|
||||
styles: readonly string[];
|
||||
};
|
||||
/** Nearby controls describe the newest attempt for a semantic scope, not its
|
||||
* history. Shared renderer instances and verified processor aliases must not
|
||||
* multiply controls. Sidebar computations already have their own controls. */
|
||||
export function selectAnnotationRetryJobs<F, J extends SemanticJob>(
|
||||
jobs: readonly J[],
|
||||
features: readonly AnnotationRetryFeature<F>[],
|
||||
sourceRevision: string,
|
||||
visible: Range,
|
||||
) {
|
||||
const scopes: { configuration: string; start: number; end: number; revision: string }[] = [];
|
||||
const result: { feature: F; job: J }[] = [];
|
||||
if (visible.end <= visible.start) return result;
|
||||
for (const job of [...jobs].sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))) {
|
||||
if (job.data.kind !== "annotation" || job.data.sourceRevision !== sourceRevision || !intersects(job.data, visible))
|
||||
continue;
|
||||
const matching = features.filter(
|
||||
(feature) =>
|
||||
feature.revision &&
|
||||
processorMatches(job.data, feature.revision, feature.aliases) &&
|
||||
sameCacheConfiguration(feature.configuration, job.configuration),
|
||||
);
|
||||
const feature = matching.find((feature) => feature.styles.some((style) => style !== "sidebar"));
|
||||
if (!feature || matching.some((feature) => feature.styles.includes("sidebar"))) continue;
|
||||
if (
|
||||
scopes.some(
|
||||
(scope) =>
|
||||
scope.revision === feature.revision &&
|
||||
scope.start === job.data.start &&
|
||||
scope.end === job.data.end &&
|
||||
sameCacheConfiguration(scope.configuration, job.configuration),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
scopes.push({
|
||||
configuration: job.configuration,
|
||||
start: job.data.start,
|
||||
end: job.data.end,
|
||||
revision: feature.revision,
|
||||
});
|
||||
if (["failed", "skipped", "uncertain"].includes(job.data.status)) result.push({ feature: feature.feature, job });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function annotationRetryStatus(status: string, error: string) {
|
||||
if (error.includes("AGENT_SETUP_UNAVAILABLE")) return "Setup was unavailable. Try again when connected.";
|
||||
if (status === "skipped") return "Not prepared. Try again when ready.";
|
||||
if (status === "uncertain") return "Preparation could not be confirmed.";
|
||||
return "Could not prepare annotations.";
|
||||
}
|
||||
/** Only an explicit control invokes this dispatcher. Guard double clicks until
|
||||
* the public operation settles; neither selection nor a status update retries. */
|
||||
export function explicitAnnotationRetry(
|
||||
key: string,
|
||||
inFlight: Set<string>,
|
||||
dispatch: (settled: () => void) => boolean,
|
||||
changed: () => void,
|
||||
) {
|
||||
if (inFlight.has(key)) return false;
|
||||
inFlight.add(key);
|
||||
changed();
|
||||
const settled = () => {
|
||||
inFlight.delete(key);
|
||||
changed();
|
||||
};
|
||||
try {
|
||||
if (dispatch(settled)) return true;
|
||||
settled();
|
||||
return false;
|
||||
} catch (error) {
|
||||
settled();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** A successful task replaces its whole semantic scope, including omitted
|
||||
* annotations. Deduplicating individual items resurrects removed old output. */
|
||||
export function selectSuccessfulAnnotationJobs<T extends SemanticJob>(
|
||||
jobs: readonly T[],
|
||||
matches: (job: T) => boolean,
|
||||
) {
|
||||
const scopes = new Map<string, string[]>();
|
||||
return jobs
|
||||
.filter((job) => job.data.kind === "annotation" && job.data.status === "ready" && matches(job))
|
||||
.sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
|
||||
.filter((job) => {
|
||||
const key = JSON.stringify([job.data.sourceRevision, job.data.start, job.data.end]);
|
||||
const configurations = scopes.get(key) ?? [];
|
||||
if (configurations.some((configuration) => sameCacheConfiguration(configuration, job.configuration)))
|
||||
return false;
|
||||
configurations.push(job.configuration);
|
||||
scopes.set(key, configurations);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
/** Index pointers are authoritative even if an older successful attempt was
|
||||
* explicitly restored. Keep attempt history for progress/errors, not as a
|
||||
* second successful result for the same indexed key. */
|
||||
export function mergeCachedAnnotationEntries<
|
||||
T extends {
|
||||
node: { _camino: { ref: { object: string } }; data: { key: string; kind: string; status: string } };
|
||||
},
|
||||
>(cached: readonly T[], history: readonly T[]) {
|
||||
const keys = new Set<string>();
|
||||
const entries = new Map<string, T>();
|
||||
for (const entry of cached) {
|
||||
if (keys.has(entry.node.data.key)) continue;
|
||||
keys.add(entry.node.data.key);
|
||||
entries.set(entry.node._camino.ref.object, entry);
|
||||
}
|
||||
for (const entry of history) {
|
||||
if (entry.node.data.kind === "annotation" && entry.node.data.status === "ready" && keys.has(entry.node.data.key))
|
||||
continue;
|
||||
if (!entries.has(entry.node._camino.ref.object)) entries.set(entry.node._camino.ref.object, entry);
|
||||
}
|
||||
return [...entries.values()];
|
||||
}
|
||||
export type PreparationScope = { key: string; configuration: string; range: Range; local: boolean };
|
||||
/** Independent scopes may share the two model slots. Nearby features take turns
|
||||
* at equal distance; completed, failed and already-submitted work is excluded. */
|
||||
export function preparationQueue<T extends PreparationScope>(
|
||||
plans: T[],
|
||||
visible: Range,
|
||||
occupied: number,
|
||||
submitted: ReadonlySet<string>,
|
||||
cached: ReadonlySet<string>,
|
||||
) {
|
||||
const groups = new Map<string, T[]>(),
|
||||
seen = new Set<string>();
|
||||
for (const plan of plans) {
|
||||
if (plan.range.end <= plan.range.start || seen.has(plan.key) || submitted.has(plan.key) || cached.has(plan.key))
|
||||
continue;
|
||||
seen.add(plan.key);
|
||||
const group = groups.get(plan.configuration) ?? [];
|
||||
group.push(plan);
|
||||
groups.set(plan.configuration, group);
|
||||
}
|
||||
const waiting: T[] = [];
|
||||
for (let index = 0; ; index++) {
|
||||
let found = false;
|
||||
for (const group of groups.values())
|
||||
if (group[index]) {
|
||||
waiting.push(group[index]);
|
||||
found = true;
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
const distance = ({ range }: T) =>
|
||||
range.end <= visible.start
|
||||
? visible.start - range.end + 1
|
||||
: range.start >= visible.end
|
||||
? range.start - visible.end + 1
|
||||
: 0;
|
||||
waiting.sort((a, b) => distance(a) - distance(b));
|
||||
let slots = Math.max(0, 2 - occupied);
|
||||
const admit = waiting.filter((plan) => plan.local || slots-- > 0);
|
||||
return { waiting, admit };
|
||||
}
|
||||
/** Intent scheduler for heavy hover work. It never owns the durable model job:
|
||||
* leaving hover cancels intent; an admitted result remains useful in its cache.
|
||||
* Local/section/cached reveal happens outside this scheduler, immediately.
|
||||
*/
|
||||
export class HoverIntent {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private generation = 0;
|
||||
constructor(private readonly delay = 420) {}
|
||||
cancel() {
|
||||
this.generation++;
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
schedule(callback: () => void) {
|
||||
this.cancel();
|
||||
const generation = this.generation;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (generation === this.generation) callback();
|
||||
}, this.delay);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user