From 461161bb5e748557f738ff2c40ed070a82af4c83 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 20:03:14 +0900 Subject: [PATCH 1/3] feat(space): add Flow activity view and agent interactions --- CHANGELOG.md | 2 + README.md | 6 +- scripts/ui-check-space-flow.mjs | 169 +++++++++++++++++++++++++++++++ scripts/ui-check.mjs | 2 + shell/shell.css | 28 ++++++ shell/space-flow.js | 170 ++++++++++++++++++++++++++++++++ shell/space.js | 26 +++-- src/server.ts | 2 +- src/static.ts | 2 +- test/space-flow.test.ts | 125 +++++++++++++++++++++++ test/space-server.test.ts | 1 + test/space-team.test.ts | 7 +- test/static.test.ts | 2 +- 13 files changed, 527 insertions(+), 15 deletions(-) create mode 100644 scripts/ui-check-space-flow.mjs create mode 100644 shell/space-flow.js create mode 100644 test/space-flow.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b06f006..36e9cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index 515c349..2297ce7 100644 --- a/README.md +++ b/README.md @@ -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=`; 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=`. 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=`; 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=`. 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. diff --git a/scripts/ui-check-space-flow.mjs b/scripts/ui-check-space-flow.mjs new file mode 100644 index 0000000..b80c55d --- /dev/null +++ b/scripts/ui-check-space-flow.mjs @@ -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 }); +} diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index e0bef40..a9b8973 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -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)), ".."); @@ -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 }); diff --git a/shell/shell.css b/shell/shell.css index 40b6879..7d04c2c 100644 --- a/shell/shell.css +++ b/shell/shell.css @@ -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); } } diff --git a/shell/space-flow.js b/shell/space-flow.js new file mode 100644 index 0000000..62162cc --- /dev/null +++ b/shell/space-flow.js @@ -0,0 +1,170 @@ +import { focusSpaceControl } from "./space-team.js"; + +const RETENTION = 30 * 60000; +const WINDOW = 15 * 60000; +const LIMIT = 200; +const relationKinds = new Set(["overlap", "conflict", "cohort"]); +export const FLOW_HISTORY = "History starts when this page opens. Up to 200 events from the last 30 minutes; message counts cover 15 minutes."; + +/** Observe only public link fields. No payload/body access, requests or persisted history. */ +export function createActivityBuffer({ now = Date.now } = {}) { + let history = [], sequence = 0; + let currentMessages = new Set(), relationships = new Map(); + // Retained message identities plus the current snapshot prevent unchanged polls + // from reintroducing an event, even when a burst exceeds the history cap. + function observe(model) { + const at = now(); + history = history.filter((event) => at - event.observedAt < RETENTION); + const retained = new Set(history.filter((event) => event.kind === "message").map((event) => event.identity)); + const actors = new Map(model.actors.map((actor) => [actor.id, actor])); + const messages = new Set(), relations = new Map(); + for (const link of model.links) { + if (link.kind !== "message" && !relationKinds.has(link.kind)) continue; + const { kind, from, to, label, at: sourceAt } = link; + const identity = JSON.stringify({ kind, from, to, label, at: sourceAt }); + const pair = JSON.stringify([from, to].sort()); + let fresh = false; + if (kind === "message") { + fresh = !messages.has(identity) && !currentMessages.has(identity) && !retained.has(identity); + messages.add(identity); + } else { + // A pair may carry several independent relation kinds in one snapshot. + const kinds = relations.get(pair) ?? new Set(); + fresh = !kinds.has(kind) && !relationships.get(pair)?.has(kind); + kinds.add(kind); relations.set(pair, kinds); + } + if (!fresh) continue; + const sender = actors.get(from), recipient = actors.get(to); + history.unshift({ id: ++sequence, identity, kind, from, to, label, + fromName: sender?.name ?? from, toName: recipient?.name ?? to, + fromGroup: sender?.group, toGroup: recipient?.group, observedAt: at }); + } + history = history.slice(0, LIMIT); + currentMessages = messages; relationships = relations; + } + function events() { + const at = now(); + history = history.filter((event) => at - event.observedAt < RETENTION); + return history.map((event) => ({ ...event })); + } + function interactions(actor) { + const recent = events().filter((event) => event.from === actor || event.to === actor); + const peers = new Map(); + for (const event of recent) { + if (event.kind !== "message") continue; + const sent = event.from === actor; + const id = sent ? event.to : event.from; + const peer = peers.get(id) ?? { id, name: sent ? event.toName : event.fromName, sent: 0, received: 0 }; + if (sent) peer.sent++; else peer.received++; + peers.set(id, peer); + } + return { peers: [...peers.values()].sort((a, b) => a.id.localeCompare(b.id)), events: recent.slice(0, 10) }; + } + return { observe, events, interactions }; +} + +export function flowRelativeTime(at, now = Date.now()) { + const minutes = Math.max(0, Math.floor((now - at) / 60000)); + return minutes < 1 ? "just now" : `${minutes} min ago`; +} + +/** Message counts are directed; relationship edges reflect the current snapshot. */ +export function flowEdges(model, events, now = Date.now()) { + const actors = new Set(model.actors.map((actor) => actor.id)); + const edges = new Map(); + for (const event of events) { + if (event.kind !== "message" || now - event.observedAt >= WINDOW || !actors.has(event.from) || !actors.has(event.to)) continue; + const key = JSON.stringify([event.kind, event.from, event.to]); + const edge = edges.get(key) ?? { kind: "message", from: event.from, to: event.to, count: 0 }; + edge.count++; edges.set(key, edge); + } + for (const link of model.links) { + if (!relationKinds.has(link.kind)) continue; + const key = JSON.stringify([link.kind, ...[link.from, link.to].sort()]); + edges.set(key, { kind: link.kind, from: link.from, to: link.to, label: link.label }); + } + return [...edges.values()]; +} + +export function createSpaceFlow(root, host) { + const { h } = host; + let signature = "", latest = null, visible = false, timer = null, destroyed = false; + let highlighted = 0; + const now = host.now ?? Date.now; + let displayAt = now(); + const schedule = host.setInterval ?? globalThis.setInterval; + const cancel = host.clearInterval ?? globalThis.clearInterval; + + function update(model, group = "", state = "") { + latest = { model, group, state }; + if (!visible || destroyed) return; + const at = displayAt, events = host.activity.events(); + const actorIds = new Set(model.actors.map((actor) => actor.id)); + const recent = events.filter((event) => (!group || event.fromGroup === group || event.toGroup === group) && + (!state || actorIds.has(event.from) || actorIds.has(event.to))); + const edges = flowEdges(model, events, at); + const snapshot = [model.groups, model.actors, edges, recent.map((event) => [event, flowRelativeTime(event.observedAt, at)])]; + const next = JSON.stringify(snapshot); + if (next === signature) return; + signature = next; + const active = root.ownerDocument.activeElement; + const focusKey = root.contains(active) ? active?.getAttribute("data-flow-focus") : null; + const names = new Map(model.actors.map((actor) => [actor.id, actor.name])); + const panels = h("div", { class: "space-flow-panels" }); + const groups = model.groups.filter((item) => !group || item.id === group); + if (model.actors.some((actor) => !actor.group)) groups.push({ id: "", title: "Ungrouped" }); + for (const item of groups) { + const members = model.actors.filter((actor) => (actor.group ?? "") === item.id); + const ids = new Set(members.map((actor) => actor.id)); + const panel = h("section", { class: "space-flow-group", "data-flow-group": item.id }, h("h2", { text: item.title })); + const actors = h("div", { class: "space-flow-actors" }); + for (const actor of members) actors.append(h("button", { type: "button", class: "space-flow-actor", "data-space-actor": actor.id, + "data-flow-focus": `actor:${actor.id}`, onclick: (event) => host.openActor(actor.id, event.currentTarget) }, + h("span", { class: "space-team-avatar", "aria-hidden": "true" }, host.sprite(actor)), + h("span", { text: actor.name }), h("span", { class: "sub", text: `${actor.state}${actor.stale ? " (stale)" : ""}` }))); + panel.append(actors); + const connections = h("ul", { class: "space-flow-edges", "aria-label": `${item.title} connections` }); + for (const edge of edges.filter((edge) => ids.has(edge.from) || ids.has(edge.to))) { + connections.append(h("li", { class: "space-flow-edge", "data-kind": edge.kind }, + h("span", { text: names.get(edge.from) ?? edge.from }), + h("span", { class: "space-flow-connector" }, h("span", { class: "space-flow-stroke", "aria-hidden": "true", text: edge.kind === "message" ? "→" : "↔" }), + h("span", { text: edge.kind === "message" ? `${edge.count} message${edge.count === 1 ? "" : "s"}` : `${edge.kind}${edge.label ? `: ${edge.label}` : ""}` })), + h("span", { text: names.get(edge.to) ?? edge.to }))); + } + panel.append(connections); + if (!members.length) panel.append(h("p", { class: "sub", text: "No agents match these filters." })); + else if (!connections.children.length) panel.append(h("p", { class: "sub", text: "No connections observed in this window." })); + panels.append(panel); + } + const activity = h("section", { class: "space-flow-activity" }, h("h2", { text: "Recent activity" })); + const list = h("ol", { class: "space-flow-events" }); + for (const event of recent) list.append(h("li", { "data-flow-event": String(event.id), "data-new": String(event.id > highlighted), "data-kind": event.kind }, + h("strong", { text: `${event.fromName} → ${event.toName}` }), + h("span", { text: `${event.kind}${event.label ? `: ${event.label}` : ""}` }), + h("span", { class: "sub", text: flowRelativeTime(event.observedAt, at) }))); + if (recent.length) activity.append(list); + else activity.append(h("p", { class: "sub", text: "No activity observed yet." })); + const layout = h("div", { class: "space-flow-layout" }, panels, activity); + root.replaceChildren(h("p", { class: "sub", text: FLOW_HISTORY }), layout); + highlighted = Math.max(highlighted, ...events.map((event) => event.id)); + if (focusKey) focusSpaceControl(root.querySelector(`[data-flow-focus="${CSS.escape(focusKey)}"]`), host.sectionTab()); + } + function setVisible(on) { + if (destroyed || on === visible) return; + visible = on; + if (timer !== null) { cancel(timer); timer = null; } + if (visible) { + displayAt = now(); + timer = schedule(() => { + if (!visible || !latest) return; + displayAt = now(); + update(latest.model, latest.group, latest.state); + host.onTime?.(); + }, 60000); + } + } + function destroy() { + setVisible(false); destroyed = true; latest = null; root.replaceChildren(); + } + return { update, setVisible, destroy }; +} diff --git a/shell/space.js b/shell/space.js index aee15c3..f4ec4cc 100644 --- a/shell/space.js +++ b/shell/space.js @@ -1,3 +1,4 @@ +import { createActivityBuffer, createSpaceFlow, flowRelativeTime, FLOW_HISTORY } from "./space-flow.js"; import { createSpaceTeam, focusSpaceControl } from "./space-team.js"; import { createSpaceViewController } from "./space-view.js"; @@ -123,11 +124,12 @@ export function createSpaceRenderer(root, host) { const project = h("select", { id: "space-project", "aria-label": "Project", onchange: () => choose({ group: project.value }) }); const header = h("header", { class: "space-header" }, h("h1", { class: "lead", id: "space-lead", text: "Space" }), summary, h("div", { class: "space-header-controls" }, tabs, h("label", { class: "space-project-label", for: "space-project" }, "Project", project))); + const flowRoot = h("section", { class: "space-flow", hidden: true }); const teamRoot = h("section", { class: "space-team", tabindex: "-1" }); const detailRoot = h("aside", { class: "space-agent-detail", role: "dialog", "aria-modal": "true", "aria-labelledby": "space-agent-title", tabindex: "-1", hidden: true }); const placeholder = h("p", { class: "notice", hidden: true }); const content = h("div", { class: "space-content" }, scroll, roster); - root.replaceChildren(header, placeholder, notice, teamRoot, content, empty, detailRoot); + root.replaceChildren(header, placeholder, notice, teamRoot, flowRoot, content, empty, detailRoot); let selection = { view: "team", group: "", state: "", actor: "" }; let savedView = "team"; let returnPoint = null, restorePoint = null, detailSignature = ""; @@ -172,9 +174,11 @@ export function createSpaceRenderer(root, host) { const pet = pets.find((p) => p.id === actor.pet) ?? (pets.length ? pets[hash(actor.id) % pets.length] : null); return pet ? host.petSprite(pet, actor.stale ? "idle" : poseFor[actor.state]) : h("span", { class: "space-token", "aria-hidden": "true", text: actor.name.slice(0, 1).toUpperCase() }); }; + const activity = createActivityBuffer(); + const flow = createSpaceFlow(flowRoot, { h, sprite, openActor, activity, sectionTab: () => sectionButtons.get(selection.view), onTime: () => { if (lastModel && !root.hidden) renderDetail(lastModel); } }); const team = createSpaceTeam(teamRoot, { h, sprite, openActor, choose, sectionTab: () => sectionButtons.get(selection.view) }); function openActor(id, element) { - if (!selection.actor) returnPoint = { element, focusKey: element?.getAttribute("data-team-focus"), actor: id, pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, columnTop: root.closest(".column")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; + if (!selection.actor) returnPoint = { element, focusKey: element?.getAttribute("data-team-focus") ?? element?.getAttribute("data-flow-focus"), actor: id, pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, columnTop: root.closest(".column")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; choose({ actor: id }); } function closeActor() { if (returnPoint) history.back(); else choose({ actor: "" }); } @@ -201,7 +205,8 @@ export function createSpaceRenderer(root, host) { const links = model.links.filter((l) => ["overlap", "conflict", "cohort"].includes(l.kind) && (l.from === actor?.id || l.to === actor?.id)); const partners = links.map((l) => ({ kind: l.kind, actor: model.actors.find((a) => a.id === (l.from === actor.id ? l.to : l.from)) })); const group = model.groups.find((g) => g.id === actor?.group); - const sig = JSON.stringify([selection.actor, actor, card, partners, group, Boolean(model.pending)]); + const interactions = selection.actor ? activity.interactions(selection.actor) : { peers: [], events: [] }; + const sig = JSON.stringify([selection.actor, actor, card, partners, group, Boolean(model.pending), interactions, interactions.events.map((event) => flowRelativeTime(event.observedAt))]); if (sig === detailSignature) return; detailSignature = sig; const wasHidden = detailRoot.hidden; @@ -224,7 +229,11 @@ export function createSpaceRenderer(root, host) { h("div", { class: "space-agent-detail-body" }, h("section", {}, h("h3", { text: "Current task" }), card ? h("button", { type: "button", class: "btn", "data-detail-focus": "task", text: card.title, onclick: () => host.navigate(card.link.tool, card.link.panel, card.link.params) }) : h("p", { class: "sub", text: "No current task reported." })), h("section", {}, h("h3", { text: "Marks" }), ...marks), h("section", {}, h("h3", { text: "Partners" }), ...(partners.length ? partners.map((p) => h("p", { text: `${p.kind}: ${p.actor?.name ?? "Unknown actor"}` })) : [h("p", { class: "sub", text: "No current partners reported." })])), - h("section", {}, h("h3", { text: "Interactions" }), h("p", { class: "sub", text: "History appears once observed." })), + h("section", { class: "space-detail-interactions" }, h("h3", { text: "Interactions" }), h("p", { class: "sub", text: FLOW_HISTORY }), + ...interactions.peers.map((peer) => h("p", { text: `${peer.name}: ${peer.sent} sent, ${peer.received} received` })), + ...(interactions.events.length ? [h("ol", { class: "space-flow-events" }, ...interactions.events.map((event) => h("li", {}, + h("strong", { text: `${event.fromName} → ${event.toName}` }), h("span", { text: `${event.kind}${event.label ? `: ${event.label}` : ""}` }), + h("span", { class: "sub", text: flowRelativeTime(event.observedAt) }))))] : [h("p", { class: "sub", text: "No activity observed yet." })])), h("button", { type: "button", class: "btn", "data-detail-focus": "open", text: `Open in ${actor.link.tool}`, onclick: () => host.navigate(actor.link.tool, actor.link.panel, actor.link.params) }))); } if (wasHidden || hadFocus) (focusKey ? detailRoot.querySelector(`[data-detail-focus="${CSS.escape(focusKey)}"]`) ?? back : back).focus({ preventScroll: true }); @@ -232,7 +241,7 @@ export function createSpaceRenderer(root, host) { function restore() { if (!restorePoint) return; const point = restorePoint; restorePoint = null; - const keyed = point.focusKey ? root.querySelector(`[data-team-focus="${CSS.escape(point.focusKey)}"]`) : null; + const keyed = point.focusKey ? root.querySelector(`[data-team-focus="${CSS.escape(point.focusKey)}"],[data-flow-focus="${CSS.escape(point.focusKey)}"]`) : null; const element = point.element?.isConnected ? point.element : keyed ?? (point.actor ? [...root.querySelectorAll("[data-space-actor],.space-actor,.space-roster-actor")].find((el) => el.dataset.spaceActor === point.actor || el.dataset.key === point.actor) : null); focusSpaceControl(element, sectionButtons.get(selection.view)); const main = root.closest("main"); if (main) main.scrollTop = point.mainTop; @@ -243,6 +252,8 @@ export function createSpaceRenderer(root, host) { } function update(model, force = false) { lastModel = model; + activity.observe(model); + flow.setVisible(selection.view === "flow" && !root.hidden); const pack = host.packs()[0]; const { sampledAt, samples: receivedSamples, ...facts } = model; let sourceGap = false, firstSource = false; @@ -259,7 +270,7 @@ export function createSpaceRenderer(root, host) { signature = sig; root.dataset.spaceSection = selection.view; const room = selection.view === "room"; - content.hidden = !room; teamRoot.hidden = selection.view !== "team"; placeholder.hidden = room || selection.view === "team"; + content.hidden = !room; teamRoot.hidden = selection.view !== "team"; flowRoot.hidden = selection.view !== "flow"; placeholder.hidden = selection.view !== "board"; // A section switch (e.g. a Team Board/Flow shortcut) must not leave focus on a now-hidden control. const active = document.activeElement; if (active && active !== root && root.contains(active) && !detailRoot.contains(active) && active.offsetParent === null) sectionButtons.get(selection.view)?.focus({ preventScroll: true }); @@ -281,6 +292,7 @@ export function createSpaceRenderer(root, host) { const unknownGroup = !model.pending && selection.group && !model.groups.some((g) => g.id === selection.group); renderDetail(model); model = filterSpaceModel(model, selection.group, selection.state); + if (selection.view === "flow") flow.update(model, selection.group, selection.state); if (selection.view === "team") team.update({ ...model, groups: selection.group ? model.groups.filter((g) => g.id === selection.group) : model.groups }); const hidden = Object.entries(model.dropped).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k} omitted`); const message = [...(unknownGroup ? ["Project not in the current scene; showing none. Choose All projects."] : []), ...(model.failed.length ? ["Some space data could not refresh. Last reported states are marked stale; details are in System."] : []), ...hidden].join(" "); @@ -394,5 +406,5 @@ export function createSpaceRenderer(root, host) { if (lastModel && !root.hidden) update(lastModel, true); }); observer.observe(root); - return { update, setRoute, enter, hide() { views?.suspend(); }, get viewStats() { return views?.stats ?? { view: "flat", renderCount: 0, pendingFrames: 0 }; }, destroy() { observer.disconnect(); document.removeEventListener("keydown", onDetailKey, true); team.destroy(); views?.destroy(); root.replaceChildren(); } }; + return { update, setRoute, enter, hide() { flow.setVisible(false); views?.suspend(); }, get viewStats() { return views?.stats ?? { view: "flat", renderCount: 0, pendingFrames: 0 }; }, destroy() { observer.disconnect(); document.removeEventListener("keydown", onDetailKey, true); team.destroy(); flow.destroy(); views?.destroy(); root.replaceChildren(); } }; } diff --git a/src/server.ts b/src/server.ts index e63eb50..885545e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -101,7 +101,7 @@ export function startServer(options: ServerOptions) { const log = options.log ?? ((line: string) => console.error(`acon: ${line}`)); const startedAt = new Date(now()).toISOString(); const serve = options.serve; - const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-team.js": ["space-team.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const; + const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-team.js": ["space-team.js", "text/javascript; charset=utf-8"], "/space-flow.js": ["space-flow.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const; const files: Record = Object.fromEntries(Object.entries(shell).map(([path, [name, type]]) => [path, { body: asset(name), type }])); // Sandboxed visual frame: fixed files only, served with FRAME_CSP and framable by the console itself. files["/visual/frame"] = { body: asset("visual-frame.html"), type: "text/html; charset=utf-8", frame: true }; diff --git a/src/static.ts b/src/static.ts index 17f6c78..0af0b43 100644 --- a/src/static.ts +++ b/src/static.ts @@ -39,7 +39,7 @@ export function staticCsp(config: StaticConfig): string { } const FILES: [string, string][] = [ - ...["shell.js", "static.js", "shell.css", "space.js", "space-team.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]), + ...["shell.js", "static.js", "shell.css", "space.js", "space-team.js", "space-flow.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]), ...["three.module.js", "three.core.js", "GLTFLoader.js", "BufferGeometryUtils.js", "SkeletonUtils.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/${file}`, `vendor/${file}`]), ...["icons.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/icons/${file}`, `vendor/icons/${file}`]), ["visual-frame.html", "visual/frame.html"], ["visual-frame.css", "visual/frame.css"], ["visual-frame.js", "visual/frame.js"], diff --git a/test/space-flow.test.ts b/test/space-flow.test.ts new file mode 100644 index 0000000..456b202 --- /dev/null +++ b/test/space-flow.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { harness } from "./space-team.test.ts"; + +const source = readFileSync(new URL("../shell/space-flow.js", import.meta.url), "utf8"); +const { createActivityBuffer, flowEdges, createSpaceFlow } = runInNewContext(`${source.replace(/^import .*;$/gm, "").replace(/^export /gm, "")}\n({createActivityBuffer,flowEdges,createSpaceFlow})`); +const actors = [{ id: "a", name: "Sender", group: "alpha" }, { id: "b", name: "Recipient", group: "alpha" }]; +const message = (at = "one", from = "a", to = "b") => ({ kind: "message", from, to, at, label: "Task" }); +const model = (links: unknown[]) => ({ actors, links }); + +test("buffer observes public JSON identities once across unchanged and disappearing links", () => { + let now = 1000; + const buffer = createActivityBuffer({ now: () => now }); + const link = { ...message(), get body() { throw new Error("private body read"); } }; + buffer.observe(model([link, link])); + now++; + buffer.observe(model([{ ...message() }])); + buffer.observe(model([])); + buffer.observe(model([link])); + expect(buffer.events()).toHaveLength(1); + expect(buffer.events()[0].observedAt).toBe(1000); + expect(JSON.stringify(buffer.events())).not.toContain("body"); + buffer.observe(model([message("two")])); + expect(buffer.events().map((event: any) => event.id)).toEqual([2, 1]); +}); + +test("relations record appearances and kind changes, not label or direction changes", () => { + const buffer = createActivityBuffer({ now: () => 1000 }); + const relation = (kind: string, extra = {}) => ({ kind, from: "a", to: "b", ...extra }); + buffer.observe(model([relation("overlap")])); + buffer.observe(model([relation("overlap", { label: "changed", from: "b", to: "a" })])); + expect(buffer.events()).toHaveLength(1); + buffer.observe(model([relation("conflict"), relation("cohort")])); + buffer.observe(model([relation("conflict"), relation("cohort")])); + expect(buffer.events().map((event: any) => event.kind)).toEqual(["cohort", "conflict", "overlap"]); + buffer.observe(model([])); + buffer.observe(model([relation("conflict")])); + expect(buffer.events()).toHaveLength(4); +}); + +test("buffer retains newest 200 events for strictly less than 30 minutes and cannot replay a large current snapshot", () => { + let now = 0; + const buffer = createActivityBuffer({ now: () => now }); + const links = Array.from({ length: 260 }, (_, i) => message(String(i))); + buffer.observe(model(links)); + expect(buffer.events()).toHaveLength(200); + expect(buffer.events()[0].id).toBe(260); + buffer.observe(model(links)); + expect(buffer.events()[0].id).toBe(260); + now = 30 * 60000; + expect(buffer.events()).toHaveLength(0); + buffer.observe(model(links)); + expect(buffer.events()).toHaveLength(0); +}); + +test("15-minute message edges are directed and detail counts cover retained history with last ten events", () => { + let now = 0; + const buffer = createActivityBuffer({ now: () => now }); + buffer.observe(model([message("old")])); + now = 15 * 60000; + buffer.observe(model(Array.from({ length: 12 }, (_, i) => message(String(i), i % 2 ? "a" : "b", i % 2 ? "b" : "a")))); + const edges = flowEdges(model([]), buffer.events(), now); + expect(edges.map((edge: any) => [edge.from, edge.to, edge.count]).sort()).toEqual([["a", "b", 6], ["b", "a", 6]]); + expect(buffer.interactions("a").peers).toEqual([{ id: "b", name: "Recipient", sent: 7, received: 6 }]); + expect(buffer.interactions("a").events).toHaveLength(10); + expect(buffer.interactions("unknown")).toEqual({ peers: [], events: [] }); + now += 15 * 60000; + expect(flowEdges(model([]), buffer.events(), now)).toHaveLength(0); +}); + +test("Flow supports keyboard detail, retained interactions and zero writes on unchanged polls", () => { + const h = harness(); + const scene = { ...h.model, links: [...h.model.links, { ...message(), from: "demo:one", to: "demo:two" }] }; + h.renderer.setRoute({ view: "flow" }); h.renderer.update(scene); + const root = h.root.querySelector(".space-flow")!; + expect(root.hidden).toBe(false); + expect(root.textContent).toContain("1 message"); + expect(root.textContent).toContain("cohort"); + expect(root.textContent).toContain("History starts when this page opens"); + const writes = h.writes; + h.renderer.update({ ...scene, sampledAt: 3 }); + expect(h.writes).toBe(writes); + const actor = root.querySelector('[data-space-actor="demo:one"]')!; + actor.focus(); actor.onclick?.({ currentTarget: actor }); + h.renderer.setRoute({ view: "flow", actor: "demo:one" }); h.renderer.update(scene); + expect(h.root.querySelector(".space-detail-interactions")!.textContent).toContain("Resting: 1 sent, 0 received"); + expect((root as any).inert).toBe(true); + h.listeners.get("keydown")!({ key: "Escape", preventDefault() {}, stopPropagation() {} }); + expect(h.back).toBe(1); + h.renderer.setRoute({ view: "flow" }); h.renderer.update(scene); + expect(h.doc.activeElement).toBe(actor); +}); + +test("Flow highlights new events once, preserves focus after changes and honors project filters", () => { + const h = harness(); + h.renderer.setRoute({ view: "flow" }); h.renderer.update(h.model); + const actor = h.root.querySelector('.space-flow [data-flow-focus="actor:demo:one"]') ?? h.root.querySelector('[data-flow-focus="actor:demo:one"]')!; + actor.focus(); + const changed = { ...h.model, links: [...h.model.links, { ...message(), from: "demo:one", to: "demo:two" }] }; + h.renderer.update(changed); + expect(h.doc.activeElement.getAttribute("data-flow-focus")).toBe("actor:demo:one"); + expect(h.root.querySelectorAll('[data-new="true"]')).toHaveLength(1); + h.renderer.update({ ...changed, actors: changed.actors.map((item: any) => ({ ...item, name: `${item.name}!` })) }); + expect(h.root.querySelectorAll('[data-new="true"]')).toHaveLength(0); + h.renderer.setRoute({ view: "flow", group: "unknown" }); h.renderer.update(changed); + expect(h.root.querySelectorAll(".space-flow-group")).toHaveLength(0); +}); + +test("Flow owns one minute timer and clears it on hiding or destruction without hidden writes", () => { + const h = harness(); + const root = h.root.querySelector(".space-flow")!; + let callback: (() => void) | null = null, schedules = 0, cancels = 0; + const flow = createSpaceFlow(root, { h: () => { throw new Error("hidden Flow wrote DOM"); }, activity: createActivityBuffer(), + setInterval(fn: () => void, delay: number) { expect(delay).toBe(60000); callback = fn; schedules++; return schedules; }, + clearInterval() { cancels++; } }); + flow.update(h.model); + flow.setVisible(true); flow.setVisible(true); + expect(schedules).toBe(1); + flow.setVisible(false); + callback!(); flow.update(h.model); + expect(cancels).toBe(1); + flow.setVisible(true); flow.destroy(); callback!(); + expect(cancels).toBe(2); +}); diff --git a/test/space-server.test.ts b/test/space-server.test.ts index ea583bb..0e28ae8 100644 --- a/test/space-server.test.ts +++ b/test/space-server.test.ts @@ -50,6 +50,7 @@ test("space capability authorizes its snapshot and transport drops private field expect(result.dropped).toEqual({ actors: 0, cards: 0, links: 0 }); plugin.manifest.capabilities!.space = false; expect((await post("/p/demo/snapshot", { panel: "space" })).status).toBe(400); + expect(await (await get("/space-flow.js")).text()).toContain("createSpaceFlow"); expect((await get("/space-team.js")).headers.get("content-type")).toContain("javascript"); expect((await get("/space.js")).headers.get("content-type")).toContain("javascript"); }); diff --git a/test/space-team.test.ts b/test/space-team.test.ts index f03e8d1..2feec26 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { runInNewContext } from "node:vm"; -function harness() { +export function harness() { let writes = 0; const listeners = new Mapvoid>(); const doc: any = { activeElement: null, querySelector(s:string){return s === "dialog[open]" && doc.nativeDialog?.open ? doc.nativeDialog : null;}, addEventListener(name:string, callback:(e:any)=>void) {listeners.set(name,callback);}, removeEventListener() {}, createDocumentFragment: () => new Element("fragment") }; @@ -46,9 +46,10 @@ function harness() { doc.body = new Element("body"); main.parent=doc.body; root.parent=main; const h = (tag:string,props:Record={},...children:any[]): Element => { const n=new Element(tag); for(const[k,v]of Object.entries(props)){if(k==="text")n.textContent=v;else if(k==="hidden")n.hidden=v;else if(k.startsWith("on"))(n as any)[k]=v;else n.setAttribute(k==="class"?"class":k,String(v));} n.append(...children.flat().filter(Boolean).map(c=>typeof c==="string"?h("text",{text:c}):c));return n; }; const teamSource=readFileSync(new URL("../shell/space-team.js",import.meta.url),"utf8"); + const flowSource=readFileSync(new URL("../shell/space-flow.js",import.meta.url),"utf8"); const source=readFileSync(new URL("../shell/space.js",import.meta.url),"utf8"); const storage=new Map(); const nav: any[]=[]; let back=0; - const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${source.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n({createSpaceRenderer,mergeSpaceScenes,filterSpaceModel})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{observe(){}disconnect(){}},CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date}); + const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${flowSource.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n${source.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n({createSpaceRenderer,mergeSpaceScenes,filterSpaceModel})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{observe(){}disconnect(){}},CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date,setInterval(){return 1;},clearInterval(){}}); const renderer=api.createSpaceRenderer(root,{h,pets:()=>[],packs:()=>[],navigateSpace:(params:any)=>nav.push(params),navigate:(...args:any[])=>nav.push(args)}); const scene={groups:[{id:"alpha",title:"Alpha"}],actors:[{id:"one",name:"Exact / name",state:"working",roles:["implementer"],since:"2026-01-01T00:00:00Z",group:"alpha",card:"task",marks:[{kind:"inbox",text:"3 inbox"},{kind:"approval",text:"1 approval"}],link:{panel:"notes"}},{id:"two",name:"Resting",state:"idle",group:"alpha",marks:[],link:{panel:"notes"}}],zones:[{id:"work",title:"Work",count:1,group:"alpha"}],cards:[{id:"task",title:"Task title",zone:"work",group:"alpha",link:{panel:"notes",params:{id:"task"}}}],links:[{kind:"cohort",from:"one",to:"two"}]}; const model=api.mergeSpaceScenes(new Map([["demo",{scene,sampledAt:1}]])); @@ -65,7 +66,7 @@ test("actor detail resolves exact actor/card/partners and closes with focus rest const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); const actor=h.root.querySelector('[data-space-actor="demo:one"]')!;actor.focus();actor.onclick?.({currentTarget:actor});expect(h.nav.at(-1).actor).toBe("demo:one"); h.renderer.setRoute({actor:"demo:one"});h.renderer.update(h.model); - const detail=h.root.querySelector(".space-agent-detail")!;expect(detail.hidden).toBe(false);expect(detail.textContent).toContain("Alpha");expect(detail.textContent).toContain("cohort: Resting");expect(detail.textContent).toContain("Open in demo");expect(detail.textContent).toContain("History appears once observed."); + const detail=h.root.querySelector(".space-agent-detail")!;expect(detail.hidden).toBe(false);expect(detail.textContent).toContain("Alpha");expect(detail.textContent).toContain("cohort: Resting");expect(detail.textContent).toContain("Open in demo");expect(detail.textContent).toContain("History starts when this page opens."); const before=h.writes;h.renderer.update({...h.model,sampledAt:3});expect(h.writes).toBe(before); detail.querySelectorAll("button").find((n:any)=>n.textContent==="Task title")!.onclick?.({}); expect(h.nav.at(-1)).toEqual(["demo","notes",{id:"task"}]); diff --git a/test/static.test.ts b/test/static.test.ts index 61cdf77..696960e 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -48,7 +48,7 @@ describe("H2 static build (#46)", () => { expect(html).toContain(`content="${staticCsp(result.config).replace("; frame-ancestors 'none'", "")}"`); expect(html).not.toMatch(/]*>\s*[^\s<]/i); expect(html).not.toMatch(/ Date: Tue, 10 Nov 2026 20:18:55 +0900 Subject: [PATCH 2/3] fix(space): preserve hidden Flow and refresh detail history --- shell/space-flow.js | 2 +- shell/space.js | 10 +++++---- test/space-flow.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++ test/space-team.test.ts | 9 +++++--- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/shell/space-flow.js b/shell/space-flow.js index 62162cc..7d3c8ea 100644 --- a/shell/space-flow.js +++ b/shell/space-flow.js @@ -103,7 +103,7 @@ export function createSpaceFlow(root, host) { const recent = events.filter((event) => (!group || event.fromGroup === group || event.toGroup === group) && (!state || actorIds.has(event.from) || actorIds.has(event.to))); const edges = flowEdges(model, events, at); - const snapshot = [model.groups, model.actors, edges, recent.map((event) => [event, flowRelativeTime(event.observedAt, at)])]; + const snapshot = [group, state, model.groups, model.actors, edges, recent.map((event) => [event, flowRelativeTime(event.observedAt, at)])]; const next = JSON.stringify(snapshot); if (next === signature) return; signature = next; diff --git a/shell/space.js b/shell/space.js index f4ec4cc..a4129d9 100644 --- a/shell/space.js +++ b/shell/space.js @@ -1,4 +1,4 @@ -import { createActivityBuffer, createSpaceFlow, flowRelativeTime, FLOW_HISTORY } from "./space-flow.js"; +import { createActivityBuffer, createSpaceFlow, flowRelativeTime } from "./space-flow.js"; import { createSpaceTeam, focusSpaceControl } from "./space-team.js"; import { createSpaceViewController } from "./space-view.js"; @@ -165,6 +165,7 @@ export function createSpaceRenderer(root, host) { let signature = "", groupSignature = "", layout = null, lastModel = null; const setStyle = (el, name, value) => { if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value); }; const setText = (el, value) => { if (el.textContent !== value) el.textContent = value; }; + const setHidden = (el, value) => { if (el.hidden !== value) el.hidden = value; }; const target = (key, invoker) => { const entry = entries.get(key); if (!entry) return; if (key.startsWith("actor:")) openActor(entry.id, invoker); else host.navigate(entry.link.tool, entry.link.panel, entry.link.params); }; const image = (el, variable, pack, name) => setStyle(el, variable, pack?.images.includes(name) ? `url("${location.origin}/assets/${pack.id}/${name}")` : "none"); const prune = (nodes, keys) => { for (const [key, entry] of nodes) if (!keys.has(key)) { entry.roster?.remove(); (entry.el ?? entry).remove(); nodes.delete(key); } }; @@ -229,7 +230,7 @@ export function createSpaceRenderer(root, host) { h("div", { class: "space-agent-detail-body" }, h("section", {}, h("h3", { text: "Current task" }), card ? h("button", { type: "button", class: "btn", "data-detail-focus": "task", text: card.title, onclick: () => host.navigate(card.link.tool, card.link.panel, card.link.params) }) : h("p", { class: "sub", text: "No current task reported." })), h("section", {}, h("h3", { text: "Marks" }), ...marks), h("section", {}, h("h3", { text: "Partners" }), ...(partners.length ? partners.map((p) => h("p", { text: `${p.kind}: ${p.actor?.name ?? "Unknown actor"}` })) : [h("p", { class: "sub", text: "No current partners reported." })])), - h("section", { class: "space-detail-interactions" }, h("h3", { text: "Interactions" }), h("p", { class: "sub", text: FLOW_HISTORY }), + h("section", { class: "space-detail-interactions" }, h("h3", { text: "Interactions" }), h("p", { class: "sub", text: "History starts when this page opens. Counts and events cover the last 30 minutes." }), ...interactions.peers.map((peer) => h("p", { text: `${peer.name}: ${peer.sent} sent, ${peer.received} received` })), ...(interactions.events.length ? [h("ol", { class: "space-flow-events" }, ...interactions.events.map((event) => h("li", {}, h("strong", { text: `${event.fromName} → ${event.toName}` }), h("span", { text: `${event.kind}${event.label ? `: ${event.label}` : ""}` }), @@ -264,13 +265,14 @@ export function createSpaceRenderer(root, host) { samples.set(tool, at); } const sig = JSON.stringify([selection, facts, pack, host.pets().map((p) => p.id)]); - if (!force && sig === signature) { if (layout) layout.at = Math.max(layout.at, sampledAt); return; } + if (!force && sig === signature) { if (selection.actor) renderDetail(model); if (layout) layout.at = Math.max(layout.at, sampledAt); return; } const factChange = sig !== signature; const freshCards = new Set(); signature = sig; root.dataset.spaceSection = selection.view; const room = selection.view === "room"; - content.hidden = !room; teamRoot.hidden = selection.view !== "team"; flowRoot.hidden = selection.view !== "flow"; placeholder.hidden = selection.view !== "board"; + setHidden(content, !room); setHidden(teamRoot, selection.view !== "team"); + setHidden(flowRoot, selection.view !== "flow"); setHidden(placeholder, selection.view !== "board"); // A section switch (e.g. a Team Board/Flow shortcut) must not leave focus on a now-hidden control. const active = document.activeElement; if (active && active !== root && root.contains(active) && !detailRoot.contains(active) && active.offsetParent === null) sectionButtons.get(selection.view)?.focus({ preventScroll: true }); diff --git a/test/space-flow.test.ts b/test/space-flow.test.ts index 456b202..91e2379 100644 --- a/test/space-flow.test.ts +++ b/test/space-flow.test.ts @@ -123,3 +123,53 @@ test("Flow owns one minute timer and clears it on hiding or destruction without flow.setVisible(true); flow.destroy(); callback!(); expect(cancels).toBe(2); }); + +test("changed snapshots do not rewrite the hidden Flow attribute", () => { + const h = harness(); + h.renderer.setRoute({ view: "flow" }); h.renderer.update(h.model); + h.renderer.setRoute({ view: "team" }); h.renderer.update(h.model); + const flow = h.root.querySelector(".space-flow")!; + const writes = flow.hiddenWrites; + const children = [...flow.children]; + h.renderer.update({ ...h.model, links: [...h.model.links, { ...message(), from: "demo:one", to: "demo:two" }] }); + expect(flow.hidden).toBe(true); + expect(flow.hiddenWrites).toBe(writes); + expect(flow.children).toEqual(children); +}); + +test("Flow renders the selected empty group even when filtered facts are identical", () => { + const h = harness(); + const scene = { ...h.model, groups: [{ id: "empty:a", title: "Empty A" }, { id: "empty:b", title: "Empty B" }] }; + for (const group of ["empty:a", "empty:b", "empty:a"]) { + h.renderer.setRoute({ view: "flow", group }); h.renderer.update(scene); + const panels = h.root.querySelectorAll(".space-flow-group"); + expect(panels).toHaveLength(1); + expect(panels[0]!.getAttribute("data-flow-group")).toBe(group); + } +}); + +test("an unchanged Team snapshot expires open detail history and peer counts", () => { + let now = 0; + const h = harness({ now: () => now }); + const scene = { ...h.model, links: [{ ...message(), from: "demo:one", to: "demo:two" }] }; + h.renderer.setRoute({ view: "team", actor: "demo:one" }); h.renderer.update(scene); + const detail = () => h.root.querySelector(".space-detail-interactions")!; + expect(detail().textContent).toContain("Resting: 1 sent, 0 received"); + expect(detail().textContent).toContain("Counts and events cover the last 30 minutes."); + expect(detail().textContent).not.toContain("message counts cover 15 minutes"); + expect(detail().textContent).toContain("just now"); + const before = h.writes; + h.renderer.update(scene); + expect(h.writes).toBe(before); + now = 2 * 60000; + h.renderer.update(scene); + expect(detail().textContent).toContain("2 min ago"); + now = 31 * 60000; + h.renderer.update(scene); + expect(detail().textContent).toContain("No activity observed yet."); + expect(detail().textContent).not.toContain("sent"); + expect(detail().querySelectorAll("li")).toHaveLength(0); + const expired = h.writes; + h.renderer.update(scene); + expect(h.writes).toBe(expired); +}); diff --git a/test/space-team.test.ts b/test/space-team.test.ts index 2feec26..8418239 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -2,13 +2,16 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { runInNewContext } from "node:vm"; -export function harness() { +export function harness({ now = Date.now }: { now?: () => number } = {}) { let writes = 0; const listeners = new Mapvoid>(); const doc: any = { activeElement: null, querySelector(s:string){return s === "dialog[open]" && doc.nativeDialog?.open ? doc.nativeDialog : null;}, addEventListener(name:string, callback:(e:any)=>void) {listeners.set(name,callback);}, removeEventListener() {}, createDocumentFragment: () => new Element("fragment") }; class Element { children: Element[] = []; parent: Element | null = null; attributes = new Map(); dataset: Record = {}; - value = ""; open = false; hidden = false; ownText = ""; ownerDocument = doc; onclick?: (e: unknown) => void; scrollTop = 0; scrollLeft = 0; + private hiddenValue = false; hiddenWrites = 0; + get hidden() { return this.hiddenValue; } + set hidden(value: boolean) { this.hiddenValue = value; this.hiddenWrites++; writes++; } + value = ""; open = false; ownText = ""; ownerDocument = doc; onclick?: (e: unknown) => void; scrollTop = 0; scrollLeft = 0; constructor(public tag: string) {} set textContent(v: string) { this.ownText = v; this.children = []; writes++; } get textContent(): string { return this.ownText + this.children.map(c=>c.textContent).join(""); } @@ -49,7 +52,7 @@ export function harness() { const flowSource=readFileSync(new URL("../shell/space-flow.js",import.meta.url),"utf8"); const source=readFileSync(new URL("../shell/space.js",import.meta.url),"utf8"); const storage=new Map(); const nav: any[]=[]; let back=0; - const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${flowSource.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n${source.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n({createSpaceRenderer,mergeSpaceScenes,filterSpaceModel})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{observe(){}disconnect(){}},CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date,setInterval(){return 1;},clearInterval(){}}); + const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${flowSource.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n${source.replace(/^import .*;$/gm,"").replace(/^export /gm,"")}\n({createSpaceRenderer,mergeSpaceScenes,filterSpaceModel})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{observe(){}disconnect(){}},CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date: class extends Date { static now = now; },setInterval(){return 1;},clearInterval(){}}); const renderer=api.createSpaceRenderer(root,{h,pets:()=>[],packs:()=>[],navigateSpace:(params:any)=>nav.push(params),navigate:(...args:any[])=>nav.push(args)}); const scene={groups:[{id:"alpha",title:"Alpha"}],actors:[{id:"one",name:"Exact / name",state:"working",roles:["implementer"],since:"2026-01-01T00:00:00Z",group:"alpha",card:"task",marks:[{kind:"inbox",text:"3 inbox"},{kind:"approval",text:"1 approval"}],link:{panel:"notes"}},{id:"two",name:"Resting",state:"idle",group:"alpha",marks:[],link:{panel:"notes"}}],zones:[{id:"work",title:"Work",count:1,group:"alpha"}],cards:[{id:"task",title:"Task title",zone:"work",group:"alpha",link:{panel:"notes",params:{id:"task"}}}],links:[{kind:"cohort",from:"one",to:"two"}]}; const model=api.mergeSpaceScenes(new Map([["demo",{scene,sampledAt:1}]])); From 43eb5ceb6b216e97e251250000560a77c1e21655 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 20:31:29 +0900 Subject: [PATCH 3/3] fix(space): retain visible ungrouped activity under filters --- shell/space-flow.js | 3 +-- test/space-flow.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/shell/space-flow.js b/shell/space-flow.js index 7d3c8ea..76a5710 100644 --- a/shell/space-flow.js +++ b/shell/space-flow.js @@ -100,8 +100,7 @@ export function createSpaceFlow(root, host) { if (!visible || destroyed) return; const at = displayAt, events = host.activity.events(); const actorIds = new Set(model.actors.map((actor) => actor.id)); - const recent = events.filter((event) => (!group || event.fromGroup === group || event.toGroup === group) && - (!state || actorIds.has(event.from) || actorIds.has(event.to))); + const recent = events.filter((event) => actorIds.has(event.from) || actorIds.has(event.to)); const edges = flowEdges(model, events, at); const snapshot = [group, state, model.groups, model.actors, edges, recent.map((event) => [event, flowRelativeTime(event.observedAt, at)])]; const next = JSON.stringify(snapshot); diff --git a/test/space-flow.test.ts b/test/space-flow.test.ts index 91e2379..1c30334 100644 --- a/test/space-flow.test.ts +++ b/test/space-flow.test.ts @@ -173,3 +173,33 @@ test("an unchanged Team snapshot expires open detail history and peer counts", ( h.renderer.update(scene); expect(h.writes).toBe(expired); }); + +test("project-filtered Recent activity retains ungrouped endpoints and follows actor filters", () => { + const h = harness(); + const scene = { ...h.model, + groups: [...h.model.groups, { id: "demo:other", title: "Other" }], + actors: [ + ...h.model.actors.map((actor: any) => ({ ...actor, group: undefined })), + ...h.model.actors.map((actor: any) => ({ ...actor, id: `${actor.id}:other`, group: "demo:other" })), + ], links: [], + }; + h.renderer.setRoute({ view: "flow", group: "demo:alpha" }); + h.renderer.update(scene); + const changed = { ...scene, links: [ + { ...message("ungrouped"), from: "demo:one", to: "demo:two", label: "Ungrouped exchange" }, + { ...message("other"), from: "demo:one:other", to: "demo:two:other", label: "Other project exchange" }, + ] }; + h.renderer.update(changed); + const flow = h.root.querySelector(".space-flow")!; + expect(flow.querySelectorAll(".space-flow-actor")).toHaveLength(2); + expect(flow.querySelectorAll(".space-flow-edge")).toHaveLength(1); + const activity = () => flow.querySelector(".space-flow-activity")!; + expect(activity().textContent).toContain("Ungrouped exchange"); + expect(activity().textContent).not.toContain("Other project exchange"); + h.renderer.setRoute({ view: "flow", group: "demo:alpha", state: "working" }); + h.renderer.update(changed); + expect(activity().textContent).toContain("Ungrouped exchange"); + h.renderer.setRoute({ view: "flow", group: "demo:alpha", state: "away" }); + h.renderer.update(changed); + expect(activity().textContent).toContain("No activity observed yet."); +});