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
2 changes: 2 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 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).

- Make Team the default Space view while honoring a saved view choice in `acon.space.section`; the Space header now scrolls with the page. Add per-group attention and active-agent summaries, collapsed idle/away rows, and namespaced actor detail routes with responsive presentation and focus restoration; keep the roster in Room. Extend the browser probe for Team/detail navigation and persistence, including a reload with `view` removed (#101).
- Add optional public Space groups, actor roles and state-change timestamps, with a group filter and Room as the default view. Raise caps to 96 actors and 128 links; save the Flat/Room/3D sub-toggle separately as `acon.space.roomView`. These API 1 fields are additive: N-1 compatibility is unaffected because older consoles ignore unknown optional fields.
- Add synthetic H1/A1 lineage and comparison integration fixtures with explicit owner/project/hash routing, bounded pages and lazy evidence reads, plus isolated compact/wide keyboard and visual checks (#63).
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ 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>`. Flow and Board remain placeholders for their later deliveries. 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>`. 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.

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.

Selecting an actor in Team 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 is a placeholder for the Flow buffer and says that history appears once observed. Open in the actor's tool follows its existing link. Unknown actor routes show a short notice with a next step.
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.

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
169 changes: 169 additions & 0 deletions scripts/ui-check-space-flow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// #101 Delivery 3: public-link observations, Flow layout and detail return paths.
import assert from "node:assert/strict";
import { join } from "node:path";

function isSpaceResponse(response, tool = "demo") {
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 enterFlow(page, ticket, textPercent) {
await page.goto((await ticket()).url);
await page.evaluate((size) => {
document.documentElement.style.fontSize = `${size}%`;
location.hash = "#/local/space?view=flow";
}, textPercent);
await page.locator(".space-flow:not([hidden])").waitFor();
// Both producer responses must be rendered before observing an unchanged poll.
await page.locator('.space-flow [data-space-actor="demo:claude"]').waitFor();
await page.locator('.space-flow [data-space-actor="echo:claude"]').waitFor();
await settle(page);
}

async function observeWrites(page, selector) {
await page.evaluate((target) => {
globalThis.flowProbeWrites = 0;
globalThis.flowProbeObserver = new MutationObserver((records) => { globalThis.flowProbeWrites += records.length; });
globalThis.flowProbeObserver.observe(document.querySelector(target), { subtree: true, attributes: true, childList: true, characterData: true });
}, selector);
}

async function finishWrites(page) {
await settle(page);
return page.evaluate(() => {
globalThis.flowProbeObserver.disconnect();
return globalThis.flowProbeWrites;
});
}

async function unchangedWrites(page) {
await observeWrites(page, "#view-space");
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()));
const writes = await finishWrites(page);
assert.equal(writes, 0, `Unchanged Flow 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 keyboardDetail(page) {
const actor = page.locator(".space-flow [data-space-actor]").first();
const actorKey = await actor.getAttribute("data-space-actor");
const focusKey = await actor.getAttribute("data-flow-focus");
assert(actorKey && focusKey, "Flow actor has no stable focus key");
await actor.focus();
await page.keyboard.press("Enter");
await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor();
assert.equal(await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] ?? "").get("actor")), actorKey, "Keyboard open selected the wrong actor");
await fitsPage(page, "Flow detail");
assert(await page.locator(".space-agent-detail").getByRole("heading", { name: "Interactions", exact: true }).isVisible(), "Flow detail has no Interactions section");
assert(await page.evaluate(() => [...document.querySelector("#view-space").children].filter((node) => !node.matches(".space-agent-detail")).every((node) => node.inert)), "A root child remains interactive behind the actor detail");
await page.keyboard.press("Escape");
await page.locator(".space-agent-detail").waitFor({ state: "hidden" });
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-flow-focus")), focusKey, "Escape did not restore Flow actor focus");
await page.keyboard.press("Enter");
await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor();
await page.locator(".space-detail-back").click();
await page.locator(".space-agent-detail").waitFor({ state: "hidden" });
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-flow-focus")), focusKey, "Back did not restore Flow actor focus");
return { actorKey, keyboardReturn: true, backReturn: true };
}

async function changedLinkProbe({ browser, ticket, report }) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, colorScheme: "light", reducedMotion: "no-preference" });
const page = await context.newPage();
page.setDefaultTimeout(12000);
let stage = 0;
const at = new Date().toISOString();
try {
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 observedStage = stage;
const response = await route.fetch();
const body = await response.json();
if (body.scene) {
if (observedStage >= 1) body.scene.links.push({ kind: "message", from: "claude", to: "codex", label: "Flow changed-link probe", at });
if (observedStage >= 2) body.scene.actors.find((actor) => actor.id === "local").name = "Flow rerender probe";
if (observedStage >= 3) body.scene.links.push({ kind: "message", from: "codex", to: "local", label: "Flow hidden-link probe", at });
}
await route.fulfill({ response, json: body });
});
await enterFlow(page, ticket, 100);
await page.evaluate(() => {
globalThis.flowProbeAnimations = [];
document.querySelector(".space-flow").addEventListener("animationstart", (event) => {
const target = event.target.closest("[data-flow-event]");
if (target) globalThis.flowProbeAnimations.push({ id: target.dataset.flowEvent, name: event.animationName });
});
});
stage = 1;
const changed = page.locator(".space-flow-activity [data-flow-event]").filter({ hasText: "Flow changed-link probe" });
await changed.waitFor();
const eventId = await changed.getAttribute("data-flow-event");
assert.equal(await changed.getAttribute("data-new"), "true", "Changed link was not highlighted on arrival");
// Waiting for the event's finite CSS animation also ensures animationstart was observed.
await changed.evaluate(async (node) => { await Promise.all(node.getAnimations().map((animation) => animation.finished.catch(() => {}))); });
const writes = await unchangedWrites(page);
assert.equal(await changed.getAttribute("data-flow-event"), eventId, "An unchanged poll duplicated the link event");
assert.equal(await changed.count(), 1, "A changed link appeared more than once");
stage = 2;
await page.locator(".space-flow-actor").filter({ hasText: "Flow rerender probe" }).waitFor();
assert.equal(await changed.getAttribute("data-new"), "false", "An old event highlighted again after an unrelated change");
const starts = await page.evaluate((id) => globalThis.flowProbeAnimations.filter((event) => event.id === id), eventId);
assert.equal(starts.length, 1, `Changed link animation started ${starts.length} times`);
await page.locator("[data-space-section-tab='team']").click();
await page.locator(".space-flow").waitFor({ state: "hidden" });
await page.locator(".space-team:not([hidden])").waitFor();
await observeWrites(page, ".space-flow");
stage = 3;
const response = await page.waitForResponse(async (item) => {
if (!isSpaceResponse(item)) return false;
return (await item.json()).scene?.links?.some((link) => link.label === "Flow hidden-link probe");
}, { timeout: 20000 });
await response.finished();
const hiddenWrites = await finishWrites(page);
assert.equal(hiddenWrites, 0, "Hidden Flow wrote DOM after an observed link change");
await page.locator("[data-space-section-tab='flow']").click();
await page.locator(".space-flow-activity [data-flow-event]").filter({ hasText: "Flow hidden-link probe" }).waitFor();
report.spaceFlowMotion = { changedLink: true, eventId, highlightStarts: starts.length, unchangedWrites: writes, hiddenWrites };
} catch (error) {
report.findings.push({ kind: "space-flow-motion", detail: error.message });
} finally { await context.close(); }
}

export async function runSpaceFlow({ browser, ticket, report, output }) {
report.spaceFlow = [];
for (const width of [375, 768, 1024, 1440]) for (const theme of ["light", "dark"]) for (const textPercent of [100, 200]) {
// All assertions use locator waits and browser evaluations under the shell CSP.
const context = await browser.newContext({ viewport: { width, height: 1000 }, colorScheme: theme, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(12000);
try {
await enterFlow(page, ticket, textPercent);
assert(await page.locator(".space-flow-group").count() >= 2, "Flow has no per-project panels");
assert((await page.locator(".space-flow").innerText()).includes("History starts when this page opens"), "Flow does not state the history boundary");
await page.locator(".space-flow-activity [data-flow-event]").first().waitFor();
const writes = await unchangedWrites(page);
await fitsPage(page, "Flow");
const screenshots = `space-flow-${width}-${theme}-${textPercent}.png`;
await page.screenshot({ path: join(output, screenshots), fullPage: true });
report.captures.push({ key: `space-flow-${width}-${theme}-${textPercent}`, screenshot: screenshots, width, theme, textPercent, view: "space-flow" });
const reducedAnimations = await page.locator(".space-flow-activity").evaluate((node) => node.getAnimations({ subtree: true }).length);
assert.equal(reducedAnimations, 0, "Reduced-motion Flow animates new activity");
const detail = await keyboardDetail(page);
report.spaceFlow.push({ width, theme, textPercent, unchangedWrites: writes, reducedAnimations, ...detail });
} catch (error) {
report.findings.push({ kind: "space-flow", width, theme, textPercent, detail: error.message });
} finally { await context.close(); }
}
await changedLinkProbe({ browser, ticket, report });
}
2 changes: 2 additions & 0 deletions scripts/ui-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { runVisualCheck } from "./ui-check-visual.mjs";
import { runLoginCheck } from "./ui-check-login.mjs";
import { runSpaceFoundation } from "./ui-check-space-foundation.mjs";
import { runSpaceTeam } from "./ui-check-space-team.mjs";
import { runSpaceFlow } from "./ui-check-space-flow.mjs";
import { runStaticCheck } from "./ui-check-static.mjs";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
Expand Down Expand Up @@ -1254,6 +1255,7 @@ try {
await runSpaceViews(ticket);
await runSpaceFoundation({ browser, ticket, report, output: options.output });
await runSpaceTeam({ browser, ticket, report, output: options.output });
await runSpaceFlow({ browser, ticket, report, output: options.output });
}
if (!focused || options.visualOnly) await runVisualCheck({ browser, root, report, output: options.output, env, scratch });
if (!focused || options.loginOnly) await runLoginCheck({ browser, root, report, output: options.output, forceOverflow: options.forceOverflow, env, scratch });
Expand Down
28 changes: 28 additions & 0 deletions shell/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -656,3 +656,31 @@ body.pip { margin: 0; overflow-y: auto; }
.space-agent-detail-header { flex: none; overflow-wrap: anywhere; }
.space-agent-detail-body { flex: none; overflow-y: visible; }
.space-agent-detail :is(h2, h3, p, .btn) { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }

/* Flow uses DOM edge rows: endpoints, a stroke and a textual count/kind. */
.space-flow, .space-flow-layout, .space-flow-panels, .space-flow-group, .space-flow-activity { min-width: 0; }
.space-flow-layout { display: grid; gap: var(--acon-space-5); align-items: start; }
.space-flow-panels { display: grid; gap: var(--acon-space-4); }
.space-flow-group, .space-flow-activity { padding: var(--acon-space-3); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-3); background: var(--acon-surface); }
.space-flow h2 { margin: 0 0 var(--acon-space-3); font-size: var(--acon-text-lg); overflow-wrap: anywhere; }
.space-flow-actors { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr)); gap: var(--acon-space-2); }
.space-flow-actor { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--acon-space-2); min-width: 0; min-height: 56px; padding: var(--acon-space-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); background: var(--acon-surface-2); color: var(--acon-text); font: inherit; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
.space-flow-actor > .sub { grid-column: 2; }
.space-flow-actor:focus-visible { outline: 2px solid var(--acon-focus); outline-offset: 2px; }
.space-flow-edges { list-style: none; padding: 0; margin: var(--acon-space-3) 0 0; }
.space-flow-edge { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr); align-items: center; gap: var(--acon-space-2); padding: var(--acon-space-2) 0; font-size: var(--acon-text-sm); overflow-wrap: anywhere; }
.space-flow-edge > :last-child { text-align: right; }
.space-flow-connector { display: flex; flex-direction: column; text-align: center; min-width: 0; }
.space-flow-stroke { border: 1px solid currentColor; color: var(--acon-text-2); }
.space-flow-edge[data-kind="overlap"] .space-flow-stroke { border-style: dashed; }
.space-flow-edge[data-kind="cohort"] .space-flow-stroke { border-style: dotted; }
.space-flow-edge[data-kind="conflict"] .space-flow-stroke { border-style: double; border-width: 3px; }
.space-flow-edge[data-kind="conflict"] .space-flow-connector { color: var(--acon-err); }
.space-flow-edge[data-kind="conflict"] .space-flow-stroke { color: var(--acon-err); }
.space-flow-events { padding-left: var(--acon-space-4); margin: 0; }
.space-flow-events li { padding: var(--acon-space-2); overflow-wrap: anywhere; border-radius: var(--acon-radius-1); }
.space-flow-events li > * { display: block; }
.space-flow-events [data-new="true"] { animation: space-flow-arrival var(--acon-duration-slow) var(--acon-ease-out) both; }
@keyframes space-flow-arrival { from { background: var(--acon-accent-soft); } to { background: transparent; } }
@media (prefers-reduced-motion: reduce) { .space-flow-events [data-new="true"] { animation: none; } }
@media (min-width: 1024px) { .space-flow-layout { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); } }
Loading
Loading