diff --git a/CHANGELOG.md b/CHANGELOG.md index 62aa02e..b06f006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added +- 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). - Serve authenticated MCP principal tools at `/principal/mcp` with a private live registry, verified Access or separate bearer identity, per-principal rate and concurrency limits, read-only filtering, bounded bodies/results and metadata-only audit. Add `acon principal list|off|on` and document the 2025-11-25 transport and registry schema (#88). diff --git a/README.md b/README.md index 809f2a4..515c349 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,19 @@ 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=`; the selected view is saved as `acon.space.view`. Room remains the default view in this delivery. Team, Flow and Board are opt-in placeholders until their later deliveries. Room contains the existing Flat, Room and, when WebGL2 is available, 3D scene controls; their sub-toggle persists separately as `acon.space.roomView`. Summary state counts filter actors (`state=working|waiting|blocked|away|idle`), and the project count resets both filters. 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. +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. + +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. + +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. 3D is opt-in and loads the vendored renderer only when selected. The default camera frames the scene's content, turning its long axis down the screen on a portrait canvas; pack models are fitted by width with a height cap so desks, boards and shelves read as furniture. Room frames every billboard inside its scroll box. Drag empty canvas space to orbit, or use the same camera controls. Actor and work-item buttons remain in the same keyboard order and follow their projected screen positions; focus or hover reveals their labels. The text roster remains available and leads on phones. Missing WebGL2, renderer startup failure or context loss returns to Room with a notice. Canvas draws happen on changed public facts, asset completion and camera input, with a sprite burst bounded to 0.8 seconds; settled scenes schedule no frame or animation timer. Reduced motion draws the final pose immediately. The exported `SpaceScene` contract contains: -- Optional `groups: {id, title}[]` describe public groups. Actors, zones and cards may each carry an optional `group` id; the shell namespaces group ids by tool. If a scene omits groups, its content is assigned to one group named after that tool. In a scene with declared groups, items without a group remain visible under every project filter; zones needed by matching cards remain visible even across group boundaries. These fields are additive API 1 metadata and may be ignored by older consoles. +- Optional `groups: {id, title}[]` describe public groups. Actors, zones and cards may each carry an optional `group` id; the shell namespaces group ids by tool. If a scene omits groups, its content is assigned to one group named after that tool. The shell keeps the existing 200-group bound per tool, preserving distinct group ids across tools instead of cutting the combined list into ungrouped content. In a scene with declared groups, items without a group remain visible under every project filter; zones needed by matching cards remain visible even across group boundaries. These fields are additive API 1 metadata and may be ignored by older consoles. - `actors`: `id`, `name`, `state` (`idle`, `working`, `waiting`, `blocked`, `away`), optional `pet`, `card`, `marks`, `group`, public `roles` (up to 4 role names, 32 characters each) and ISO `since`, plus a panel `link`. - `marks`: `kind` (`approval`, `inbox`, `paused`, `mode`) and public `text`. - `zones`: `id`, `title`, optional `kind` (`queue`, `work`, `review`, `done`) and a non-negative `count`. @@ -106,7 +112,7 @@ The exported `SpaceScene` contract contains: - Optional `links`: `kind` (`message`, `overlap`, `conflict`, `cohort`), actor ids `from` and `to`, and optional public `label` and ISO `at`. - Panel links use `{panel, params?}`. Routing parameters must contain public identifiers, never bodies, private paths or task details. -Strings are limited to 200 characters; at most 96 actors, 200 cards and 128 links are rendered across tools. The server copies only allowed fields and reports dropped records; the shell also caps the combined scene. Conformance rejects extra fields, oversized strings and excess records with named problems. All state labels include text and a glyph. Clicking an actor or card navigates to its panel; Space performs no action. A text roster lists the same actors and zone counts and leads on phones, with the visual scene inside its own scroll box. +Strings are limited to 200 characters; at most 96 actors, 200 cards and 128 links are rendered across tools. The server copies only allowed fields and reports dropped records; the shell also caps the combined scene. Conformance rejects extra fields, oversized strings and excess records with named problems. All state labels include text and a glyph. Clicking a card navigates to its panel; clicking an actor opens its detail, whose Open in button navigates to the actor panel; Space performs no action. A text roster lists the same actors and zone counts and leads on phones, with the visual scene inside its own scroll box. Office packs live at `$AGENTHUB_HOME/console/assets//`. A `pack.json` is at most 16 KB and has this form: diff --git a/scripts/ui-check-space-foundation.mjs b/scripts/ui-check-space-foundation.mjs index 910b58e..c142d5b 100644 --- a/scripts/ui-check-space-foundation.mjs +++ b/scripts/ui-check-space-foundation.mjs @@ -7,6 +7,11 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) { const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: "reduce" }); try { const page = await context.newPage(); + let spacePolls = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname !== "/p/demo/snapshot" || request.method() !== "POST") return; + try { if (request.postDataJSON()?.panel === "space") spacePolls += 1; } catch { /* Ignore non-JSON requests. */ } + }); await page.goto((await ticket()).url); await page.evaluate(() => { location.hash = "#/local/space?view=room"; }); await page.locator(".space-actor").nth(5).waitFor(); @@ -17,24 +22,31 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) { assert(Math.abs(layout.view - layout.main) < 2, "Space does not fill main width"); assert(layout.document <= layout.viewport + 1, "Space causes page overflow"); await page.locator("[data-space-section-tab='team']").click(); - await page.getByText("Team view is not available yet. Room shows the current scene.").waitFor(); - assert.equal(await page.evaluate(() => localStorage.getItem("acon.space.view")), "team"); + await page.locator(".space-team").waitFor(); + assert((await page.locator(".space-team").innerText()).trim().length > 0, "Team view has no content"); + assert.equal(await page.evaluate(() => localStorage.getItem("acon.space.section")), "team"); + await page.evaluate(() => { location.hash = "#/local/space"; }); await page.reload(); - await page.getByText("Team view is not available yet. Room shows the current scene.").waitFor(); + await page.locator(".space-team").waitFor(); + assert.equal(await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] ?? "").get("view")), null, "Persistence reload retained an explicit view route"); await page.locator("[data-space-section-tab='room']").click(); await page.locator(".space-actor").nth(5).waitFor(); + const pollsBeforeFilters = spacePolls; await page.locator("#space-project").selectOption("demo:delivery"); await page.waitForFunction(() => location.hash.includes("group=demo%3Adelivery") && document.querySelectorAll(".space-actor").length === 2); await page.locator(".space-summary button").filter({ hasText: /working$/ }).click(); await page.waitForFunction(() => location.hash.includes("state=working") && document.querySelectorAll(".space-actor").length === 1); + await page.waitForTimeout(150); + assert.equal(spacePolls, pollsBeforeFilters, "Group/state route changes triggered a full Space poll"); const screenshot = `foundation-${width}.png`; await page.screenshot({ path: join(output, screenshot) }); report.captures.push({ key: `foundation-${width}`, screenshot, width, view: "space-foundation" }); - report.spaceFoundation.push({ width, ...layout, routing: true, persistence: true, groupAndStateFilters: true }); + report.spaceFoundation.push({ width, ...layout, routing: true, persistence: true, groupAndStateFilters: true, filterChangesWithoutRepoll: true }); } catch (error) { report.findings.push({ kind: "space-foundation", width, detail: error.message }); } finally { await context.close(); } } - const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" }); + // The probe polls with waitForFunction, which the shell CSP blocks as eval; bypass it for this measurement context only. + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce", bypassCSP: true }); try { const page = await context.newPage(); let calls = 0, change = false; await page.route("**/p/*/snapshot", async (route) => { diff --git a/scripts/ui-check-space-team.mjs b/scripts/ui-check-space-team.mjs new file mode 100644 index 0000000..6274a78 --- /dev/null +++ b/scripts/ui-check-space-team.mjs @@ -0,0 +1,124 @@ +// #101 Delivery 2: Team routes, actor detail return paths and unchanged-poll DOM writes. +import assert from "node:assert/strict"; +import { join } from "node:path"; + +async function enterSpace(page, ticket, width, textPercent) { + await page.setViewportSize({ width, height: 1000 }); + await page.goto((await ticket()).url); + await page.evaluate((size) => { document.documentElement.style.fontSize = `${size}%`; localStorage.removeItem("acon.space.section"); location.hash = "#/local/space"; }, textPercent); + await page.locator(".space-team").waitFor(); + if (await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] ?? "").has("view"))) throw new Error("Default Team route unexpectedly contains an explicit view"); + await page.locator("[data-space-actor]").first().waitFor(); +} + +async function unchangedWrites(page, mode) { + await page.evaluate(() => { + globalThis.teamProbeWrites = 0; + globalThis.teamProbeObserver = new MutationObserver((records) => { globalThis.teamProbeWrites += records.length; }); + globalThis.teamProbeObserver.observe(document.querySelector("#view-space"), { subtree: true, attributes: true, childList: true, characterData: true }); + }); + const response = await page.waitForResponse((item) => { + if (new URL(item.url()).pathname !== "/p/demo/snapshot" || item.request().method() !== "POST") return false; + try { return item.request().postDataJSON()?.panel === "space"; } catch { return false; } + }, { timeout: 20000 }); + await response.finished(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const writes = await page.evaluate(() => { globalThis.teamProbeObserver.disconnect(); return globalThis.teamProbeWrites; }); + assert.equal(writes, 0, `Unchanged ${mode} poll wrote ${writes} DOM mutations`); + return writes; +} + +export async function runSpaceTeam({ browser, ticket, report, output }) { + report.spaceTeam = []; + for (const width of [375, 768, 1024, 1440]) for (const theme of ["light", "dark"]) for (const textPercent of [100, 200]) { + const context = await browser.newContext({ viewport: { width, height: 1000 }, colorScheme: theme, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(12000); + try { + await enterSpace(page, ticket, width, textPercent); + const teamText = (await page.locator(".space-team").innerText()).trim(); + assert(teamText.length > 0, "Team view is empty"); + assert(!teamText.includes("Team view is not available yet"), "Team view still shows its placeholder"); + const teamWrites = await unchangedWrites(page, "Team"); + const fits = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1); + assert(fits, "Team caused horizontal page scroll"); + const teamScreenshot = `space-team-${width}-${theme}-${textPercent}.png`; + await page.screenshot({ path: join(output, teamScreenshot), fullPage: true }); + report.captures.push({ key: `space-team-${width}-${theme}-${textPercent}`, screenshot: teamScreenshot, width, theme, textPercent, view: "space-team" }); + if (width !== 375 && width !== 1440) { + report.spaceTeam.push({ width, theme, textPercent, teamWrites }); + continue; + } + + const actor = page.locator("[data-space-actor]").first(); + const actorKey = await actor.getAttribute("data-space-actor"); + const actorFocusKey = await actor.getAttribute("data-team-focus"); + assert(actorKey && actorKey.includes(":"), `Expected a namespaced actor key, got ${actorKey}`); + await actor.focus(); + await page.keyboard.press("Enter"); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + await page.waitForFunction((key) => new URLSearchParams(location.hash.split("?")[1] ?? "").get("actor") === key, actorKey); + const detailWrites = await unchangedWrites(page, "detail"); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1), "Detail caused horizontal page scroll"); + await page.keyboard.press("Escape"); + await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + assert.equal(await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] ?? "").get("actor")), null, "Escape left actor in the route"); + assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-space-actor") ?? document.activeElement?.getAttribute("data-actor-id")), actorKey, "Escape did not restore focus to the invoking actor"); + + await page.locator(`[data-team-focus='${actorFocusKey}']`).click(); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + const detailScreenshot = `space-team-detail-${width}-${theme}-${textPercent}.png`; + await page.screenshot({ path: join(output, detailScreenshot), fullPage: true }); + report.captures.push({ key: `space-team-detail-${width}-${theme}-${textPercent}`, screenshot: detailScreenshot, width, theme, textPercent, view: "space-agent-detail" }); + 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-space-actor") ?? document.activeElement?.getAttribute("data-actor-id")), actorKey, "Back did not restore focus to the invoking actor"); + await page.evaluate((key) => { location.hash = `#/local/space?view=team&actor=${encodeURIComponent(key)}`; }, actorKey); + 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, "Direct actor route did not open the requested detail"); + await page.evaluate(() => { location.hash = "#/local/space?view=team"; }); + await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + + if (width < 1024) { + // Compact: .column is the page scroller; a producer round trip must restore it and the actor focus. + await page.evaluate(() => { location.hash = "#/local/space?view=team"; }); + await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + const scrolled = await page.evaluate(() => { const c = document.querySelector(".column"); if (!c || c.scrollHeight - c.clientHeight < 40) return null; c.scrollTop = Math.min(160, c.scrollHeight - c.clientHeight); return c.scrollTop; }); + if (scrolled !== null) { + await page.locator(`.space-team [data-space-actor="${actorKey}"]`).first().click(); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + await page.locator(".space-agent-detail [data-detail-focus='open']").click(); + await page.waitForFunction(() => !location.hash.includes("/space")); + await page.goBack(); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + await page.keyboard.press("Escape"); + await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + const after = await page.evaluate(() => ({ top: document.querySelector(".column")?.scrollTop ?? 0, key: document.activeElement?.getAttribute("data-space-actor") })); + assert(Math.abs(after.top - scrolled) <= 2, `Compact scroll not restored: ${after.top} vs ${scrolled}`); + assert.equal(after.key, actorKey, "Compact producer round trip did not restore actor focus"); + } + } + if (width >= 1024) { + // Direct Room route: Room controls are created after the detail and must still be inert. + await page.evaluate((key) => { location.hash = `#/local/space?view=room&actor=${encodeURIComponent(key)}`; }, actorKey); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + await page.locator(".space-view-bar").waitFor({ state: "attached" }); + assert.equal(await page.evaluate(() => Boolean(document.querySelector(".space-view-bar")?.closest("[inert]"))), true, "Room controls stay interactive behind the detail"); + // Leaving for the producer panel and coming back puts focus inside the detail again. + await page.locator(".space-agent-detail [data-detail-focus='open']").click(); + await page.waitForFunction(() => !location.hash.includes("/space")); + await page.goBack(); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + await page.waitForFunction(() => document.querySelector(".space-agent-detail")?.contains(document.activeElement)); + await page.keyboard.press("Escape"); + await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + } + await page.evaluate(() => { location.hash = "#/local/space?view=team&actor=unknown%3Aactor"; }); + await page.locator(".space-agent-detail .notice").filter({ hasText: /not in the current scene|waiting for agent data/i }).waitFor(); + assert.equal(await page.locator(".space-agent-detail").isVisible(), true, "Unknown actor notice is not shown in detail"); + report.spaceTeam.push({ width, theme, textPercent, actorKey, teamWrites, detailWrites, keyboardReturn: true, backReturn: true, directRoute: true, unknownActor: true }); + } catch (error) { + report.findings.push({ kind: "space-team", width, theme, textPercent, detail: error.message }); + } finally { await context.close(); } + } +} diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index d15847b..e0bef40 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; 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 { runStaticCheck } from "./ui-check-static.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -457,7 +458,7 @@ async function waitSpacePoll(page) { async function checkSpace(page, scenario, resources, packed) { await page.locator("#areas a[href='#/local/space']").waitFor(); - await page.evaluate(() => { location.hash = "#/local/space"; }); + await page.evaluate(() => { location.hash = "#/local/space?view=room"; }); await page.locator("#view-space:not([hidden]) .space-actor").first().waitFor(); await page.waitForFunction(() => document.querySelectorAll("#view-space .space-actor").length === 6); await settle(page); @@ -542,16 +543,20 @@ async function checkSpace(page, scenario, resources, packed) { await capture(page, scenario, packed ? "space-scene" : "space-fallback-scene"); report.spaceChecks.push({ ...scenario, packed, ...facts, metadata, resources: resources.filter((entry) => entry.path.startsWith("/assets/") || entry.path.startsWith("/pets/")), unchangedWrites: writes.length }); - for (const [selector, tool, id] of [[".space-actor[data-key='demo:claude']", "demo", "n1"], [".space-card[data-key='echo:n2']", "echo", "n2"]]) { - await page.locator(`#view-space:not([hidden]) ${selector}`).click(); - await page.waitForFunction(({ tool, id }) => { - const [route, query = ""] = location.hash.split("?"); - return route === `#/local/${tool}/notes` && new URLSearchParams(query).get("id") === id; - }, { tool, id }); - await page.locator(".panel-mount:not([hidden]) .ui-detail").waitFor(); - await page.evaluate(() => { location.hash = "#/local/space"; }); - await page.locator("#view-space:not([hidden]) .space-actor").first().waitFor(); - } + await page.locator("#view-space:not([hidden]) .space-actor[data-key='demo:claude']").click(); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + await page.waitForFunction(() => new URLSearchParams(location.hash.split("?")[1] ?? "").get("actor") === "demo:claude"); + await page.locator(".space-detail-back").click(); + await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + await page.locator("#view-space:not([hidden]) .space-actor").first().waitFor(); + await page.locator("#view-space:not([hidden]) .space-card[data-key='echo:n2']").click(); + await page.waitForFunction(() => { + const [route, query = ""] = location.hash.split("?"); + return route === "#/local/echo/notes" && new URLSearchParams(query).get("id") === "n2"; + }); + await page.locator(".panel-mount:not([hidden]) .ui-detail").waitFor(); + await page.evaluate(() => { location.hash = "#/local/space?view=room"; }); + await page.locator("#view-space:not([hidden]) .space-actor").first().waitFor(); } let baseline; @@ -881,7 +886,7 @@ async function runSpaceMotion(ticket) { await page.goto((await ticket()).url, { waitUntil: "networkidle" }); await page.locator("#areas a[href='#/local/space']").waitFor(); await page.evaluate((force) => { - document.documentElement.dataset.theme = "light"; document.documentElement.style.fontSize = "100%"; location.hash = "#/local/space"; + document.documentElement.dataset.theme = "light"; document.documentElement.style.fontSize = "100%"; location.hash = "#/local/space?view=room"; if (force) { const bad = document.createElement("div"); bad.style.width = "200vw"; bad.style.height = "1px"; bad.dataset.uiCheck = "forced-overflow"; document.body.append(bad); } }, options.forceOverflow); await page.locator("#view-space .space-actor").nth(5).waitFor(); @@ -1079,7 +1084,7 @@ async function runSpaceViews(ticket) { try { await page.goto((await ticket()).url, { waitUntil: "networkidle" }); await page.evaluate(({ size, force }) => { - document.documentElement.style.fontSize = `${size}%`; location.hash = "#/local/space"; + document.documentElement.style.fontSize = `${size}%`; location.hash = "#/local/space?view=room"; if (force) { const bad = document.createElement("div"); bad.style.width = "200vw"; bad.style.height = "1px"; bad.dataset.uiCheck = "forced-overflow"; document.body.append(bad); } }, { size: textPercent, force: options.forceOverflow }); await page.locator(".space-actor").nth(5).waitFor(); @@ -1195,7 +1200,7 @@ async function runSpaceViews(ticket) { const boxPage = await boxContext.newPage(); try { await boxPage.goto((await ticket(true)).url, { waitUntil: "networkidle" }); - await boxPage.evaluate(() => { location.hash = "#/local/space"; }); + await boxPage.evaluate(() => { location.hash = "#/local/space?view=room"; }); await boxPage.locator(".space-actor").first().waitFor(); await boxPage.locator("button[data-space-view='3d']").click(); await boxPage.locator(".space-webgl[data-ready='true'][data-pending-frames='0']").waitFor(); @@ -1212,7 +1217,7 @@ async function runSpaceViews(ticket) { try { await page.addInitScript(() => { const original = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(type, ...args) { return type === "webgl2" ? null : original.call(this, type, ...args); }; localStorage.setItem("acon.space.view", "3d"); }); await page.goto((await ticket(true)).url, { waitUntil: "networkidle" }); - await page.evaluate(() => { location.hash = "#/local/space"; }); + await page.evaluate(() => { location.hash = "#/local/space?view=room"; }); await page.locator("#view-space[data-space-view='room'] .space-actor").first().waitFor(); const hidden = await page.locator("button[data-space-view='3d']").count() === 0; const notice = await page.locator(".space-view-notice").isVisible(); @@ -1245,7 +1250,11 @@ try { await runMini(ticket, { width: 375, theme: "light", textPercent: 100 }, "mini-fallback", 1000, false); } if (!focused || options.spaceMotionOnly) await runSpaceMotion(ticket); - if (!focused || options.spaceViewsOnly) { await runSpaceViews(ticket); await runSpaceFoundation({ browser, ticket, report, output: options.output }); } + if (!focused || options.spaceViewsOnly) { + await runSpaceViews(ticket); + await runSpaceFoundation({ browser, ticket, report, output: options.output }); + await runSpaceTeam({ 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 }); if (!focused || options.staticOnly) await runStaticCheck({ browser, root, capture, report, output: options.output, forceOverflow: options.forceOverflow }); diff --git a/shell/shell.css b/shell/shell.css index b3abebb..40b6879 100644 --- a/shell/shell.css +++ b/shell/shell.css @@ -583,7 +583,7 @@ body.pip { margin: 0; overflow-y: auto; } /* Space uses the whole main area; its scene remains an independently bounded scroller. */ #view-space { max-width: none; width: 100%; min-width: 0; } -.space-header { position: sticky; top: 0; z-index: var(--shell-z-raised); padding-bottom: var(--acon-space-3); background: var(--acon-bg); border-bottom: 1px solid var(--acon-line); } +.space-header { padding-bottom: var(--acon-space-3); background: var(--acon-bg); border-bottom: 1px solid var(--acon-line); } .space-header-controls, .space-summary, .space-section-tabs, .space-project-label { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); min-width: 0; } .space-header-controls { justify-content: space-between; margin-top: var(--acon-space-3); } .space-summary .btn, .space-section-tabs .btn { min-height: 44px; } @@ -591,3 +591,68 @@ body.pip { margin: 0; overflow-y: auto; } .space-project-label { max-width: 100%; } .space-summary [aria-pressed="true"], .space-section-tabs [aria-pressed="true"] { color: var(--acon-accent-text); background: var(--acon-accent-soft); border-color: var(--acon-accent); } #view-space:not([data-space-section="room"]) .space-view-bar, #view-space:not([data-space-section="room"]) .space-selection, #view-space:not([data-space-section="room"]) .space-view-notice { display: none; } + +/* Private Space Team renderer and agent detail surface. */ +.space-team { display: flex; flex-direction: column; gap: var(--acon-space-5); min-width: 0; } +.space-team-attention { display: flex; flex-direction: column; gap: var(--acon-space-2); min-width: 0; } +.space-team-attention h2, .space-team-group-title h2 { margin: 0; font-size: var(--acon-text-lg); } +.space-team-empty { margin: 0; color: var(--acon-text-2); } +.space-team-attention-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto minmax(0, 2fr) auto; align-items: center; gap: var(--acon-space-3); width: 100%; min-height: 56px; padding: var(--acon-space-2) var(--acon-space-3); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); background: var(--acon-surface); color: var(--acon-text); font: inherit; text-align: left; cursor: pointer; } +.space-team-attention-row[data-state="waiting"] { border-color: var(--acon-warn); } +.space-team-attention-row[data-state="blocked"] { border-color: var(--acon-err); } +.space-team-group { display: flex; flex-direction: column; gap: var(--acon-space-3); min-width: 0; padding-top: var(--acon-space-3); border-top: 1px solid var(--acon-line); } +.space-team-group-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--acon-space-2); min-width: 0; } +.space-team-group-title { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--acon-space-2); min-width: 0; } +.space-team-count { color: var(--acon-text-2); font-size: var(--acon-text-sm); } +.space-team-shortcuts { display: flex; gap: var(--acon-space-2); } +.space-team-shortcuts .btn { min-height: 44px; } +.space-team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); align-items: stretch; gap: var(--acon-space-3); min-width: 0; } +.space-team-tile { display: grid; grid-template-columns: 40px minmax(0, 1fr); grid-template-rows: auto auto 1fr auto; align-items: start; gap: var(--acon-space-2) var(--acon-space-3); min-width: 0; min-height: 150px; padding: var(--acon-space-3); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-3); background: var(--acon-surface); color: var(--acon-text); font: inherit; text-align: left; cursor: pointer; } +.space-team-tile[data-state="working"] { border-color: var(--acon-accent); } +.space-team-tile[data-state="waiting"] { border-color: var(--acon-warn); } +.space-team-tile[data-state="blocked"] { border-color: var(--acon-err); } +.space-team-tile[data-state="away"] { border-style: dashed; opacity: 0.78; } +.space-team-tile[data-stale="true"], .space-team-attention-row[data-stale="true"] { border-style: dashed; } +.space-team-avatar { grid-column: 1; grid-row: 1 / span 2; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: var(--acon-surface-2); color: var(--acon-accent-text); } +.space-team-avatar > * { max-width: 40px; max-height: 40px; } +.space-team-avatar .pet-sprite { --cell-w: 40px; --cell-h: calc(40px * 13 / 12); width: var(--cell-w); height: var(--cell-h); } +.space-team-identity { display: flex; flex-direction: column; gap: var(--acon-space-1); min-width: 0; } +.space-team-name { overflow-wrap: anywhere; font-weight: 600; } +.space-team-roles, .space-team-badges { display: flex; flex-wrap: wrap; gap: var(--acon-space-1); min-width: 0; } +.space-team-role, .space-team-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 1px var(--acon-space-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-1); color: var(--acon-text-2); font-size: var(--acon-text-xs); overflow-wrap: anywhere; } +.space-team-state { display: inline-flex; align-items: center; gap: var(--acon-space-1); justify-self: start; min-height: 24px; padding: 1px var(--acon-space-2); border-radius: var(--acon-radius-1); background: var(--acon-surface-2); color: var(--acon-text); font-size: var(--acon-text-xs); } +.space-team-state[data-state="working"] { background: var(--acon-accent-soft); color: var(--acon-accent-text); } +.space-team-state[data-state="waiting"] { background: var(--acon-warn-soft); color: var(--acon-warn); } +.space-team-state[data-state="blocked"] { background: var(--acon-err-soft); color: var(--acon-err); } +.space-team-state[data-state="away"] { color: var(--acon-text-2); } +.space-team-taskline { min-width: 0; color: var(--acon-text-2); font-size: var(--acon-text-sm); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; } +.space-team-tile > .space-team-state { grid-column: 2; } +.space-team-tile > .space-team-taskline { grid-column: 1 / -1; grid-row: 3; } +.space-team-tile > .space-team-badges { grid-column: 1 / -1; grid-row: 4; align-self: end; } +.space-team-attention-row > .space-team-avatar { grid-column: 1; grid-row: 1; } +.space-team-attention-row > .space-team-identity { grid-column: 2; grid-row: 1; } +.space-team-attention-row > .space-team-state { grid-column: 3; grid-row: 1; } +.space-team-attention-row > .space-team-taskline { grid-column: 4; grid-row: 1; } +.space-team-attention-row > .space-team-badges { grid-column: 5; grid-row: 1; } +.space-team-badge-approval { background: var(--acon-warn-soft); color: var(--acon-warn); } +.space-team-resting { min-width: 0; } +.space-team-resting > summary { display: block; min-height: 44px; padding: var(--acon-space-3); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); background: var(--acon-surface); color: var(--acon-text-2); cursor: pointer; overflow-wrap: anywhere; } +.space-team-resting-grid { margin-top: var(--acon-space-2); } +.space-team :is(button, summary):focus-visible { outline: 2px solid var(--acon-focus); outline-offset: 2px; } +.space-agent-detail { position: fixed; z-index: var(--shell-z-sheet); top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(480px, 100vw); border-left: 1px solid var(--acon-line); background: var(--acon-surface); box-shadow: var(--acon-shadow); } +.space-agent-detail-header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--acon-space-2); min-height: 56px; padding: var(--acon-space-2) var(--acon-space-4); border-bottom: 1px solid var(--acon-line); } +.space-agent-detail-body { flex: 1; min-height: 0; overflow-y: auto; padding: var(--acon-space-4); } +.space-detail-back { min-width: 44px; min-height: 44px; } +@media (max-width: 1023px) { + .space-agent-detail { inset: 0; width: 100%; border-left: 0; box-shadow: none; } +} +@media (max-width: 600px) { + .space-team-attention-row { grid-template-columns: 40px minmax(0, 1fr) auto; } + .space-team-attention-row > .space-team-taskline { grid-column: 1 / -1; grid-row: 2; } + .space-team-attention-row > .space-team-badges { grid-column: 1 / -1; grid-row: 3; } +} + +.space-agent-detail { 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; } diff --git a/shell/shell.js b/shell/shell.js index 632c1ea..f39c60d 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -884,6 +884,7 @@ function route() { $("view-inbox").hidden = r.area !== "inbox"; $("view-system").hidden = r.area !== "system"; $("view-space").hidden = r.area !== "space"; + if (prev.area === "space" && r.area !== "space") spaceRenderer?.hide(); if (!pip) miniView.hidden = r.area !== "mini"; $("app").classList.toggle("mini-mode", r.area === "mini"); for (const [id, p] of state.panels) p.mount.hidden = r.area !== "tool" || r.tool !== id; @@ -893,7 +894,7 @@ function route() { if (r.area === "inbox") { renderInbox(true); if (r.params.id) selectKey(r.params.id, true); } if (r.area === "system") renderSystem(); if (r.area === "mini") renderMini(true); - if (r.area === "space") showSpace(); + if (r.area === "space") showSpace(prev.area === "space"); if (r.area === "tool") showTool(r); if (prev.area !== r.area || prev.tool !== r.tool) $("main").focus({ preventScroll: true }); } @@ -1859,7 +1860,7 @@ function showKeys() { // ---------- Space snapshots (rendering is a private, lazy-loaded DOM module) ---------- let spaceModule = null, spaceRenderer = null, spaceLoading = null; -async function showSpace() { +async function showSpace(internal = false) { if (!spaceLoading) spaceLoading = import("/space.js").then((mod) => { spaceModule = mod; spaceRenderer = mod.createSpaceRenderer($("view-space"), { h, navigate, petSprite, pets: () => state.pets, packs: () => state.assetPacks, onError: (error) => clientError("acon", "space 3D", error), navigateSpace: (params) => navigate(toolById("space") ? "~space" : "space", undefined, params) }); @@ -1871,11 +1872,12 @@ async function showSpace() { if (!spaceRenderer || state.route.area !== "space") return; for (const tool of spaceTools()) { if (!state.space.has(tool.tool)) state.space.set(tool.tool, { scene: null, cursor: 0, next: 0, busy: false, failed: false }); - else state.space.get(tool.tool).next = 0; + else if (!internal) state.space.get(tool.tool).next = 0; } spaceRenderer.setRoute(state.route.params); - renderSpace(true); - pollSpace(); + renderSpace(!internal); + if (!internal) spaceRenderer.enter?.(); + if (!internal) pollSpace(); } function renderSpace(force = false) { if (!spaceRenderer || state.route.area !== "space") return; @@ -1896,6 +1898,7 @@ async function pollSpace() { entry.busy = true; const res = await post(`/p/${tool.tool}/snapshot`, { panel: "space", after: entry.cursor }); entry.busy = false; + entry.reported = true; if (res.status === 200 && res.data.scene) { entry.sampledAt = Date.now(); entry.scene = res.data.scene; diff --git a/shell/space-team.js b/shell/space-team.js new file mode 100644 index 0000000..d7836f0 --- /dev/null +++ b/shell/space-team.js @@ -0,0 +1,177 @@ +const stateInfo = { + working: { glyph: "▶", label: "working", className: "working" }, + waiting: { glyph: "?", label: "waiting", className: "waiting" }, + blocked: { glyph: "!", label: "blocked", className: "blocked" }, + idle: { glyph: "○", label: "idle", className: "idle" }, + away: { glyph: "-", label: "away", className: "away" }, +}; + +const attentionStates = new Set(["blocked", "waiting"]); +const markKind = (mark) => String(mark?.kind ?? "").toLowerCase(); +const markText = (mark) => String(mark?.text ?? ""); +const isApproval = (mark) => mark?.kind === "approval"; +const isInbox = (mark) => /inbox|message/.test(markKind(mark)); +const isActive = (actor) => ["working", "waiting", "blocked"].includes(actor.state); + +function setText(node, value) { + const text = String(value ?? ""); + if (node.textContent !== text) node.textContent = text; +} + +/** Resolve focus to a visible disclosure summary before falling back to a section tab. */ +export function focusSpaceControl(target, fallback) { + let candidate = target; + for (let ancestor = target?.parentElement; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.tagName === "DETAILS" && !ancestor.open) candidate = ancestor.querySelector("summary"); + } + if (candidate && candidate.offsetParent !== null && !candidate.closest("[hidden],[inert]")) candidate.focus({ preventScroll: true }); + else fallback?.focus({ preventScroll: true }); +} + +export function createSpaceTeam(root, host) { + let signature = ""; + let destroyed = false; + + function element(tag, attrs, ...children) { + return host.h(tag, attrs, ...children); + } + + function actorButton(actor, cards, attention = false) { + const info = stateInfo[actor.state] ?? { glyph: "○", label: String(actor.state ?? "unknown"), className: "unknown" }; + const button = element("button", { + type: "button", + class: attention ? "space-team-attention-row" : "space-team-tile", + "data-actor-id": actor.id, + "data-team-focus": `${attention ? "attention" : "tile"}:${actor.id}`, + "data-space-actor": actor.id, + "data-state": info.className, + "data-stale": String(Boolean(actor.stale)), + onclick: (event) => host.openActor(actor.id, event.currentTarget), + }); + const avatar = element("span", { class: "space-team-avatar", "aria-hidden": "true" }); + const sprite = host.sprite(actor); + if (sprite) avatar.append(sprite); + else setText(avatar, String(actor.name ?? "").slice(0, 1)); + + const identity = element("span", { class: "space-team-identity" }); + identity.append(element("strong", { class: "space-team-name", text: actor.name ?? "" })); + const roles = element("span", { class: "space-team-roles" }); + for (const role of actor.roles ?? []) roles.append(element("span", { class: "space-team-role", text: role })); + identity.append(roles); + + const state = element("span", { class: "space-team-state", "data-state": info.className }, + element("span", { "aria-hidden": "true", text: info.glyph }), + element("span", { text: actor.stale ? `${info.label} (last reported, stale)` : info.label })); + const details = element("span", { class: "space-team-taskline" }); + const card = (actor.card && cards.get(actor.card)) || actor.card; + const taskTitle = typeof card === "object" ? card.title : undefined; + if (taskTitle) setText(details, taskTitle); + else if (typeof card === "string") setText(details, card); + + const badges = element("span", { class: "space-team-badges" }); + const marks = actor.marks ?? []; + const inboxMarks = marks.filter(isInbox); + const approvalMarks = marks.filter(isApproval); + const markLabel = (items, fallback) => { + const label = items.map(markText).find(Boolean); + if (!label) return `${fallback} ${items.length}`; + return /\d/.test(label) || /inbox|approv/i.test(label) ? label : `${fallback} ${label}`; + }; + if (inboxMarks.length) badges.append(element("span", { class: "space-team-badge", text: markLabel(inboxMarks, "inbox") })); + if (approvalMarks.length) badges.append(element("span", { class: "space-team-badge space-team-badge-approval", text: markLabel(approvalMarks, "approval") })); + + button.append(avatar, identity, state, details, badges); + return button; + } + + function update(model) { + if (destroyed) return; + const stableModel = { ...model }; + delete stableModel.sampledAt; + delete stableModel.samples; + const nextSignature = JSON.stringify(stableModel); + if (nextSignature === signature) return; + + const previousOpen = new Map([...root.querySelectorAll("details[data-group-id]")].map((node) => [node.dataset.groupId, node.open])); + const active = root.ownerDocument.activeElement; + const focusKey = root.contains(active) ? active?.getAttribute?.("data-team-focus") : null; + const groups = model.groups ?? []; + const actors = model.actors ?? []; + const cards = new Map((model.cards ?? []).map((card) => [card.id, card])); + const grouped = new Map(groups.map((group) => [group.id, []])); + const ungrouped = []; + for (const actor of actors) { + if (grouped.has(actor.group)) grouped.get(actor.group).push(actor); + else ungrouped.push(actor); + } + + const fragment = root.ownerDocument.createDocumentFragment(); + const attentionActors = actors.filter((actor) => attentionStates.has(actor.state) || (actor.marks ?? []).some(isApproval)); + const attention = element("section", { class: "space-team-attention", "aria-labelledby": "space-team-attention-title" }); + attention.append(element("h2", { id: "space-team-attention-title", text: "Needs attention" })); + if (attentionActors.length) { + for (const actor of attentionActors) attention.append(actorButton(actor, cards, true)); + } else { + attention.append(element("p", { class: "space-team-empty", text: "No agents need attention." })); + } + fragment.append(attention); + + function appendGroup(id, title, members) { + const section = element("section", { class: "space-team-group", "data-group-id": id }); + const heading = element("header", { class: "space-team-group-header" }); + const activeCount = members.filter(isActive).length; + heading.append(element("div", { class: "space-team-group-title" }, + element("h2", { text: title }), + element("span", { class: "space-team-count", text: `${members.length} agents, ${activeCount} active` }))); + const shortcuts = element("div", { class: "space-team-shortcuts" }); + for (const view of ["board", "flow"]) shortcuts.append(element("button", { + type: "button", class: "btn", "data-team-focus": `shortcut:${id}:${view}`, text: view === "board" ? "Board" : "Flow", + onclick: () => host.choose({ view, group: id }), + })); + heading.append(shortcuts); + section.append(heading); + const activeMembers = members.filter(isActive); + if (activeMembers.length) { + const grid = element("div", { class: "space-team-grid" }); + for (const actor of activeMembers) grid.append(actorButton(actor, cards)); + section.append(grid); + } + const resting = members.filter((actor) => !isActive(actor)); + if (resting.length) { + const disclosure = element("details", { class: "space-team-resting", "data-group-id": id }); + disclosure.open = previousOpen.get(id) ?? false; + const restingText = ["idle", "away"].map((state) => { + const names = resting.filter((actor) => actor.state === state).map((actor) => actor.name); + return names.length ? `${state}: ${names.join(", ")}` : ""; + }).filter(Boolean).join(" · "); + disclosure.append(element("summary", { "data-team-focus": `resting:${id}`, text: restingText })); + const grid = element("div", { class: "space-team-grid space-team-resting-grid" }); + for (const actor of resting) grid.append(actorButton(actor, cards)); + disclosure.append(grid); + section.append(disclosure); + } + fragment.append(section); + } + + for (const group of groups) appendGroup(group.id, group.title, grouped.get(group.id) ?? []); + if (ungrouped.length) appendGroup("", "Ungrouped", ungrouped); + + signature = nextSignature; + root.replaceChildren(fragment); + if (focusKey) { + const exact = root.querySelector(`[data-team-focus="${CSS.escape(focusKey)}"]`); + const actor = focusKey.startsWith("attention:") ? focusKey.slice("attention:".length) : null; + const tile = actor ? root.querySelector(`[data-team-focus="${CSS.escape(`tile:${actor}`)}"]`) : null; + focusSpaceControl(exact ?? tile, host.sectionTab()); + } + } + + function destroy() { + if (destroyed) return; + destroyed = true; + signature = ""; + root.replaceChildren(); + } + + return { update, destroy }; +} diff --git a/shell/space-view.js b/shell/space-view.js index 78c80ff..b946de1 100644 --- a/shell/space-view.js +++ b/shell/space-view.js @@ -13,8 +13,8 @@ export function createSpaceViewController(host) { const mount = h("div", { class: "space-webgl", "aria-hidden": "true", hidden: true }); stage.prepend(mount); const fallback = h("p", { class: "notice space-view-notice", role: "status", hidden: true }); - const viewLabel = h("span", { text: "View" }); - const choices = h("div", { class: "space-view-choices", role: "group", "aria-label": "Space view" }, viewLabel); + const viewLabel = h("span", { text: "Scene style" }); + const choices = h("div", { class: "space-view-choices", role: "group", "aria-label": "Scene style" }, viewLabel); const controls = h("div", { class: "space-camera", role: "group", "aria-label": "Room camera" }); const bar = h("div", { class: "space-view-bar" }, choices, controls); const selection = h("p", { class: "space-selection sub", hidden: true }); @@ -25,10 +25,10 @@ export function createSpaceViewController(host) { try { saved = localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem("acon.space.view") ?? "flat"; } catch { /* Storage may be disabled. */ } let state = null, webgl = null, lastReady = null, generation = 0, destroyed = false; let camera = { yaw: 45, pitch: 60, zoom: 1 }; - let cameraTouched = false; + let cameraTouched = false, suspended = false; const buttons = new Map(); for (const [id, label] of [["flat", "Flat"], ["room", "Room"], ...(available ? [["3d", "3D"]] : [])]) { - const button = h("button", { type: "button", class: "btn", text: label, "data-space-view": id, "aria-pressed": "false", onclick: () => select(id) }); + const button = h("button", { type: "button", class: "btn", text: label, "aria-label": `${label} scene style`, "data-space-view": id, "aria-pressed": "false", onclick: () => select(id) }); choices.append(button); buttons.set(id, button); } const rotate = (step) => { cameraTouched = true; camera = { ...camera, yaw: camera.yaw + step }; apply(); }; @@ -68,7 +68,7 @@ export function createSpaceViewController(host) { scroll.scrollLeft = 0; scroll.scrollTop = 0; }; function apply() { - if (!state || destroyed) return; + if (!state || destroyed || suspended) return; root.dataset.spaceView = view; plane.dataset.view = view; for (const [id, button] of buttons) button.setAttribute("aria-pressed", String(id === view)); controls.hidden = view === "flat"; mount.hidden = view !== "3d"; @@ -144,7 +144,7 @@ export function createSpaceViewController(host) { plane.style.removeProperty("width"); plane.style.removeProperty("min-width"); if (state) plane.style.height = `${state.layout.height}px`; apply(); - if (view === "3d" && !webgl) { + if (view === "3d" && !webgl && !suspended) { try { const { createSpace3D } = await import("/space3d.js"); if (id !== generation || destroyed) return; @@ -160,7 +160,8 @@ export function createSpaceViewController(host) { const resize = new ResizeObserver(() => { if (view !== "flat") apply(); }); resize.observe(scroll); const theme = new MutationObserver(() => { if (view === "3d") apply(); }); theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); return { - update(next) { state = next; if (!root.dataset.spaceView) select(["flat", "room", "3d"].includes(saved) ? saved : "flat"); else apply(); }, + update(next) { suspended = false; state = next; if (!root.dataset.spaceView) select(["flat", "room", "3d"].includes(saved) ? saved : "flat"); else if (view === "3d" && !webgl) select("3d"); else apply(); }, + suspend() { if (suspended) return; suspended = true; generation += 1; webgl?.destroy(); webgl = null; lastReady = null; lastProjection = null; mount.replaceChildren(); delete mount.dataset.ready; }, select, get stats() { return { view, renderCount: webgl?.renderCount ?? 0, pendingFrames: webgl?.pendingFrames ?? 0, modelCount: webgl?.modelCount ?? 0, boxCount: webgl?.boxCount ?? 0 }; }, destroy() { destroyed = true; generation += 1; resize.disconnect(); theme.disconnect(); scroll.removeEventListener("keydown", onKey); webgl?.destroy(); plane.removeEventListener("focusin", describe); plane.removeEventListener("pointerover", describe); plane.removeEventListener("focusout", clearDescription); plane.removeEventListener("pointerout", clearDescription); bar.remove(); selection.remove(); fallback.remove(); } diff --git a/shell/space.js b/shell/space.js index 328921f..aee15c3 100644 --- a/shell/space.js +++ b/shell/space.js @@ -1,3 +1,4 @@ +import { createSpaceTeam, focusSpaceControl } from "./space-team.js"; import { createSpaceViewController } from "./space-view.js"; // Private shell module. Public collaboration facts enter through the space snapshot allowlist. @@ -5,16 +6,16 @@ export const SPACE_CAPS = { actors: 96, cards: 200, links: 128 }; const ACTOR_WIDTH = 180, ACTOR_STEP = 200, ZONE_WIDTH = 234, ZONE_STEP = 250; export function mergeSpaceScenes(entries) { - const model = { groups: [], actors: [], zones: [], cards: [], links: [], dropped: { actors: 0, cards: 0, links: 0 }, failed: [], received: 0, sampledAt: 0, samples: new Map() }; + const model = { groups: [], actors: [], zones: [], cards: [], links: [], dropped: { actors: 0, cards: 0, links: 0 }, failed: [], pending: 0, received: 0, sampledAt: 0, samples: new Map() }; for (const [tool, entry] of [...entries].sort(([a], [b]) => a.localeCompare(b))) { if (entry.failed) model.failed.push(tool); const scene = entry.scene; - if (!scene) continue; + if (!scene) { if (!entry.failed && !entry.reported) model.pending += 1; continue; } model.received += 1; model.samples.set(tool, entry.sampledAt ?? 0); model.sampledAt = Math.max(model.sampledAt, entry.sampledAt ?? 0); const key = (id) => `${tool}:${id}`; - const groups = scene.groups?.length ? scene.groups.map((g) => ({ id: key(g.id), title: g.title })) : [{ id: key("~tool"), title: tool }]; + const groups = scene.groups?.length ? scene.groups.slice(0, SPACE_CAPS.cards).map((g) => ({ id: key(g.id), title: g.title })) : [{ id: key("~tool"), title: tool }]; model.groups.push(...groups); const group = (item) => scene.groups?.length ? (item.group ? key(item.group) : undefined) : groups[0].id; const target = (link) => ({ tool, panel: link.panel, ...(link.params ? { params: link.params } : {}) }); @@ -31,9 +32,6 @@ export function mergeSpaceScenes(entries) { model.dropped[k] += Math.max(0, model[k].length - SPACE_CAPS[k]); model[k] = model[k].slice(0, SPACE_CAPS[k]); } - model.groups = model.groups.slice(0, SPACE_CAPS.cards); - const groups = new Set(model.groups.map((g) => g.id)); - for (const item of [...model.actors, ...model.zones, ...model.cards]) if (item.group && !groups.has(item.group)) delete item.group; const actors = new Set(model.actors.map((a) => a.id)); const cards = new Set(model.cards.map((c) => c.id)); for (const actor of model.actors) if (actor.card && !cards.has(actor.card)) delete actor.card; @@ -50,7 +48,9 @@ export function filterSpaceModel(model, group, state) { const known = !group || model.groups.some((g) => g.id === group); const actors = model.actors.filter((a) => known && includes(a) && (!state || a.state === state)); const actorIds = new Set(actors.map((a) => a.id)); - const cards = model.cards.filter((c) => known && includes(c) && (!state || actorIds.has(c.owner))); + // A kept actor keeps its current card even when the card has no owner or another owner. + const actorCards = new Set(actors.map((a) => a.card).filter(Boolean)); + const cards = model.cards.filter((c) => known && includes(c) && (!state || actorIds.has(c.owner) || actorCards.has(c.id))); const cardIds = new Set(cards.map((c) => c.id)); const zones = new Set(cards.map((c) => c.zone)); return { ...model, actors: actors.map((a) => a.card && !cardIds.has(a.card) ? { ...a, card: undefined } : a), @@ -123,15 +123,18 @@ 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 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, content, empty); - let selection = { view: "room", group: "", state: "" }; - let savedView = "room"; - try { const saved = localStorage.getItem("acon.space.view"); if (["team", "flow", "board", "room"].includes(saved)) savedView = saved; } catch { /* Persistence is optional. */ } + root.replaceChildren(header, placeholder, notice, teamRoot, content, empty, detailRoot); + let selection = { view: "team", group: "", state: "", actor: "" }; + let savedView = "team"; + let returnPoint = null, restorePoint = null, detailSignature = ""; + try { const saved = localStorage.getItem("acon.space.section"); if (["team", "flow", "board", "room"].includes(saved)) savedView = saved; } catch { /* Persistence is optional. */ } const sectionButtons = new Map(), summaryButtons = new Map(); function choose(patch) { - const next = { ...selection, ...patch }; + const next = { ...selection, actor: "", ...patch }; host.navigateSpace?.(Object.fromEntries(Object.entries(next).filter(([, value]) => value))); } for (const [id, title] of [["team", "Team"], ["flow", "Flow"], ["board", "Board"], ["room", "Room"]]) { @@ -145,21 +148,22 @@ export function createSpaceRenderer(root, host) { const groupSummary = h("button", { class: "btn", type: "button", "aria-label": "Clear project and state filters", onclick: () => choose({ group: "", state: "" }) }); summary.append(groupSummary); function setRoute(params = {}) { - selection = { view: ["team", "flow", "board", "room"].includes(params.view) ? params.view : savedView, + if (selection.actor && !params.actor) { restorePoint = returnPoint ?? { pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, columnTop: root.closest(".column")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; returnPoint = null; } + selection = { actor: params.actor ?? "", view: ["team", "flow", "board", "room"].includes(params.view) ? params.view : savedView, group: params.group ?? "", state: ["working", "waiting", "blocked", "away", "idle"].includes(params.state) ? params.state : "" }; if (selection.view !== savedView) { savedView = selection.view; - try { localStorage.setItem("acon.space.view", savedView); } catch { /* Optional persistence. */ } + try { localStorage.setItem("acon.space.section", savedView); } catch { /* Optional persistence. */ } } } - const views = createSpaceViewController({ root, plane, scroll, h, onError: host.onError }); + let views = null; const actorNodes = new Map(), cardNodes = new Map(), zoneNodes = new Map(), linkNodes = new Map(), rosterNodes = new Map(); const entries = new Map(); const seenMessages = new Map(), samples = new Map(); 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 target = (key) => { const entry = entries.get(key); if (entry) host.navigate(entry.link.tool, entry.link.panel, entry.link.params); }; + 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); } }; const place = (el, position) => { if (position) setStyle(el, "transform", `translate(${position.x}px, ${position.y}px)`); }; @@ -168,6 +172,75 @@ 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 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 }; + choose({ actor: id }); + } + function closeActor() { if (returnPoint) history.back(); else choose({ actor: "" }); } + const onDetailKey = (event) => { + if (!selection.actor || root.hidden || document.querySelector("dialog[open]")) return; + if (!detailRoot.contains(document.activeElement) && document.activeElement !== document.body) return; + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeActor(); } + if (event.key === "Tab") { + const buttons = [...detailRoot.querySelectorAll("button,a[href],input,select,textarea,[tabindex]")].filter((el) => !el.disabled && el.offsetParent !== null); + const first = buttons[0], last = buttons[buttons.length - 1]; + if (!detailRoot.contains(document.activeElement)) { event.preventDefault(); first?.focus(); } + else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + } + }; + document.addEventListener("keydown", onDetailKey, true); + // Everything in Space except the detail is inert while it is open, including siblings added later (Room controls). + function syncInert() { const on = Boolean(selection.actor); for (const element of root.children) if (element !== detailRoot && element.inert !== on) element.inert = on; } + // Entering Space (e.g. Back from a producer panel) moves focus to
; put it back in an open detail. + function enter() { if (selection.actor && !detailRoot.hidden && !detailRoot.contains(document.activeElement)) detailRoot.querySelector("[data-detail-focus=\"back\"]")?.focus({ preventScroll: true }); } + function renderDetail(model) { + const actor = model.actors.find((a) => a.id === selection.actor); + const card = model.cards.find((c) => c.id === actor?.card); + 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)]); + if (sig === detailSignature) return; + detailSignature = sig; + const wasHidden = detailRoot.hidden; + const hadFocus = detailRoot.contains(document.activeElement); + const focusKey = hadFocus ? document.activeElement?.getAttribute("data-detail-focus") : null; + detailRoot.hidden = !selection.actor; + syncInert(); + if (!selection.actor) return; + const back = h("button", { type: "button", class: "btn space-detail-back", "data-detail-focus": "back", text: "Back to Space", onclick: closeActor }); + if (!actor) { + detailRoot.replaceChildren(back, h("h2", { id: "space-agent-title", text: "Agent detail" }), h("p", { class: "notice", text: model.pending ? "Waiting for agent data." : "Agent not in the current scene; close this detail and choose an available agent." })); + } else { + const title = h("h2", { id: "space-agent-title", text: actor.name }); + const since = actor.since ? Math.max(0, Math.floor((Date.now() - Date.parse(actor.since)) / 60000)) : null; + const elapsed = since === null ? "" : since < 1 ? "State changed less than a minute ago" : `State changed ${since} minutes ago`; + const marks = actor.marks.map((m) => h("span", { class: "chip", text: `${m.kind}: ${m.text}` })); + detailRoot.replaceChildren(back, h("header", { class: "space-agent-detail-header" }, h("span", { class: "space-team-avatar" }, sprite(actor)), title, + h("p", { text: group?.title ?? "Ungrouped" }), h("div", { class: "space-team-roles" }, (actor.roles ?? []).map((r) => h("span", { class: "chip", text: r }))), + h("p", { text: `${stateGlyph[actor.state]} ${stateText[actor.state]}${actor.stale ? " (last reported, stale)" : ""}` }), ...(elapsed ? [h("p", { class: "sub", text: elapsed })] : [])), + 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("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 }); + } + 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 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; + // At compact widths .column, not main, is the page scroller. + const column = root.closest(".column"); if (column && column !== main) column.scrollTop = point.columnTop; + scroll.scrollTop = point.sceneTop; scroll.scrollLeft = point.sceneLeft; + window.scrollTo(point.pageX, point.pageY); + } function update(model, force = false) { lastModel = model; const pack = host.packs()[0]; @@ -186,7 +259,10 @@ export function createSpaceRenderer(root, host) { signature = sig; root.dataset.spaceSection = selection.view; const room = selection.view === "room"; - content.hidden = !room; placeholder.hidden = room; + content.hidden = !room; teamRoot.hidden = selection.view !== "team"; placeholder.hidden = room || selection.view === "team"; + // 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 }); setText(placeholder, `${selection.view[0].toUpperCase() + selection.view.slice(1)} view is not available yet. Room shows the current scene.`); for (const [id, button] of sectionButtons) button.setAttribute("aria-pressed", String(id === selection.view)); const grouped = filterSpaceModel(model, selection.group, ""); @@ -195,31 +271,37 @@ export function createSpaceRenderer(root, host) { button.setAttribute("aria-pressed", String(selection.state === state)); } setText(groupSummary, `${model.groups.length} projects`); + groupSummary.setAttribute("aria-label", `${model.groups.length} projects, clear project and state filters`); const nextGroups = JSON.stringify(model.groups); if (nextGroups !== groupSignature) { groupSignature = nextGroups; project.replaceChildren(h("option", { value: "", text: "All projects" }), ...model.groups.map((g) => h("option", { value: g.id, text: g.title }))); } project.value = selection.group; - const unknownGroup = selection.group && !model.groups.some((g) => g.id === selection.group); + const unknownGroup = !model.pending && selection.group && !model.groups.some((g) => g.id === selection.group); + renderDetail(model); model = filterSpaceModel(model, selection.group, selection.state); - image(plane, "--space-floor", pack, "floor"); image(wall, "--space-wall", pack, "wall"); - plane.dataset.pack = pack?.id ?? "fallback"; - if (pack) setStyle(plane, "background-size", `${pack.tile[0]}px ${pack.tile[1]}px`); - else setStyle(plane, "background-size", "64px 32px"); + 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(" "); if (notice.hidden !== !message) notice.hidden = !message; setText(notice, message); const nothing = !model.actors.length && !model.cards.length; if (empty.hidden !== (!nothing || !room || Boolean(unknownGroup))) empty.hidden = !nothing || !room || Boolean(unknownGroup); + if (!room) { views?.suspend(); restore(); return; } + if (!views) views = createSpaceViewController({ root, plane, scroll, h, onError: host.onError }); + syncInert(); + image(plane, "--space-floor", pack, "floor"); image(wall, "--space-wall", pack, "wall"); + plane.dataset.pack = pack?.id ?? "fallback"; + if (pack) setStyle(plane, "background-size", `${pack.tile[0]}px ${pack.tile[1]}px`); + else setStyle(plane, "background-size", "64px 32px"); entries.clear(); for (const actor of model.actors) { entries.set(`actor:${actor.id}`, actor); let n = actorNodes.get(actor.id); if (!n) { const name = h("strong"), status = h("span", { class: "row-sub" }), marks = h("span", { class: "space-marks" }), visual = h("span", { class: "space-visual" }); - const el = h("button", { type: "button", class: "space-actor", "data-key": actor.id, onclick: () => target(`actor:${actor.id}`) }, visual, name, status, marks); + const el = h("button", { type: "button", class: "space-actor", "data-key": actor.id, onclick: (event) => target(`actor:${actor.id}`, event.currentTarget) }, visual, name, status, marks); plane.append(el); n = { el, name, status, marks, visual, sig: "" }; actorNodes.set(actor.id, n); } const card = model.cards.find((c) => c.id === actor.card); @@ -235,7 +317,7 @@ export function createSpaceRenderer(root, host) { image(n.visual, "--space-desk", pack, "desk"); } let r = rosterNodes.get(`actor:${actor.id}`); - if (!r) { r = h("button", { type: "button", class: "space-roster-actor btn", "data-key": actor.id, onclick: () => target(`actor:${actor.id}`) }); roster.append(r); rosterNodes.set(`actor:${actor.id}`, r); } + if (!r) { r = h("button", { type: "button", class: "space-roster-actor btn", "data-key": actor.id, onclick: (event) => target(`actor:${actor.id}`, event.currentTarget) }); roster.append(r); rosterNodes.set(`actor:${actor.id}`, r); } setText(r, `${displayName}: ${text}`); } for (const zone of model.zones) { @@ -302,6 +384,7 @@ export function createSpaceRenderer(root, host) { actors: actorMetrics, cards: cardMetrics, zones: [...zoneNodes].map(([id, n]) => ({ id, el: n.el, width: n.el.offsetWidth, height: n.el.offsetHeight })) }); + restore(); } let geometry = ""; const observer = new ResizeObserver(() => { @@ -311,5 +394,5 @@ export function createSpaceRenderer(root, host) { if (lastModel && !root.hidden) update(lastModel, true); }); observer.observe(root); - return { update, setRoute, get viewStats() { return views.stats; }, destroy() { observer.disconnect(); views.destroy(); root.replaceChildren(); } }; + 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(); } }; } diff --git a/src/server.ts b/src/server.ts index ab7c4a3..e63eb50 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-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-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 3faafd5..17f6c78 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-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-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-server.test.ts b/test/space-server.test.ts index 9b75566..ea583bb 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 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 new file mode 100644 index 0000000..f03e8d1 --- /dev/null +++ b/test/space-team.test.ts @@ -0,0 +1,228 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +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") }; + 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; + 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(""); } + contains(node: Element | null): boolean { return node === this || this.children.some((c) => c.contains(node)); } + get parentElement() { return this.parent; } + get tagName() { return this.tag.toUpperCase(); } + get offsetParent(): Element | null { for(let n:Element|null=this;n;n=n.parent){if(n.hidden || (n as any).inert)return null;}return main; } + get isConnected(): boolean { return this.tag === "root" || Boolean(this.parent?.isConnected); } + setAttribute(k: string,v: string) { this.attributes.set(k,String(v)); if(k.startsWith("data-")) this.dataset[k.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase())]=String(v); writes++; } + getAttribute(k: string) { return this.attributes.get(k) ?? null; } + append(...children: Element[]) { for(const child of children.flat()) { if(child.tag === "fragment") this.append(...child.children); else { child.parent=this; this.children.push(child); writes++; } } } + replaceChildren(...children: Element[]) { for(const c of this.children)c.parent=null; this.children=[]; this.ownText=""; this.append(...children); writes++; } + querySelectorAll(selector: string): Element[] { + const matches = (node: Element,s: string) => { + const tag = s.match(/^[a-z]+/)?.[0]; if(tag && node.tag!==tag)return false; + const cls = s.match(/\.([\w-]+)/)?.[1]; if(cls && !(node.getAttribute("class")??"").split(" ").includes(cls))return false; + for(const a of s.matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g))if(a[2]===undefined ? node.getAttribute(a[1]!)===null : node.getAttribute(a[1]!)!==a[2])return false; + return true; + }; + return this.children.flatMap(c=>[...(selector.split(",").some(s=>matches(c,s))?[c]:[]),...c.querySelectorAll(selector)]); + } + querySelector(s:string) { return this.querySelectorAll(s)[0] ?? null; } + focus() { + if(this.offsetParent===null)return; + for(let n=this.parent;n;n=n.parent)if(n.tag==="details"&&!n.open&&n.querySelector("summary")!==this)return; + doc.activeElement=this; + } + closest(s:string) { + if(s === "[hidden],[inert]") { for(let n:Element|null=this;n;n=n.parent)if(n.hidden || (n as any).inert)return n;return null; } + return main; + } + remove() { if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this); this.parent=null; } + } + const main = new Element("main"), root = new Element("root"); + 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 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 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}]])); + return {api,renderer,root,model,doc,nav,listeners,storage,get writes(){return writes;},get back(){return back;}}; +} +test("Team is default, shows attention/roles/task/badges and collapsed resting actors without Room initialization",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + expect(h.root.dataset.spaceSection).toBe("team"); + expect(h.root.textContent).toContain("Needs attention");expect(h.root.textContent).toContain("Exact / name");expect(h.root.textContent).toContain("implementer");expect(h.root.textContent).toContain("Task title");expect(h.root.textContent).toContain("3 inbox"); + expect(h.root.querySelector("details[data-group-id]")?.open).toBe(false); + const before=h.writes;h.renderer.update({...h.model,sampledAt:2});expect(h.writes).toBe(before); +}); +test("actor detail resolves exact actor/card/partners and closes with focus restoration",()=>{ + 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 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"}]); + h.root.querySelector(".space-detail-back")!.onclick?.({});expect(h.back).toBe(1); + h.renderer.setRoute({});h.renderer.update(h.model);expect(detail.hidden).toBe(true);expect(h.doc.activeElement).toBe(actor); +}); +test("unknown actor and group notices wait for scene reports",()=>{ + const h=harness();h.renderer.setRoute({actor:"unknown",group:"unknown"}); + h.renderer.update({...h.model,pending:1});expect(h.root.textContent).not.toContain("Project not in the current scene");expect(h.root.textContent).toContain("Waiting for agent data."); + h.renderer.update({...h.model,pending:0});expect(h.root.textContent).toContain("Project not in the current scene");expect(h.root.textContent).toContain("Agent not in the current scene"); + h.renderer.setRoute({});h.renderer.update(h.model);expect(h.doc.activeElement.getAttribute("data-space-section-tab")).toBe("team"); +}); + +test("group limits are per source and never erase another source's group identity",()=>{ + const h=harness();const groups=Array.from({length:150},(_,i)=>({id:`g${i}`,title:`Group ${i}`})); + const scene={groups,actors:[{id:"actor",name:"Actor",group:"g149",state:"idle",link:{panel:"notes"}}],zones:[],cards:[],links:[]}; + const merged=h.api.mergeSpaceScenes(new Map([["a",{scene}],["b",{scene}]])); + expect(merged.groups).toHaveLength(300);expect(merged.actors.map((a:any)=>a.group)).toEqual(["a:g149","b:g149"]); +}); +test("internal Space routes keep poll deadlines and do not start a re-poll",async()=>{ + const source=readFileSync(new URL("../shell/shell.js",import.meta.url),"utf8"); + const body=source.slice(source.indexOf("async function showSpace("),source.indexOf("function renderSpace(")); + const entry={next:1234};const state={route:{area:"space",params:{view:"team"}},space:new Map([["demo",entry]])}; + const forced:boolean[]=[];let polls=0; + const show=runInNewContext(`${body}\nshowSpace`,{spaceLoading:Promise.resolve(),spaceRenderer:{setRoute(){}},state,spaceTools:()=>[{tool:"demo"}],renderSpace:(v:boolean)=>forced.push(v),pollSpace:()=>{polls++;}}) as (internal:boolean)=>Promise; + await show(true);expect(entry.next).toBe(1234);expect(polls).toBe(0);expect(forced).toEqual([false]); + await show(false);expect(entry.next).toBe(0);expect(polls).toBe(1); +}); + +test("changed actor facts retain the exact detail control and missing controls fall back to Back",()=>{ + const h=harness();h.renderer.setRoute({actor:"demo:one"});h.renderer.update(h.model); + const detail=h.root.querySelector(".space-agent-detail")!; + detail.querySelector('[data-detail-focus="open"]')!.focus(); + const changed={...h.model,actors:h.model.actors.map((a:any)=>({...a,name:a.name+" changed"}))}; + h.renderer.update(changed); + expect(detail.contains(h.doc.activeElement)).toBe(true); + expect(h.doc.activeElement.getAttribute("data-detail-focus")).toBe("open"); + detail.querySelector('[data-detail-focus="task"]')!.focus(); + h.renderer.update({...changed,actors:changed.actors.map((a:any)=>({...a,card:undefined}))}); + expect(h.doc.activeElement.getAttribute("data-detail-focus")).toBe("back"); + h.doc.body.focus();let prevented=false; + h.listeners.get("keydown")!({key:"Tab",preventDefault(){prevented=true;}}); + expect(prevented).toBe(true);expect(h.doc.activeElement.getAttribute("data-detail-focus")).toBe("back"); +}); +test("changed Team facts preserve shortcut, disclosure, attention and tile focus independently",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + let turn=0; + for(const key of ["shortcut:demo:alpha:board","shortcut:demo:alpha:flow","resting:demo:alpha","attention:demo:one","tile:demo:one"]) { + h.root.querySelector(`[data-team-focus="${key}"]`)!.focus(); + turn++;h.renderer.update({...h.model,actors:h.model.actors.map((a:any)=>({...a,name:a.name+turn}))}); + expect(h.doc.activeElement.getAttribute("data-team-focus")).toBe(key); + expect(h.doc.activeElement.isConnected).toBe(true); + } +}); +test("resting summary groups idle and away names once per state",()=>{ + const h=harness();h.renderer.setRoute({}); + const resting=h.model.actors[1]; + h.renderer.update({...h.model,actors:[...h.model.actors,{...resting,id:"other",name:"Other"},{...resting,id:"away",name:"Away",state:"away"}]}); + expect(h.root.querySelector('[data-team-focus="resting:demo:alpha"]')!.textContent).toBe("idle: Resting, Other · away: Away"); +}); + +test("an active tile becoming idle focuses its closed resting summary",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + h.root.querySelector('[data-team-focus="tile:demo:one"]')!.focus(); + const idle={...h.model,actors:h.model.actors.map((a:any)=>({...a,state:"idle",marks:[]}))}; + h.renderer.update(idle); + expect(h.doc.activeElement.getAttribute("data-team-focus")).toBe("resting:demo:alpha"); + expect(h.doc.activeElement.parentElement.open).toBe(false); +}); +test("closing detail after an actor rests focuses the disclosure instead of its hidden tile",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + const actor=h.root.querySelector('[data-team-focus="tile:demo:one"]')!; + actor.focus();actor.onclick?.({currentTarget:actor}); + h.renderer.setRoute({actor:"demo:one"});h.renderer.update(h.model); + const idle={...h.model,actors:h.model.actors.map((a:any)=>({...a,state:"idle",marks:[]}))}; + h.renderer.update(idle);h.renderer.setRoute({});h.renderer.update(idle); + expect(h.doc.activeElement.getAttribute("data-team-focus")).toBe("resting:demo:alpha"); + expect(h.root.querySelector(".space-agent-detail")!.contains(h.doc.activeElement)).toBe(false); +}); +test("removed Team controls fall back to the Space section tab",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + h.root.querySelector('[data-team-focus="tile:demo:one"]')!.focus(); + h.renderer.update({...h.model,actors:[],groups:[],cards:[],zones:[],links:[]}); + expect(h.doc.activeElement.getAttribute("data-space-section-tab")).toBe("team"); +}); +test("native dialogs own Escape and Tab while actor detail stays open",()=>{ + const h=harness();h.renderer.setRoute({actor:"demo:one"});h.renderer.update(h.model); + const palette:any={open:true};h.doc.nativeDialog=palette; + h.doc.activeElement={getAttribute:()=>null};let prevented=false; + for(const key of ["Tab","Escape"]) { + h.listeners.get("keydown")!({key,preventDefault(){prevented=true;},stopPropagation(){}}); + // Native dialog handling remains responsible for its default Escape behavior. + if(key==="Escape"&&!prevented)palette.open=false; + } + expect(prevented).toBe(false);expect(palette.open).toBe(false);expect(h.back).toBe(0); + expect(h.root.querySelector(".space-agent-detail")!.hidden).toBe(false); + h.doc.activeElement=h.root;h.listeners.get("keydown")!({key:"Tab",preventDefault(){prevented=true;}}); + expect(prevented).toBe(false); +}); +test("a failed first source is settled, so unknown actor and project guidance appears",()=>{ + const h=harness();const empty={actors:[],zones:[],cards:[],links:[]}; + const merged=h.api.mergeSpaceScenes(new Map([["down",{scene:null,failed:true}],["up",{scene:empty}]])); + expect(merged.pending).toBe(0);expect(merged.failed).toEqual(["down"]); + h.renderer.setRoute({actor:"missing",group:"missing"});h.renderer.update(merged); + expect(h.root.textContent).not.toContain("Waiting for agent data."); + expect(h.root.textContent).toContain("Some space data could not refresh"); + expect(h.root.textContent).toContain("Agent not in the current scene"); + expect(h.root.textContent).toContain("Project not in the current scene"); +}); +test("removing one actor falls back to the section tab, not another Team control",()=>{ + const h=harness();const two={...h.model,actors:[...h.model.actors,{...h.model.actors[0],id:"demo:two",name:"Two",card:undefined}]}; + h.renderer.setRoute({});h.renderer.update(two); + h.root.querySelector('[data-team-focus="tile:demo:one"]')!.focus(); + h.renderer.update({...two,actors:two.actors.filter((a:any)=>a.id!=="demo:one")}); + expect(h.root.querySelector('[data-team-focus="tile:demo:two"]')).not.toBeNull(); + expect(h.doc.activeElement.getAttribute("data-space-section-tab")).toBe("team"); +}); +test("the section choice uses its own key and never rewrites the legacy scene-style key",()=>{ + const h=harness();h.renderer.setRoute({view:"room"}); + expect(h.storage.get("acon.space.section")).toBe("room"); + expect(h.storage.has("acon.space.view")).toBe(false); +}); +test("an open detail makes every other Space child inert, including later siblings",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + const sibling:any=new (h.root as any).constructor("div");h.root.append(sibling); + h.renderer.setRoute({actor:"demo:one"});h.renderer.update(h.model); + const detail=h.root.querySelector(".space-agent-detail")!; + for(const child of h.root.children as any[])expect(Boolean(child.inert)).toBe(child!==detail); + h.renderer.setRoute({});h.renderer.update(h.model); + expect(sibling.inert).toBe(false); +}); +test("entering Space with an open detail moves focus back into the dialog",()=>{ + const h=harness();h.renderer.setRoute({actor:"demo:one"});h.renderer.update(h.model); + h.doc.activeElement=h.doc.body; + h.renderer.update(h.model);h.renderer.enter(); + expect(h.doc.activeElement.getAttribute("data-detail-focus")).toBe("back"); + h.root.querySelector('[data-detail-focus="open"]')!.focus();h.renderer.enter(); + expect(h.doc.activeElement.getAttribute("data-detail-focus")).toBe("open"); +}); +test("a state filter keeps an actor's current card even without a matching owner",()=>{ + const h=harness(); + const model={...h.model,cards:h.model.cards.map((c:any)=>c.id==="demo:task"?{...c,owner:undefined}:c)}; + const filtered=h.api.filterSpaceModel(model,"","working"); + expect(filtered.actors.find((a:any)=>a.id==="demo:one")?.card).toBe("demo:task"); + expect(filtered.cards.map((c:any)=>c.id)).toContain("demo:task"); +}); +test("Team labels stale actors like Room and detail",()=>{ + const h=harness();h.renderer.setRoute({}); + h.renderer.update({...h.model,actors:h.model.actors.map((a:any)=>({...a,stale:true}))}); + const tile=h.root.querySelector('[data-team-focus="tile:demo:one"]')!; + expect(tile.getAttribute("data-stale")).toBe("true"); + expect(tile.textContent).toContain("(last reported, stale)"); +}); +test("a Team Board/Flow shortcut moves focus to the selected section tab",()=>{ + const h=harness();h.renderer.setRoute({});h.renderer.update(h.model); + const shortcut=h.root.querySelector('[data-team-focus="shortcut:demo:alpha:board"]')!;shortcut.focus(); + shortcut.onclick?.({});expect(h.nav.at(-1)).toMatchObject({view:"board",group:"demo:alpha"}); + h.renderer.setRoute({view:"board",group:"demo:alpha"});h.renderer.update(h.model); + expect(h.doc.activeElement.getAttribute("data-space-section-tab")).toBe("board"); +});