Merge develop into T-693 and reconcile Subject behavior with Memory information
Normal merge of develop@b0bce50b retains T-692 state hierarchy, diagnostics disclosures, ready-empty and candidate-only regressions alongside T-693 behavior/CAS/route fences. ResidentContext load and refresh retain the five-second timeout from 685b8aa6, with a seven-request regression.
Validation: root cargo check; full worker(serial), session-store, workspace-server and server-api(typescript) suites; frontend check with zero diagnostics; 464 Web tests and 171 component tests (19 Memory); production Memory browser-shell test/build; fmt/diff/OpenAPI checks.
This commit is contained in:
@@ -596,6 +596,16 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
||||
await touchPage.reload();
|
||||
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||
// Different-route and same-page links both dismiss the mobile overlay.
|
||||
for (let visit = 0; visit < 2; visit++) {
|
||||
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
|
||||
await touchPage.getByRole("link", { name: "Workspace home", exact: true }).tap();
|
||||
await touchPage.waitForURL(`**/w/${workspaceId}`);
|
||||
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||
assertEquals(await touchPage.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
|
||||
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||
}
|
||||
await touchPage.goto(page.url());
|
||||
// A wide touchscreen still has a temporary-open control in hover mode.
|
||||
await touchPage.setViewportSize({ width: 1024, height: 600 });
|
||||
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
|
||||
|
||||
@@ -64,6 +64,104 @@ Deno.test("Console pending scopes are independent and match Tasks row typography
|
||||
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
|
||||
);
|
||||
await page.locator(".task-mini-row").waitFor();
|
||||
const metadata = page.getByLabel("Worker model and context", { exact: true });
|
||||
const statusGeometry = await metadata.evaluate((status) => {
|
||||
const task = document.querySelector(".task-mini-row")!;
|
||||
const composer = document.querySelector(".console-composer")!;
|
||||
const metrics = (node: Element) => {
|
||||
const style = getComputedStyle(node);
|
||||
return [style.fontFamily, style.fontSize, style.lineHeight];
|
||||
};
|
||||
const rect = status.getBoundingClientRect();
|
||||
return {
|
||||
typography: metrics(status),
|
||||
taskTypography: metrics(task),
|
||||
height: rect.height,
|
||||
belowComposer: rect.top >= composer.getBoundingClientRect().bottom,
|
||||
alignedWithTasks: rect.left + parseFloat(getComputedStyle(status).paddingLeft) === task.getBoundingClientRect().left,
|
||||
visible: rect.bottom <= innerHeight,
|
||||
wrap: getComputedStyle(status).flexWrap,
|
||||
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
});
|
||||
assertEquals(statusGeometry.typography, statusGeometry.taskTypography);
|
||||
assertEquals(statusGeometry.height, 24);
|
||||
assertEquals(statusGeometry.belowComposer, true);
|
||||
assertEquals(statusGeometry.alignedWithTasks, true);
|
||||
assertEquals(statusGeometry.visible, true);
|
||||
assertEquals(statusGeometry.wrap, "nowrap");
|
||||
assertEquals(statusGeometry.pageOverflow, false);
|
||||
const details = metadata.getByRole("button", { name: "Details", exact: true });
|
||||
assertEquals(await page.locator(".console-header").count(), 0);
|
||||
const detailsGeometry = await details.evaluate((button) => {
|
||||
const row = button.closest(".worker-context-status")!;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
return {
|
||||
rightAligned: Math.abs(rect.right - (rowRect.right - parseFloat(getComputedStyle(row).paddingRight))) < 1,
|
||||
centered: Math.abs(rect.top + rect.height / 2 - (rowRect.top + rowRect.height / 2)) < 1,
|
||||
width: rect.width,
|
||||
};
|
||||
});
|
||||
assertEquals(detailsGeometry.rightAligned, true);
|
||||
assertEquals(detailsGeometry.centered, true);
|
||||
assertEquals(detailsGeometry.width, 24);
|
||||
const overview = metadata.getByRole("switch", { name: "Overview", exact: true });
|
||||
const controls = await overview.evaluate((group) => {
|
||||
const row = group.closest(".worker-context-status")!;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const buttons = [...row.querySelectorAll("button")];
|
||||
return {
|
||||
buttonsInsideRow: buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= rowRect.left && rect.right <= rowRect.right &&
|
||||
rect.top >= rowRect.top && rect.bottom <= rowRect.bottom;
|
||||
}),
|
||||
heights: buttons.map((button) => button.getBoundingClientRect().height),
|
||||
modeFont: getComputedStyle(buttons[0]).fontFamily,
|
||||
divider: getComputedStyle(buttons[1]).borderLeftWidth,
|
||||
beforeDetails: group.nextElementSibling?.getAttribute("aria-label") === "Details",
|
||||
};
|
||||
});
|
||||
assertEquals(controls.buttonsInsideRow, true);
|
||||
assertEquals(controls.heights, [24, 24]);
|
||||
assertEquals(controls.modeFont.includes("mono"), false);
|
||||
assertEquals(controls.divider, "0px");
|
||||
assertEquals(controls.beforeDetails, true);
|
||||
const onSize = (await overview.boundingBox())!;
|
||||
assert(onSize.width < 90, "Switch and fixed label should use less than 90px");
|
||||
assertEquals(await overview.getAttribute("aria-checked"), "true");
|
||||
await overview.focus();
|
||||
assertEquals(await overview.evaluate((button) => getComputedStyle(button).outlineStyle), "solid");
|
||||
await page.keyboard.press("Enter");
|
||||
assertEquals(await overview.getAttribute("aria-checked"), "false");
|
||||
assertEquals((await overview.textContent())!.trim(), "Overview");
|
||||
assertEquals((await overview.boundingBox())!.width, onSize.width);
|
||||
if (mode === "both") {
|
||||
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
|
||||
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}-off.png`) });
|
||||
}
|
||||
await page.keyboard.press("Space");
|
||||
assertEquals(await overview.getAttribute("aria-checked"), "true");
|
||||
assertEquals((await overview.boundingBox())!.width, onSize.width);
|
||||
await overview.locator("span").last().click();
|
||||
assertEquals(await overview.getAttribute("aria-checked"), "false");
|
||||
await overview.locator(".console-view-toggle-track").click();
|
||||
assertEquals(await overview.getAttribute("aria-checked"), "true");
|
||||
await details.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
assertEquals(await details.getAttribute("aria-expanded"), "true");
|
||||
await page.getByRole("button", { name: "Close", exact: true }).waitFor();
|
||||
await page.keyboard.press("Space");
|
||||
assertEquals(await details.getAttribute("aria-expanded"), "false");
|
||||
await details.click();
|
||||
assertEquals(await details.getAttribute("aria-expanded"), "true");
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
assertEquals(await details.getAttribute("aria-expanded"), "false");
|
||||
if (mode === "both") {
|
||||
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
|
||||
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}.png`) });
|
||||
}
|
||||
const queue = mode === "both" || mode === "queue";
|
||||
const notifications = ["both", "notifications", "legacy"].includes(mode);
|
||||
assertEquals(
|
||||
|
||||
@@ -73,6 +73,7 @@ const subjects = [
|
||||
42,
|
||||
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
|
||||
3,
|
||||
connectedWorker("Release coordination Worker with a deliberately long display name"),
|
||||
),
|
||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
||||
@@ -82,6 +83,30 @@ const subjects = [
|
||||
];
|
||||
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
|
||||
|
||||
function connectedWorker(displayName: string) {
|
||||
return {
|
||||
runtime_id: "embedded-worker-runtime",
|
||||
worker_id: "release-coordination-worker",
|
||||
host_id: "fixture-host",
|
||||
display_name: displayName,
|
||||
label: displayName,
|
||||
profile: "builtin:companion",
|
||||
singleton_key: `subjektiv:${representativeSubjectId}`,
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: workspaceId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
state: "idle",
|
||||
last_seen_at: "2026-01-02T03:04:05Z",
|
||||
pinned: false,
|
||||
retention_state: "retained",
|
||||
implementation: { kind: "embedded", display_hint: "Fixture Worker" },
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function subject(
|
||||
id: string,
|
||||
role: string,
|
||||
@@ -89,6 +114,7 @@ function subject(
|
||||
storeRevision: number,
|
||||
behaviorMd = "",
|
||||
behaviorRevision = 0,
|
||||
currentWorker?: ReturnType<typeof connectedWorker>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
@@ -99,6 +125,7 @@ function subject(
|
||||
store_revision: storeRevision,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
...(currentWorker ? { current_worker: currentWorker } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,7 +168,7 @@ function memoriesFor(subjectId: string) {
|
||||
1,
|
||||
"working_assumption",
|
||||
"retracted",
|
||||
"T-672 UNIQUE END MARKER",
|
||||
"Memory list end marker for scroll validation",
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -251,7 +278,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) {
|
||||
evidence_id: "evidence-message-0001",
|
||||
origin: { kind: "human_input", account_id: "account-fixture" },
|
||||
evidence_kind: "message",
|
||||
label: "T-672 product direction",
|
||||
label: "Memory information design source",
|
||||
summary: "Bounded source reference retained with the committed revision.",
|
||||
}],
|
||||
source_refs_total: 1,
|
||||
|
||||
@@ -16,7 +16,7 @@ const memoryId =
|
||||
const subjectPageCursor = "fixture-subject-page-2";
|
||||
const createInteractionArtifacts = join(
|
||||
repositoryRoot,
|
||||
"target/web-ux/t-693-subject-behavior",
|
||||
"target/web-ux/t-692-browser-interactions",
|
||||
);
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
|
||||
const document = (globalThis as any).document;
|
||||
const main = document.querySelector("main");
|
||||
const marker = [...document.querySelectorAll("h3")].find((heading) =>
|
||||
heading.textContent === "T-672 UNIQUE END MARKER"
|
||||
heading.textContent === "Memory list end marker for scroll validation"
|
||||
);
|
||||
if (!main || !marker) return false;
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
@@ -270,7 +270,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
|
||||
await page.getByRole("article", { name: "Resident context" }).locator("img, script")
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
@@ -290,7 +290,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
await page.locator("main").hover();
|
||||
await page.mouse.wheel(0, 100_000);
|
||||
} else if (scenario.label === "keyboard") {
|
||||
await page.getByText("Surface provenance", { exact: true }).focus();
|
||||
await page.getByText("Surface sources and diagnostics", { exact: true }).focus();
|
||||
await page.keyboard.press("End");
|
||||
} else {
|
||||
await swipeMainToEnd(page);
|
||||
@@ -321,17 +321,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
level: 1,
|
||||
}).waitFor();
|
||||
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
|
||||
assertEquals(await detailPage.getByText("T-672 product direction").count(), 1);
|
||||
assertEquals(await detailPage.getByText("Memory information design source").count(), 1);
|
||||
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
|
||||
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0);
|
||||
assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
|
||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||
"link",
|
||||
{ name: "Next page →" },
|
||||
).click();
|
||||
await detailPage.getByText("Revision 1", { exact: true }).waitFor();
|
||||
await detailPage.getByText("Memory revision 1", { exact: true }).waitFor();
|
||||
assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
|
||||
assertEquals(
|
||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||
@@ -360,6 +360,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const paginationPage = await paginationContext.newPage();
|
||||
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor();
|
||||
assertEquals(
|
||||
await paginationPage.getByText(
|
||||
"Release coordination Worker with a deliberately long display name",
|
||||
{ exact: true },
|
||||
).count(),
|
||||
1,
|
||||
);
|
||||
assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0);
|
||||
assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0);
|
||||
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
|
||||
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
|
||||
"link",
|
||||
@@ -473,10 +482,12 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||
1,
|
||||
);
|
||||
const currentWorker = createPage.locator(".subject-facts div").filter({
|
||||
hasText: "Current worker",
|
||||
});
|
||||
assert((await currentWorker.textContent())?.includes("None"));
|
||||
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
|
||||
"..",
|
||||
);
|
||||
assert((await currentWorker.textContent())?.includes("Not connected"));
|
||||
await createPage.getByText("Resident context has not been generated.").waitFor();
|
||||
await createPage.getByText("No committed Memories yet.").waitFor();
|
||||
await createPage.getByRole("button", { name: "Edit" }).click();
|
||||
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
|
||||
await behaviorEditor.fill(
|
||||
@@ -519,21 +530,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const stateCases = [
|
||||
{
|
||||
id: "empty-subject",
|
||||
text: "Resident surface is ready and empty.",
|
||||
text: "Resident context is current but empty.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{
|
||||
id: "stale-subject",
|
||||
text: "Resident context needs to be refreshed.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
|
||||
{
|
||||
id: "failed-subject",
|
||||
text: "Resident surface generation failed.",
|
||||
text: "Resident context generation failed.",
|
||||
role: "alert" as const,
|
||||
},
|
||||
{
|
||||
id: "ungenerated-subject",
|
||||
text: "Resident surface has not been generated.",
|
||||
text: "Resident context has not been generated.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{ id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const },
|
||||
{
|
||||
id: "error-subject",
|
||||
text: "Resident context status unavailable.",
|
||||
role: "alert" as const,
|
||||
},
|
||||
];
|
||||
for (const stateCase of stateCases) {
|
||||
const page = await stateContext.newPage();
|
||||
@@ -541,8 +560,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
|
||||
await state.waitFor();
|
||||
assert((await state.textContent())?.includes(stateCase.text));
|
||||
if (stateCase.id === "stale-subject") {
|
||||
assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1);
|
||||
}
|
||||
if (stateCase.id === "failed-subject") {
|
||||
assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1);
|
||||
}
|
||||
if (stateCase.id !== "empty-subject") {
|
||||
assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0);
|
||||
assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0);
|
||||
}
|
||||
await assertNoPageWideOverflow(page);
|
||||
await page.close();
|
||||
|
||||
@@ -122,9 +122,33 @@
|
||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Ready subject" },
|
||||
{
|
||||
"id": "subject-diagnostics",
|
||||
"label": "Subject diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.subject-technical-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.subject-technical-details[open]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "surface-diagnostics",
|
||||
"label": "Surface sources and diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.surface-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.surface-details[open]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
@@ -150,6 +174,18 @@
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||
},
|
||||
{
|
||||
"id": "ungenerated",
|
||||
"label": "Ungenerated subject context",
|
||||
"path": "/w/memory-review/memory/ungenerated-subject",
|
||||
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
|
||||
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
|
||||
},
|
||||
{
|
||||
"id": "stale",
|
||||
"label": "Stale subject surface",
|
||||
|
||||
@@ -122,9 +122,33 @@
|
||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Ready subject" },
|
||||
{
|
||||
"id": "subject-diagnostics",
|
||||
"label": "Subject diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.subject-technical-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.subject-technical-details[open]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "surface-diagnostics",
|
||||
"label": "Surface sources and diagnostics",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "details.surface-details summary" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details.surface-details[open]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
@@ -150,6 +174,18 @@
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||
},
|
||||
{
|
||||
"id": "ungenerated",
|
||||
"label": "Ungenerated subject context",
|
||||
"path": "/w/memory-review/memory/ungenerated-subject",
|
||||
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
|
||||
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
|
||||
},
|
||||
{
|
||||
"id": "stale",
|
||||
"label": "Stale subject surface",
|
||||
|
||||
Reference in New Issue
Block a user