Compare commits

7 changed files with 31 additions and 331 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{
"monorepoCommit": "11bc3ae35b0034c113daca4c35d1b341aa31ef70",
"monorepoCommit": "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9",
"sourcePath": "examples/local-first/packages/language-reader-presentation"
}
+20 -20
View File
@@ -1,22 +1,22 @@
quixos-lock version 1 {
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "11bc3ae35b0034c113daca4c35d1b341aa31ef70"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "6c26bde43dc95ce70371fc120689947a23f8c564"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "caf95ae904857b554f1c1b84803f4b8f2a7895ae"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "425ce639dd6446e01d791f7aad9fcf0682af7d09"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "1cfa42c98466b6b7f4e3b177c9bf076ed0d1d97f"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "680be4948bd0dfb9e512967ac6a48746dc2835a4"; }
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "70e01195cf65994c6e54312498dd69115204ec04"; }
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "7be1aa0ebcf364d2283cc1423a06383e7658addd"; }
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "ebda8ef7734973e21e47446ed1ddd9d4d51d760d"; }
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "5c5f0e6eea473f3d3e5ccda90c4b99264da1a200"; }
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "39cf5dfec15f9ab63d9371512c7dcb0bde273eb2"; }
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "53fd9bb61b572091319db6927adce8d180f5924f"; }
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "105a9d9957c8c0d7e3f8a0896698f5cad6203efc"; }
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "467c619dc09f79b882a5831e2fb07921f35bf550"; }
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "837f31b643da53c52ae9d4b61f29c02a1f4c3a83"; }
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "e45cd54d915210819c9eea2b74075807a48f72af"; }
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "1470958778a58992d0732b193ba79bae48a552b0"; }
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "f237c2d8ef235dabb941b9c8936bcf94b938d7ad"; }
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "89659cf5b6d5688ba9e01b1007a6749b865a2201"; }
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "af259cf22a7a63e8ba53c0d44522f4e19b3633d0"; }
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "ad65d325d071443ba72edf5a99a5aeaa59f3e7e0"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "42b3565df44e6297ef88476fa44fa37e38a70d25"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "6ac56d55ee1dd79992cf28c3d760e1cf190225f1"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "2e18f362ce34884e15771b4068197f1cd97c12d4"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "3bc1b603b65c981e08c8521d111c9dfb983f3d83"; }
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "9b302fb44e96671832de4aef4e4563dba5af6f2d"; }
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "4c8dc969136d0add25c87c8f6cacb00f7dde97c0"; }
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "7f1e9d6278366329fd76f2698cd5a53c136f9914"; }
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "6ace62f773c230c468efeabe9b5aac35115b7900"; }
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "6e702e5cab24634d306c8f657f3a0b4873469060"; }
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "7d69058c2c12b4ca1800d8ff8218caffa45e5d13"; }
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "29a974e9db15387c9efc0f0fe64fec348db10344"; }
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "59a8927fdeaba29c51ce5de8fa2cc0ee10a6a134"; }
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "4a322809adfbb50494c9d5dd9889b56c907e737d"; }
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "b8f27e813b05231c9d3b4d3692e91b8fc2936dec"; }
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "e11912f9e11a2014a489919294424c00a98f7cf3"; }
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "710cab97959f633a0ffd9a757f2dd2cf9b06f77f"; }
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "ea1ffb89efbc7bdd9c20794cc63dc112055536c8"; }
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "8f5661caa8b6a4abb5bc85c88f1c3843a4c92f38"; }
}
+7 -9
View File
@@ -78,7 +78,7 @@ export function useProcessing({
range.start,
range.end,
].join(":");
function request(feature: FeatureNode, range: Range, retry = false, settled?: () => void): boolean {
function request(feature: FeatureNode, range: Range, retry = false): boolean {
if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor])
return false;
const item = { feature, range, context },
@@ -94,14 +94,12 @@ export function useProcessing({
retry,
createdAt: new Date().toISOString(),
}),
)
.catch((cause) => {
admitting.current.delete(key);
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
error(errorMessage(cause));
setTick((value) => value + 1);
})
.finally(settled);
).catch((cause) => {
admitting.current.delete(key);
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
error(errorMessage(cause));
setTick((value) => value + 1);
});
return true;
}
useEffect(() => {
-3
View File
@@ -216,9 +216,6 @@ export const readingStyle = `
.lr-request-config dt{color:var(--muted)}.lr-request-config dd{margin:0}
.lr-request-config p{font-size:13px;white-space:pre-wrap}
.lr-variant-status{padding:12px 24px;font-size:12px;border-top:1px solid var(--line);background:#f5f7f0;display:flex;align-items:center;gap:12px}
.lr-annotation-retries{padding:8px 24px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
.lr-annotation-retry{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:4px 0}
.lr-annotation-retry button{font-size:12px;white-space:nowrap}
@media(max-width:650px){.lr-settings-nav{width:145px;padding:12px 6px}.lr-settings-content{padding:18px 14px}.lr-settings-top{padding:8px 14px}.lr-settings-nav button{font-size:12px}.lr-settings-content .lr-fields{flex-direction:column;gap:0}}
+2 -53
View File
@@ -3,7 +3,7 @@ import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
import { configDefinition, configValues } from "./annotator-config.js";
import { presentAnnotations } from "./annotation-display.js";
import { Options } from "./controls.js";
import { Button, Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
import { Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
import { renderAnnotations, type AnnotationRender } from "./render-hooks.js";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
@@ -26,14 +26,7 @@ import { intersects, type Annotation, type Range } from "./canvas/types.js";
import type { ReadingWindow } from "./canvas/pages.js";
import type { ProcessingRegion } from "./canvas/processing.js";
import { useProcessing } from "./processing.js";
import {
HoverIntent,
processorMatches,
selectSuccessfulAnnotationJobs,
selectAnnotationRetryJobs,
annotationRetryStatus,
explicitAnnotationRetry,
} from "./work.js";
import { HoverIntent, processorMatches, selectSuccessfulAnnotationJobs } from "./work.js";
import { ReaderSettings } from "./editor.js";
import { Importer, SpeechSettings, Playback, Rewrite } from "./sources.js";
import {
@@ -672,20 +665,6 @@ function ReadingDocument({
)
.map((job) => ({ feature, job })),
);
const retryJobs = selectAnnotationRetryJobs(
jobs,
enabled.map((feature) => ({
feature,
configuration: computations[referenceOf(feature).object],
revision: revisions[feature.data.processor],
aliases: revisionAliases[feature.data.processor],
styles: (renders[feature.data.processor] ?? [{ style: feature.data.style }]).map((render) => render.style),
})),
doc.data.sourceRevision,
window.visible,
);
const retryInFlight = useRef(new Set<string>());
const [, updateRetryState] = useState(0);
const pendingSidebars = displays
.filter((feature) => feature.data.style === "sidebar")
.flatMap((feature) =>
@@ -926,36 +905,6 @@ function ReadingDocument({
Loading annotations…
</div>
)}
{retryJobs.length > 0 && (
<div className="lr-annotation-retries" aria-label="Annotation preparation">
{retryJobs.map(({ feature, job }) => {
const key = referenceOf(job).object;
return (
<div className="lr-annotation-retry" key={key}>
<span role="status">
<strong>{feature.data.name}</strong>: {annotationRetryStatus(job.data.status, job.data.error)}
</span>
<Button
size="compact"
tone="quiet"
disabled={!annotationsReady || retryInFlight.current.has(key)}
aria-label={`Try again: ${feature.data.name}`}
onClick={() =>
explicitAnnotationRetry(
key,
retryInFlight.current,
(settled) => request(feature, { start: job.data.start, end: job.data.end }, true, settled),
() => updateRetryState((value) => value + 1),
)
}
>
{retryInFlight.current.has(key) ? "Trying again…" : "Try again"}
</Button>
</div>
);
})}
</div>
)}
{variant && next && (
<div className="lr-variant-status" role="status">
<span>
+1 -81
View File
@@ -1,5 +1,5 @@
import { sameCacheConfiguration } from "./cache.js";
import { intersects, type Range } from "./canvas/types.js";
import type { Range } from "./canvas/types.js";
export type Feature = {
processor: string;
generation: string;
@@ -37,86 +37,6 @@ type SemanticJob = {
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>(
-164
View File
@@ -6,9 +6,6 @@ import {
preparationQueue,
selectSuccessfulAnnotationJobs,
mergeCachedAnnotationEntries,
selectAnnotationRetryJobs,
annotationRetryStatus,
explicitAnnotationRetry,
} from "../src/work.js";
const oldProcessor = { key: "processor-v1:scope" };
assert(processorMatches(oldProcessor, "processor-v1"));
@@ -259,164 +256,3 @@ assert.deepEqual(
console.log(
"PASS canonical cache pointers and whole-scope replacement, including empty/narrowed successes, aliases and failed retries",
);
const retryFeature = {
feature: "pinyin",
configuration: scopeConfiguration,
revision: "current",
aliases: ["verified-previous"],
styles: ["overhead"],
};
const retryAttempt = (object: string, status: string, start = 0, createdAt = "2026-10-04") => ({
...savedJob(object, createdAt, []),
data: { ...savedJob(object, createdAt, []).data, status, start, end: start + 6 },
});
const skipped = retryAttempt("skipped", "skipped"),
failed = retryAttempt("failed", "failed", 6),
uncertain = retryAttempt("uncertain", "uncertain", 12),
offPage = retryAttempt("off-page", "skipped", 30),
ready = retryAttempt("ready", "ready", 18),
queued = retryAttempt("queued", "queued", 24);
const retryHistory = [skipped, failed, uncertain, offPage, ready, queued];
const retryHistorySnapshot = structuredClone(retryHistory);
const visiblePage = { start: 0, end: 30 };
assert.deepEqual(
selectAnnotationRetryJobs(
retryHistory,
[retryFeature, { ...retryFeature, feature: "hover pinyin", styles: ["tooltip"] }],
"doc@1",
visiblePage,
).map(({ feature, job }) => [feature, job._camino.ref.object]),
[
["pinyin", "skipped"],
["pinyin", "failed"],
["pinyin", "uncertain"],
],
"Failed/skipped/uncertain visible scopes appear once across shared annotators; queued/ready/off-page scopes do not",
);
const legacySkipped = {
...skipped,
configuration: oldAlias.configuration,
data: { ...skipped.data, key: "verified-previous:legacy", createdAt: "2026-10-03" },
};
assert.equal(
selectAnnotationRetryJobs([legacySkipped, skipped], [retryFeature], "doc@1", visiblePage).length,
1,
"Verified aliases and legacy scheduling configuration deduplicate the same scope",
);
for (const status of ["ready", "queued"]) {
assert.deepEqual(
selectAnnotationRetryJobs(
[skipped, legacySkipped, retryAttempt("replacement", status, 0, "2026-10-05")],
[retryFeature],
"doc@1",
visiblePage,
),
[],
"Newer queued or successful work supersedes old errors",
);
}
assert.deepEqual(
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["sidebar"] }], "doc@1", visiblePage),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["underline", "sidebar"] }], "doc@1", visiblePage),
[],
"Multi-hook sidebar processors already have controls",
);
assert.deepEqual(
selectAnnotationRetryJobs(
[skipped],
[retryFeature, { ...retryFeature, feature: "notes", styles: ["sidebar"] }],
"doc@1",
visiblePage,
),
[],
"A shared sidebar renderer does not get duplicate controls",
);
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@2", visiblePage), []);
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", { start: 0, end: 0 }), []);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...skipped, data: { ...skipped.data, kind: "variant" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...unknown, data: { ...unknown.data, status: "failed" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...anotherInput, data: { ...anotherInput.data, status: "failed" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
retryHistory,
retryHistorySnapshot,
"Status selection preserves all cached values and attempt history",
);
assert.equal(
annotationRetryStatus("skipped", "AGENT_SETUP_UNAVAILABLE: Authentication required"),
"Setup was unavailable. Try again when connected.",
);
assert.equal(annotationRetryStatus("uncertain", "raw transport error"), "Preparation could not be confirmed.");
const retrying = new Set<string>();
let explicitCalls = 0,
finishRetry: () => void = () => {},
rerenders = 0;
const dispatch = (settled: () => void) => {
explicitCalls++;
// The view delegates the original scope with retry:true, without replanning.
const { feature, job } = selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", visiblePage)[0];
assert.deepEqual(
{ feature, start: job.data.start, end: job.data.end, retry: true },
{ feature: "pinyin", start: 0, end: 6, retry: true },
);
finishRetry = settled;
return true;
};
assert.equal(explicitCalls, 0, "Selecting failed statuses never dispatches inference");
assert(explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
assert(!explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
assert.equal(explicitCalls, 1, "Double clicks cannot repeat an in-flight retry");
finishRetry();
assert.equal(explicitCalls, 1, "Settlement never automatically retries");
assert.equal(retrying.size, 0);
assert.equal(rerenders, 2);
assert(
!explicitAnnotationRetry(
"not-ready",
retrying,
() => false,
() => {},
),
);
assert.equal(retrying.size, 0, "An unavailable operation releases the button");
assert.throws(() =>
explicitAnnotationRetry(
"rejected",
retrying,
() => {
throw Error("rejected");
},
() => {},
),
);
assert.equal(retrying.size, 0);
console.log(
"PASS explicit nearby annotator retries, shared identities/aliases, sidebar/off-page exclusion and superseded attempts",
);