Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

### Added

- Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101).

- Coalesce identical snapshots across viewers in a bounded in-memory LRU cache with per-panel TTL overrides, stale-while-revalidate, action/reload invalidation and separate principal keys. Declared readOnly principal tools preserve the cache; other principal actions and all console actions invalidate it. Keep producer bodies and error snapshots unchanged, expose freshness only through cache/age/sample-time headers and add System cache/provider timing diagnostics, and add a private-output snapshot timing probe (#105).

- Add the Space Flow view with per-project actors, directed 15-minute message counts, distinct relationship strokes and recent activity. Keep at most 200 public link events for 30 minutes in page memory, and show per-peer counts and the last 10 events in agent detail. Suspend the minute refresh while Flow is hidden (#101).
Expand All @@ -23,6 +25,8 @@

### Fixed

- Restrict geometry-forced Space updates to Room: Team, Flow and Board use CSS layout and no longer rewrite unchanged root controls on ResizeObserver notifications. Wait for both fixture producers before measuring Team polls; remove its CSP-incompatible predicate waits (#101).

- Honor `changed: false` panel replies without notifying listeners or rerendering, while updating the conditional cursor and retaining legacy full-snapshot deduplication (#63).
- Safely release queued diagram visibility callbacks after frame handover or a visible theme redraw, without dereferencing a cleared observer (#90).

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@ Space is an optional area in the rail, shown when a ready plugin declares `capab

The area uses `#/local/space`; if a plugin already owns the tool id `space`, the area's link uses `#/local/~space` so that plugin's existing URLs keep working.

The Space header provides a summary strip, view tabs and a group filter. Routes accept `#/local/space?view=team|flow|board|room&group=<id>`; Team is the default, while a saved view choice in `acon.space.section` wins when the route omits `view`. The header scrolls with the page. Actor detail uses `&actor=<namespaced-key>`. Board remains a placeholder for its later delivery. Summary state counts filter actors (`state=working|waiting|blocked|away|idle`), and the project count resets both filters.
The Space header provides a summary strip, view tabs and a group filter. Routes accept `#/local/space?view=team|flow|board|room&group=<id>`; Team is the default, while a saved view choice in `acon.space.section` wins when the route omits `view`. The header scrolls with the page. Actor detail uses `&actor=<namespaced-key>`. Summary state counts filter actors (`state=working|waiting|blocked|away|idle`), and the project count resets both filters.

Team puts blocked, waiting and approval-holding actors first under Needs attention, then shows each group with agent and active counts and shortcuts to that group's Board and Flow. Working, waiting and blocked actors appear as tiles with their avatar, roles, state, current task, inbox and approval counts. Idle and away actors collapse into one expandable line per group. Team is the text alternative; the separate actor/zone/link roster remains available in Room.

Flow shows one compact actor panel per project with directed message counts over 15 minutes and labeled overlap, conflict and cohort connections. Recent activity sits beside the panels at 1024 px and wider and below them on narrow screens. History starts when the page opens: the shell retains up to 200 observed public link events for 30 minutes, without requesting or storing message bodies. New activity highlights once; reduced motion disables the animation.

Selecting an actor in Team, Flow or Room opens its detail using the namespaced actor route. At 1024 px and wider, detail is a side sheet; below 1024 px, it is a full page. Escape or Back returns to the prior view and restores its scroll position and focus. Detail includes the actor's current task and producer link, marks, and current overlap/conflict/cohort partners. Its Interactions section shows messages sent and received per peer over the retained history and the actor's last 10 observed events. Open in the actor's tool follows its existing link. Unknown actor routes show a short notice with a next step.
Board shows each group's work as columns from the producer's zone kinds and titles, with the reported card titles, tones and owner avatars/names. Cards open their existing producer links; owner buttons open agent detail. Column headings preserve the producer-reported total and show the supplied/filtered card count when it differs. Done shows the last five cards in supplied order, with earlier cards behind a disclosure. Each row scrolls within its own keyboard-focusable region on narrow screens, keeping the page width fixed. Project and state filters apply to Board, including ungrouped work.

Selecting an actor in Team, Flow, Board or Room opens its detail using the namespaced actor route. At 1024 px and wider, detail is a side sheet; below 1024 px, it is a full page. Escape or Back returns to the prior view and restores its scroll position and focus. Detail includes the actor's current task and producer link, marks, and current overlap/conflict/cohort partners. Its Interactions section shows messages sent and received per peer over the retained history and the actor's last 10 observed events. Open in the actor's tool follows its existing link. Unknown actor routes show a short notice with a next step.

Room contains the existing Flat, Room and, when WebGL2 is available, 3D scene controls; their sub-toggle persists separately as `acon.space.roomView`. Flat keeps the normal layout. Room uses CSS 3D floor and wall planes with upright actor/card buttons, without downloading Three.js. Rotate left/right changes yaw by 90 degrees; zoom has three steps. Focus the scene and use Left/Right or +/- for the same controls. Reduced motion removes camera transitions.

Expand Down Expand Up @@ -297,7 +299,7 @@ Versions and upgrades (#26):

Snapshot reuse (#105): the server shares an in-process cache between viewers, keyed by tool, provider and canonical snapshot input. Identical concurrent requests share one provider call. The default TTL is `min(pollMs / 2, 2000)` ms (`pollMs` defaults to 5000). A plugin can set `panels[].maxAgeMs`, `capabilities.inboxMaxAgeMs` or `capabilities.spaceMaxAgeMs` to a finite value from 0 to 60000 ms; 0 disables reuse while preserving concurrent-call coalescing. An explicitly declared panel owns its TTL even when its id is `space`; capability TTLs apply only to undeclared reserved panels with the corresponding capability enabled. The fields are additive API 1 options, and conformance checks their bounds.

During one extra TTL of grace, a cached reply can be served immediately while one background refresh runs. A failed refresh retains the last good value during the grace window. Cache diagnostics use `X-Acon-Cache: hit|miss|stale`, `X-Acon-Cache-Age-Ms` and `X-Acon-Sampled-At` (ISO time) response headers, also exposed to configured CORS origins. Producer snapshot bodies, including their own `stale` and `sampledAt` fields, stay unchanged. A producer `ok: false` snapshot stays a status-200 reply and is never retained as a cache entry. Every console action and each principal action not declared `readOnly` invalidate that tool's entries before returning, and a provider reload drops the old provider's entries. Principal snapshots have a separate namespace and include the authenticated principal in their canonical input. The current MCP `tools/call` route uses the principal action port; those calls remain actions rather than cached panel reads. A principal tool declared `readOnly` preserves the cache, matching its availability on a read-only node.
During one extra TTL of grace, a cached reply can be served immediately while one background refresh runs. A failed refresh retains the last good value during the grace window. Cache diagnostics use `X-Acon-Cache: hit|miss|stale`, `X-Acon-Cache-Age-Ms` and `X-Acon-Sampled-At` (ISO time) response headers, also exposed to configured CORS origins. The cache does not overwrite producer fields: ordinary panel bodies retain producer-owned `stale` and `sampledAt`; principal ports retain their existing public-value projection, and successful Space replies retain their existing allowlisted scene projection (which omits producer top-level `stale` and `sampledAt`). A producer `ok: false` snapshot stays a status-200 reply and is never retained as a cache entry. Every console action and each principal action not declared `readOnly` invalidate that tool's entries before returning, and a provider reload drops the old provider's entries. Principal snapshots have a separate namespace and include the authenticated principal in their canonical input. The current MCP `tools/call` route uses the principal action port; those calls remain actions rather than cached panel reads. A principal tool declared `readOnly` preserves the cache, matching its availability on a read-only node.

The cache holds at most 256 entries and 4 MiB, evicts least recently used entries, and writes nothing to disk. System > Snapshot cache shows entry/byte totals and hit, miss and stale counts by tool, panel and namespace. Its p50/p95 provider durations use a rolling window of the last 128 calls; cache hits do not add provider timing samples. Older nodes without these diagnostics remain usable.

Expand Down
137 changes: 137 additions & 0 deletions scripts/ui-check-space-board.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// #101 Delivery 4: per-project Board, Done disclosure and producer-card navigation.
import assert from "node:assert/strict";
import { join } from "node:path";

function isSpaceResponse(response, tool) {
if (new URL(response.url()).pathname !== `/p/${tool}/snapshot` || response.request().method() !== "POST") return false;
try { return response.request().postDataJSON()?.panel === "space"; } catch { return false; }
}

async function settle(page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
}

async function installDoneFixture(page) {
// This context adds public synthetic work items only; each producer link targets an existing fixture note.
await page.route("**/p/demo/snapshot", async (route) => {
let input;
try { input = route.request().postDataJSON(); } catch { await route.continue(); return; }
if (input?.panel !== "space") { await route.continue(); return; }
const response = await route.fetch();
const body = await response.json();
if (body.scene) {
const zone = body.scene.zones.find((item) => item.kind === "done" && item.group === "delivery");
assert(zone, "Board fixture has no delivery Done zone");
body.scene.cards = body.scene.cards.filter((card) => card.zone !== zone.id);
const tones = ["ok", "warn", "err"];
for (let i = 0; i < 7; i++) body.scene.cards.push({
id: `board-done-${i}`, title: `Board completed task ${i + 1}`, zone: zone.id, group: "delivery", owner: "claude", tone: tones[i % tones.length],
link: { panel: "notes", params: { id: "n1" } },
});
zone.count = 20; // The producer total deliberately exceeds the seven supplied cards.
}
await route.fulfill({ response, json: body });
});
}

async function enterBoard(page, ticket, textPercent) {
await page.goto((await ticket()).url);
await page.evaluate((size) => {
document.documentElement.style.fontSize = `${size}%`;
location.hash = "#/local/space?view=board";
}, textPercent);
await page.locator(".space-board:not([hidden])").waitFor();
// Both producers must have reached the merged model before an unchanged-poll measurement starts.
await page.locator('.space-board [data-board-card="demo:board-done-6"]').waitFor();
await page.locator('.space-board [data-board-card="echo:n1"]').waitFor({ state: "attached" });
await settle(page);
}

async function unchangedWrites(page) {
await page.evaluate(() => {
globalThis.boardProbeWrites = 0;
globalThis.boardProbeObserver = new MutationObserver((records) => { globalThis.boardProbeWrites += records.length; });
globalThis.boardProbeObserver.observe(document.querySelector("#view-space"), { subtree: true, attributes: true, childList: true, characterData: true });
});
const responses = await Promise.all(["demo", "echo"].map((tool) => page.waitForResponse((response) => isSpaceResponse(response, tool), { timeout: 20000 })));
await Promise.all(responses.map((response) => response.finished()));
await settle(page);
const writes = await page.evaluate(() => {
globalThis.boardProbeObserver.disconnect();
return globalThis.boardProbeWrites;
});
assert.equal(writes, 0, `Unchanged Board poll wrote ${writes} DOM mutations`);
return writes;
}

async function fitsPage(page, label) {
assert(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1), `${label} caused horizontal page scroll`);
}

async function checkDone(page) {
const done = page.locator('.space-board-group[data-board-group="demo:delivery"] .space-board-column[data-board-kind="done"]');
const details = done.locator("details");
const summary = details.locator("summary");
await summary.waitFor();
assert.equal(await details.evaluate((node) => node.open), false, "Done was not collapsed initially");
assert.equal(await done.locator("h3").innerText(), "Done (20) · 7 shown", "Done must distinguish producer total from supplied cards");
assert.equal(await done.locator("[data-board-card]:visible").count(), 5, "Collapsed Done must show its last five cards");
const lastFive = await done.locator("[data-board-card]:visible").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-board-card")));
assert.deepEqual(lastFive.sort(), Array.from({ length: 5 }, (_, i) => `demo:board-done-${i + 2}`), "Collapsed Done does not show the last five cards");
assert(await summary.getAttribute("data-board-focus"), "Done summary has no stable focus key");
await summary.focus();
await page.keyboard.press("Enter");
await done.locator('[data-board-card="demo:board-done-0"]').waitFor();
assert.equal(await details.evaluate((node) => node.open), true, "Keyboard did not expand Done");
assert.equal(await done.locator("[data-board-card]:visible").count(), 7, "Expanded Done does not show all cards");
await fitsPage(page, "Expanded Board Done");
await summary.click();
await done.locator('[data-board-card="demo:board-done-0"]').waitFor({ state: "hidden" });
return { doneCount: 20, suppliedCards: 7, collapsedCards: 5, expandedCards: 7 };
}

async function keyboardProducer(page) {
const card = page.locator('.space-board [data-board-card="demo:board-done-6"]');
assert(await card.getAttribute("data-board-focus"), "Board card has no stable focus key");
assert((await card.evaluate((node) => node.closest(".space-board-item")?.textContent ?? "")).includes("claude"), "Board card item does not show its owner name");
await card.focus();
await page.keyboard.press("Enter");
await page.waitForURL((url) => {
const [route, query] = url.hash.split("?");
return route === "#/local/demo/notes" && new URLSearchParams(query).get("id") === "n1";
});
await page.locator(".space-board").waitFor({ state: "hidden" });
await page.goBack();
await page.locator(".space-board:not([hidden])").waitFor();
return { keyboardProducer: true, producerLink: "demo/notes?id=n1" };
}

export async function runSpaceBoard({ browser, ticket, report, output }) {
report.spaceBoard = [];
for (const width of [375, 768, 1024, 1440]) for (const theme of ["light", "dark"]) for (const textPercent of [100, 200]) {
// Locator waits and evaluations run under the shell CSP; no bypass or waitForFunction is needed.
const context = await browser.newContext({ viewport: { width, height: 1000 }, colorScheme: theme, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(12000);
try {
await installDoneFixture(page);
await enterBoard(page, ticket, textPercent);
assert(await page.locator(".space-board-group").count() >= 2, "Board has no per-project groups");
const writes = await unchangedWrites(page);
await fitsPage(page, "Board");
const done = await checkDone(page);
const scrollers = await page.locator(".space-board-columns").evaluateAll((nodes) => nodes.map((node) => ({
width: node.clientWidth, content: node.scrollWidth, overflow: getComputedStyle(node).overflowX,
})));
assert(scrollers.length > 0, "Board has no column scroll regions");
assert(scrollers.every((scroller) => scroller.content <= scroller.width + 1 || ["auto", "scroll"].includes(scroller.overflow)), "Overflowing Board columns are not locally scrollable");
const screenshot = `space-board-${width}-${theme}-${textPercent}.png`;
await page.screenshot({ path: join(output, screenshot), fullPage: true });
report.captures.push({ key: `space-board-${width}-${theme}-${textPercent}`, screenshot, width, theme, textPercent, view: "space-board" });
const navigation = await keyboardProducer(page);
report.spaceBoard.push({ width, theme, textPercent, unchangedWrites: writes, ...done, ...navigation });
} catch (error) {
report.findings.push({ kind: "space-board", width, theme, textPercent, detail: error.message });
} finally { await context.close(); }
}
}
Loading
Loading