From 225ec4a4fef515787e4255a41ef9a412606e31a4 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 14:46:41 +0900 Subject: [PATCH 1/9] feat: add default Space Team view and agent detail --- CHANGELOG.md | 1 + README.md | 10 +- scripts/ui-check-space-foundation.mjs | 17 ++- scripts/ui-check-space-team.mjs | 86 ++++++++++++++ scripts/ui-check.mjs | 41 ++++--- shell/shell.css | 56 +++++++++ shell/shell.js | 11 +- shell/space-team.js | 157 ++++++++++++++++++++++++++ shell/space-view.js | 15 +-- shell/space.js | 110 ++++++++++++++---- src/server.ts | 2 +- src/static.ts | 2 +- test/space-server.test.ts | 1 + test/space-team.test.ts | 83 ++++++++++++++ 14 files changed, 535 insertions(+), 57 deletions(-) create mode 100644 scripts/ui-check-space-team.mjs create mode 100644 shell/space-team.js create mode 100644 test/space-team.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62aa02e..708fd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added +- Make Team the default Space view while honoring a saved view choice. 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..bed237d 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.view` wins when the route omits `view`. 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`. diff --git a/scripts/ui-check-space-foundation.mjs b/scripts/ui-check-space-foundation.mjs index 910b58e..f20d85f 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,20 +22,26 @@ 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(); + 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.view")), "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(); } } diff --git a/scripts/ui-check-space-team.mjs b/scripts/ui-check-space-team.mjs new file mode 100644 index 0000000..62cf6b6 --- /dev/null +++ b/scripts/ui-check-space-team.mjs @@ -0,0 +1,86 @@ +// #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) { + await page.setViewportSize({ width, height: 1000 }); + await page.goto((await ticket()).url); + await page.evaluate(() => { localStorage.removeItem("acon.space.view"); location.hash = "#/local/space"; }); + 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, 1440]) for (const theme of ["light", "dark"]) { + 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); + 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 actor = page.locator("[data-space-actor]").first(); + const actorKey = await actor.getAttribute("data-space-actor"); + 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"); + 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-space-actor='${actorKey}']`).click(); + await page.locator(".space-agent-detail[role='dialog']:not([hidden])").waitFor(); + const detailScreenshot = `space-team-detail-${width}-${theme}.png`; + await page.screenshot({ path: join(output, detailScreenshot), fullPage: true }); + report.captures.push({ key: `space-team-detail-${width}-${theme}`, screenshot: detailScreenshot, width, theme, 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" }); + + 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"); + if (width === 375) { + const screenshot = `space-team-375-${theme}.png`; + await page.evaluate(() => { location.hash = "#/local/space?view=team"; }); + await page.locator(".space-team").waitFor(); + await page.screenshot({ path: join(output, screenshot), fullPage: true }); + report.captures.push({ key: `space-team-375-${theme}`, screenshot, width, theme, view: "space-team" }); + } + report.spaceTeam.push({ width, theme, actorKey, teamWrites, detailWrites, keyboardReturn: true, backReturn: true, directRoute: true, unknownActor: true }); + } catch (error) { + report.findings.push({ kind: "space-team", width, detail: error.message }); + } finally { await context.close(); } + } +} diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index d15847b..c5ab59d 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[hidden]").waitFor(); + 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..d8d8952 100644 --- a/shell/shell.css +++ b/shell/shell.css @@ -591,3 +591,59 @@ 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-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 { grid-column: 2; 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 { grid-column: 1 / -1; grid-row: 3; 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-badges { grid-column: 1 / -1; grid-row: 4; align-self: end; } +.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; } +} + +.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..15b2606 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,11 @@ 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) pollSpace(); } function renderSpace(force = false) { if (!spaceRenderer || state.route.area !== "space") return; diff --git a/shell/space-team.js b/shell/space-team.js new file mode 100644 index 0000000..e607642 --- /dev/null +++ b/shell/space-team.js @@ -0,0 +1,157 @@ +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; +} + +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-space-actor": actor.id, + "data-state": info.className, + 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: 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 focusedActor = active?.getAttribute?.("data-actor-id"); + 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", 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 restingNames = resting.map((actor) => actor.name).join(", "); + disclosure.append(element("summary", { text: `${resting.map((actor) => actor.state === "away" ? "away" : "idle").join(" / ")}: ${restingNames}` })); + 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 (focusedActor) root.querySelector(`[data-actor-id="${CSS.escape(focusedActor)}"]`)?.focus({ preventScroll: true }); + } + + 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..0260b84 100644 --- a/shell/space.js +++ b/shell/space.js @@ -1,3 +1,4 @@ +import { createSpaceTeam } 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) { 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; @@ -123,15 +121,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" }); + 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"; + 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.view"); 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 +146,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, 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. */ } } } - 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 +170,64 @@ 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 }); + function openActor(id, element) { + if (!selection.actor) returnPoint = { element, actor: id, pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.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) 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 (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); + 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; + detailRoot.hidden = !selection.actor; + for (const element of [header, placeholder, notice, teamRoot, content, empty]) element.inert = Boolean(selection.actor); + if (!selection.actor) return; + const back = h("button", { type: "button", class: "btn space-detail-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", 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", text: `Open in ${actor.link.tool}`, onclick: () => host.navigate(actor.link.tool, actor.link.panel, actor.link.params) }))); + } + if (wasHidden) back.focus({ preventScroll: true }); + } + function restore() { + if (!restorePoint) return; + const point = restorePoint; restorePoint = null; + const element = point.element?.isConnected ? point.element : 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; + (element ?? root.querySelector(`[data-space-section-tab="${selection.view}"]`))?.focus({ preventScroll: true }); + const main = root.closest("main"); if (main) main.scrollTop = point.mainTop; + 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 +246,7 @@ 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"; 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 +255,36 @@ 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 }); + 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 +300,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 +367,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 +377,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, 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..2189dbb --- /dev/null +++ b/test/space-team.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +function harness() { + let writes = 0; + const doc: any = { activeElement: null, addEventListener() {}, 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(""); } + 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() { doc.activeElement=this; } + closest() { 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"); + 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})`,{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,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); +}); From c151e0e0ccc20fbc0f83d55ba9f4fc9ebaf4f6c9 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 15:03:19 +0900 Subject: [PATCH 2/9] fix: preserve Space control focus across changed snapshots --- scripts/ui-check-space-team.mjs | 36 +++++++++++++++++-------------- shell/shell.css | 16 ++++++++++---- shell/space-team.js | 19 ++++++++++++----- shell/space.js | 20 ++++++++++------- test/space-team.test.ts | 38 +++++++++++++++++++++++++++++++-- 5 files changed, 94 insertions(+), 35 deletions(-) diff --git a/scripts/ui-check-space-team.mjs b/scripts/ui-check-space-team.mjs index 62cf6b6..68ce945 100644 --- a/scripts/ui-check-space-team.mjs +++ b/scripts/ui-check-space-team.mjs @@ -2,10 +2,10 @@ import assert from "node:assert/strict"; import { join } from "node:path"; -async function enterSpace(page, ticket, width) { +async function enterSpace(page, ticket, width, textPercent) { await page.setViewportSize({ width, height: 1000 }); await page.goto((await ticket()).url); - await page.evaluate(() => { localStorage.removeItem("acon.space.view"); location.hash = "#/local/space"; }); + await page.evaluate((size) => { document.documentElement.style.fontSize = `${size}%`; localStorage.removeItem("acon.space.view"); 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(); @@ -30,35 +30,46 @@ async function unchangedWrites(page, mode) { export async function runSpaceTeam({ browser, ticket, report, output }) { report.spaceTeam = []; - for (const width of [375, 1440]) for (const theme of ["light", "dark"]) { + 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); + 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-space-actor='${actorKey}']`).click(); + 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}.png`; + 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}`, screenshot: detailScreenshot, width, theme, view: "space-agent-detail" }); + 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"); @@ -71,16 +82,9 @@ export async function runSpaceTeam({ browser, ticket, report, output }) { 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"); - if (width === 375) { - const screenshot = `space-team-375-${theme}.png`; - await page.evaluate(() => { location.hash = "#/local/space?view=team"; }); - await page.locator(".space-team").waitFor(); - await page.screenshot({ path: join(output, screenshot), fullPage: true }); - report.captures.push({ key: `space-team-375-${theme}`, screenshot, width, theme, view: "space-team" }); - } - report.spaceTeam.push({ width, theme, actorKey, teamWrites, detailWrites, keyboardReturn: true, backReturn: true, directRoute: true, unknownActor: true }); + 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, detail: error.message }); + report.findings.push({ kind: "space-team", width, theme, textPercent, detail: error.message }); } finally { await context.close(); } } } diff --git a/shell/shell.css b/shell/shell.css index d8d8952..e0a57d8 100644 --- a/shell/shell.css +++ b/shell/shell.css @@ -619,13 +619,20 @@ body.pip { margin: 0; overflow-y: auto; } .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 { grid-column: 2; 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 { 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 { grid-column: 1 / -1; grid-row: 3; 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-badges { grid-column: 1 / -1; grid-row: 4; align-self: end; } +.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; } @@ -640,7 +647,8 @@ body.pip { margin: 0; overflow-y: auto; } } @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; } + .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; } diff --git a/shell/space-team.js b/shell/space-team.js index e607642..db86e96 100644 --- a/shell/space-team.js +++ b/shell/space-team.js @@ -32,6 +32,7 @@ export function createSpaceTeam(root, host) { 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, onclick: (event) => host.openActor(actor.id, event.currentTarget), @@ -82,7 +83,7 @@ export function createSpaceTeam(root, host) { const previousOpen = new Map([...root.querySelectorAll("details[data-group-id]")].map((node) => [node.dataset.groupId, node.open])); const active = root.ownerDocument.activeElement; - const focusedActor = active?.getAttribute?.("data-actor-id"); + 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])); @@ -113,7 +114,7 @@ export function createSpaceTeam(root, host) { 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", text: view === "board" ? "Board" : "Flow", + type: "button", class: "btn", "data-team-focus": `shortcut:${id}:${view}`, text: view === "board" ? "Board" : "Flow", onclick: () => host.choose({ view, group: id }), })); heading.append(shortcuts); @@ -128,8 +129,11 @@ export function createSpaceTeam(root, host) { if (resting.length) { const disclosure = element("details", { class: "space-team-resting", "data-group-id": id }); disclosure.open = previousOpen.get(id) ?? false; - const restingNames = resting.map((actor) => actor.name).join(", "); - disclosure.append(element("summary", { text: `${resting.map((actor) => actor.state === "away" ? "away" : "idle").join(" / ")}: ${restingNames}` })); + 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); @@ -143,7 +147,12 @@ export function createSpaceTeam(root, host) { signature = nextSignature; root.replaceChildren(fragment); - if (focusedActor) root.querySelector(`[data-actor-id="${CSS.escape(focusedActor)}"]`)?.focus({ preventScroll: true }); + 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; + (exact ?? tile ?? root.querySelector("[data-team-focus]") ?? root).focus({ preventScroll: true }); + } } function destroy() { diff --git a/shell/space.js b/shell/space.js index 0260b84..e62681a 100644 --- a/shell/space.js +++ b/shell/space.js @@ -121,7 +121,7 @@ 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" }); + 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); @@ -172,7 +172,7 @@ export function createSpaceRenderer(root, host) { }; const team = createSpaceTeam(teamRoot, { h, sprite, openActor, choose }); function openActor(id, element) { - if (!selection.actor) returnPoint = { element, actor: id, pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; + 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, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; choose({ actor: id }); } function closeActor() { if (returnPoint) history.back(); else choose({ actor: "" }); } @@ -182,7 +182,8 @@ export function createSpaceRenderer(root, host) { 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 (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + 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(); } } }; @@ -197,10 +198,12 @@ export function createSpaceRenderer(root, host) { 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; for (const element of [header, placeholder, notice, teamRoot, content, empty]) element.inert = Boolean(selection.actor); if (!selection.actor) return; - const back = h("button", { type: "button", class: "btn space-detail-back", text: "Back to Space", onclick: closeActor }); + 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 { @@ -211,18 +214,19 @@ export function createSpaceRenderer(root, host) { 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", 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("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", text: `Open in ${actor.link.tool}`, onclick: () => host.navigate(actor.link.tool, actor.link.panel, actor.link.params) }))); + 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) back.focus({ preventScroll: true }); + 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 element = point.element?.isConnected ? point.element : 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; + 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); (element ?? root.querySelector(`[data-space-section-tab="${selection.view}"]`))?.focus({ preventScroll: true }); const main = root.closest("main"); if (main) main.scrollTop = point.mainTop; scroll.scrollTop = point.sceneTop; scroll.scrollLeft = point.sceneLeft; diff --git a/test/space-team.test.ts b/test/space-team.test.ts index 2189dbb..1a291a3 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -4,13 +4,15 @@ import { runInNewContext } from "node:vm"; function harness() { let writes = 0; - const doc: any = { activeElement: null, addEventListener() {}, removeEventListener() {}, createDocumentFragment: () => new Element("fragment") }; + const listeners = new Mapvoid>(); + const doc: any = { activeElement: 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 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; } @@ -39,7 +41,7 @@ function harness() { 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,get writes(){return writes;},get back(){return back;}}; + return {api,renderer,root,model,doc,nav,listeners,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); @@ -81,3 +83,35 @@ test("internal Space routes keep poll deadlines and do not start a re-poll",asyn 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.root.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"); +}); From d2e3284f5c786b4265e4d4590f4064b1379105f8 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 15:20:06 +0900 Subject: [PATCH 3/9] fix: restore visible Space focus and respect native dialogs --- shell/shell.js | 1 + shell/space-team.js | 12 +++++++- shell/space.js | 11 ++++--- test/space-team.test.ts | 68 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/shell/shell.js b/shell/shell.js index 15b2606..a1b7c36 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -1897,6 +1897,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 index db86e96..cce79de 100644 --- a/shell/space-team.js +++ b/shell/space-team.js @@ -18,6 +18,16 @@ function setText(node, 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; @@ -151,7 +161,7 @@ export function createSpaceTeam(root, host) { 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; - (exact ?? tile ?? root.querySelector("[data-team-focus]") ?? root).focus({ preventScroll: true }); + focusSpaceControl(exact ?? tile ?? root.querySelector("[data-team-focus]"), host.sectionTab()); } } diff --git a/shell/space.js b/shell/space.js index e62681a..b3cbdf6 100644 --- a/shell/space.js +++ b/shell/space.js @@ -1,4 +1,4 @@ -import { createSpaceTeam } from "./space-team.js"; +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. @@ -10,7 +10,7 @@ export function mergeSpaceScenes(entries) { 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) { model.pending += 1; 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); @@ -170,14 +170,15 @@ 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 }); + 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, 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) return; + 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); @@ -227,7 +228,7 @@ export function createSpaceRenderer(root, host) { 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); - (element ?? root.querySelector(`[data-space-section-tab="${selection.view}"]`))?.focus({ preventScroll: true }); + focusSpaceControl(element, sectionButtons.get(selection.view)); const main = root.closest("main"); if (main) main.scrollTop = point.mainTop; scroll.scrollTop = point.sceneTop; scroll.scrollLeft = point.sceneLeft; window.scrollTo(point.pageX, point.pageY); diff --git a/test/space-team.test.ts b/test/space-team.test.ts index 1a291a3..e54913d 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -5,7 +5,7 @@ import { runInNewContext } from "node:vm"; function harness() { let writes = 0; const listeners = new Mapvoid>(); - const doc: any = { activeElement: null, addEventListener(name:string, callback:(e:any)=>void) {listeners.set(name,callback);}, removeEventListener() {}, createDocumentFragment: () => new Element("fragment") }; + 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; @@ -13,6 +13,9 @@ function harness() { 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; } @@ -28,11 +31,19 @@ function harness() { 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() { doc.activeElement=this; } - closest() { return main; } + 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"); @@ -95,7 +106,7 @@ test("changed actor facts retain the exact detail control and missing controls f 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.root.focus();let prevented=false; + 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"); }); @@ -115,3 +126,52 @@ test("resting summary groups idle and away names once per state",()=>{ 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"); +}); From 6d6747972c8154c58eafd4b8892d2ee152df8d3d Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 16:52:55 +0900 Subject: [PATCH 4/9] fix: isolate Space detail, keep section storage separate and static header - Make every Space root child except the detail inert while it is open, so Room scene-style and camera controls cannot take focus behind it. - Fall back to the section tab when a focused Team control disappears. - Store the section choice under acon.space.section; acon.space.view stays a read-only legacy scene-style input. - Let the Space header scroll with the page; the sticky header covered scene controls at 200% text and narrow widths. - ui-check: wait for the hidden detail state and run the raised-cap performance probe in a CSP-bypassing context. --- README.md | 2 +- scripts/ui-check-space-foundation.mjs | 5 +++-- scripts/ui-check-space-team.mjs | 2 +- scripts/ui-check.mjs | 2 +- shell/shell.css | 2 +- shell/space-team.js | 2 +- shell/space.js | 6 +++--- test/space-team.test.ts | 24 +++++++++++++++++++++++- 8 files changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bed237d..cc60564 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Space is an optional area in the rail, shown when a ready plugin declares `capab The area uses `#/local/space`; if a plugin already owns the tool id `space`, the area's link uses `#/local/~space` so that plugin's existing URLs keep working. -The Space header provides a summary strip, view tabs and a group filter. Routes accept `#/local/space?view=team|flow|board|room&group=`; Team is the default, while a saved view choice in `acon.space.view` wins when the route omits `view`. Actor detail uses `&actor=`. Flow and Board remain placeholders for their later deliveries. Summary state counts filter actors (`state=working|waiting|blocked|away|idle`), and the project count resets both filters. +The Space header provides a summary strip, view tabs and a group filter. Routes accept `#/local/space?view=team|flow|board|room&group=`; Team is the default, while a saved view choice in `acon.space.section` wins when the route omits `view`. The header scrolls with the page. Actor detail uses `&actor=`. 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. diff --git a/scripts/ui-check-space-foundation.mjs b/scripts/ui-check-space-foundation.mjs index f20d85f..c142d5b 100644 --- a/scripts/ui-check-space-foundation.mjs +++ b/scripts/ui-check-space-foundation.mjs @@ -24,7 +24,7 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) { await page.locator("[data-space-section-tab='team']").click(); 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.view")), "team"); + assert.equal(await page.evaluate(() => localStorage.getItem("acon.space.section")), "team"); await page.evaluate(() => { location.hash = "#/local/space"; }); await page.reload(); await page.locator(".space-team").waitFor(); @@ -45,7 +45,8 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) { } 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 index 68ce945..62becda 100644 --- a/scripts/ui-check-space-team.mjs +++ b/scripts/ui-check-space-team.mjs @@ -5,7 +5,7 @@ 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.view"); location.hash = "#/local/space"; }, textPercent); + 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(); diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index c5ab59d..e0bef40 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -547,7 +547,7 @@ async function checkSpace(page, scenario, resources, packed) { 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[hidden]").waitFor(); + 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(() => { diff --git a/shell/shell.css b/shell/shell.css index e0a57d8..1c53c9c 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; } diff --git a/shell/space-team.js b/shell/space-team.js index cce79de..c639f3a 100644 --- a/shell/space-team.js +++ b/shell/space-team.js @@ -161,7 +161,7 @@ export function createSpaceTeam(root, host) { 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 ?? root.querySelector("[data-team-focus]"), host.sectionTab()); + focusSpaceControl(exact ?? tile, host.sectionTab()); } } diff --git a/shell/space.js b/shell/space.js index b3cbdf6..5c9f946 100644 --- a/shell/space.js +++ b/shell/space.js @@ -129,7 +129,7 @@ export function createSpaceRenderer(root, host) { let selection = { view: "team", group: "", state: "", actor: "" }; let savedView = "team"; let returnPoint = null, restorePoint = null, detailSignature = ""; - try { const saved = localStorage.getItem("acon.space.view"); if (["team", "flow", "board", "room"].includes(saved)) savedView = saved; } catch { /* Persistence is optional. */ } + 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, actor: "", ...patch }; @@ -151,7 +151,7 @@ export function createSpaceRenderer(root, host) { 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. */ } } } let views = null; @@ -202,7 +202,7 @@ export function createSpaceRenderer(root, host) { const hadFocus = detailRoot.contains(document.activeElement); const focusKey = hadFocus ? document.activeElement?.getAttribute("data-detail-focus") : null; detailRoot.hidden = !selection.actor; - for (const element of [header, placeholder, notice, teamRoot, content, empty]) element.inert = Boolean(selection.actor); + for (const element of root.children) if (element !== detailRoot) element.inert = Boolean(selection.actor); 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) { diff --git a/test/space-team.test.ts b/test/space-team.test.ts index e54913d..df17c40 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -52,7 +52,7 @@ function harness() { 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,get writes(){return writes;},get back(){return back;}}; + 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); @@ -175,3 +175,25 @@ test("a failed first source is settled, so unknown actor and project guidance ap 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); +}); From 0adea69407c6a296613e93d9c79d6c1a82c7c6bc Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 16:53:06 +0900 Subject: [PATCH 5/9] docs: note the Space section key and scrolling header --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 708fd45..b06f006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Added -- Make Team the default Space view while honoring a saved view choice. 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). +- 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). From 338c901c095ad42f333a5a9addff43da6dcf31d5 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 17:20:25 +0900 Subject: [PATCH 6/9] fix: keep Room controls inert and refocus the detail on return - Re-apply inert after the lazy Room controller adds its controls, so a direct room&actor route cannot leave scene-style buttons operable. - When the Space area is entered with an open detail (for example Back from a producer panel), move focus to the detail's Back control so Escape and the Tab trap work again. - README: actors open their detail; cards navigate to their panel. --- README.md | 2 +- scripts/ui-check-space-team.mjs | 15 +++++++++++++++ shell/shell.js | 1 + shell/space.js | 9 +++++++-- test/space-team.test.ts | 8 ++++++++ 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cc60564..515c349 100644 --- a/README.md +++ b/README.md @@ -112,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-team.mjs b/scripts/ui-check-space-team.mjs index 62becda..09428aa 100644 --- a/scripts/ui-check-space-team.mjs +++ b/scripts/ui-check-space-team.mjs @@ -79,6 +79,21 @@ export async function runSpaceTeam({ browser, ticket, report, output }) { await page.evaluate(() => { location.hash = "#/local/space?view=team"; }); await page.locator(".space-agent-detail").waitFor({ state: "hidden" }); + 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"); diff --git a/shell/shell.js b/shell/shell.js index a1b7c36..f39c60d 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -1876,6 +1876,7 @@ async function showSpace(internal = false) { } spaceRenderer.setRoute(state.route.params); renderSpace(!internal); + if (!internal) spaceRenderer.enter?.(); if (!internal) pollSpace(); } function renderSpace(force = false) { diff --git a/shell/space.js b/shell/space.js index 5c9f946..aa8a96b 100644 --- a/shell/space.js +++ b/shell/space.js @@ -189,6 +189,10 @@ export function createSpaceRenderer(root, host) { } }; 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); @@ -202,7 +206,7 @@ export function createSpaceRenderer(root, host) { const hadFocus = detailRoot.contains(document.activeElement); const focusKey = hadFocus ? document.activeElement?.getAttribute("data-detail-focus") : null; detailRoot.hidden = !selection.actor; - for (const element of root.children) if (element !== detailRoot) element.inert = Boolean(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) { @@ -279,6 +283,7 @@ export function createSpaceRenderer(root, host) { 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`); @@ -382,5 +387,5 @@ export function createSpaceRenderer(root, host) { if (lastModel && !root.hidden) update(lastModel, true); }); observer.observe(root); - return { update, setRoute, hide() { views?.suspend(); }, get viewStats() { return views?.stats ?? { view: "flat", renderCount: 0, pendingFrames: 0 }; }, destroy() { observer.disconnect(); document.removeEventListener("keydown", onDetailKey, true); team.destroy(); views?.destroy(); root.replaceChildren(); } }; + return { update, setRoute, enter, hide() { 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/test/space-team.test.ts b/test/space-team.test.ts index df17c40..46d2707 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -197,3 +197,11 @@ test("an open detail makes every other Space child inert, including later siblin 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"); +}); From ae639b5756e1de5932452c1a4afa17512bb462e4 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 17:48:29 +0900 Subject: [PATCH 7/9] fix: restore compact scroll and keep current cards under state filters - Record and restore .column scrollTop, the page scroller at compact widths, when an actor detail closes after a producer round trip. - A state filter keeps each kept actor's current card even when the card has no owner or another owner, so Team keeps the task title. - ui-check: compact producer -> Back -> Escape restores scroll and focus. --- scripts/ui-check-space-team.mjs | 19 +++++++++++++++++++ shell/space.js | 10 +++++++--- test/space-team.test.ts | 9 ++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/ui-check-space-team.mjs b/scripts/ui-check-space-team.mjs index 09428aa..6274a78 100644 --- a/scripts/ui-check-space-team.mjs +++ b/scripts/ui-check-space-team.mjs @@ -79,6 +79,25 @@ export async function runSpaceTeam({ browser, ticket, report, output }) { 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); diff --git a/shell/space.js b/shell/space.js index aa8a96b..6d27b2a 100644 --- a/shell/space.js +++ b/shell/space.js @@ -48,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), @@ -146,7 +148,7 @@ 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 = {}) { - if (selection.actor && !params.actor) { restorePoint = returnPoint ?? { pageX: window.scrollX, pageY: window.scrollY, mainTop: root.closest("main")?.scrollTop ?? 0, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; returnPoint = null; } + 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) { @@ -172,7 +174,7 @@ export function createSpaceRenderer(root, host) { }; 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, sceneTop: scroll.scrollTop, sceneLeft: scroll.scrollLeft }; + 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: "" }); } @@ -234,6 +236,8 @@ export function createSpaceRenderer(root, host) { 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); } diff --git a/test/space-team.test.ts b/test/space-team.test.ts index 46d2707..c5e4040 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -48,7 +48,7 @@ function harness() { 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})`,{document:doc,window:{scrollX:0,scrollY:0,scrollTo(){}},history:{back(){back++;}},localStorage:{getItem:(k:string)=>storage.get(k),setItem:(k:string,v:string)=>storage.set(k,v)},ResizeObserver:class{observe(){}disconnect(){}},CSS:{escape:(v:string)=>v},createSpaceViewController(){throw new Error("Hidden Room initialized");},Date}); + const api=runInNewContext(`${teamSource.replace(/^export /gm,"")}\n${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}]])); @@ -205,3 +205,10 @@ test("entering Space with an open detail moves focus back into the dialog",()=>{ 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"); +}); From 2b5aa3b055e424f66fcd15b1d585fccfbba9fb85 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 18:19:55 +0900 Subject: [PATCH 8/9] fix: mark stale actors in the Team view Team now shows '(last reported, stale)' and a dashed border for actors whose source failed to refresh, matching Room and the actor detail. --- shell/shell.css | 1 + shell/space-team.js | 3 ++- test/space-team.test.ts | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/shell/shell.css b/shell/shell.css index 1c53c9c..40b6879 100644 --- a/shell/shell.css +++ b/shell/shell.css @@ -612,6 +612,7 @@ body.pip { margin: 0; overflow-y: auto; } .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); } diff --git a/shell/space-team.js b/shell/space-team.js index c639f3a..d7836f0 100644 --- a/shell/space-team.js +++ b/shell/space-team.js @@ -45,6 +45,7 @@ export function createSpaceTeam(root, host) { "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" }); @@ -60,7 +61,7 @@ export function createSpaceTeam(root, host) { const state = element("span", { class: "space-team-state", "data-state": info.className }, element("span", { "aria-hidden": "true", text: info.glyph }), - element("span", { text: info.label })); + 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; diff --git a/test/space-team.test.ts b/test/space-team.test.ts index c5e4040..ea953cc 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -212,3 +212,10 @@ test("a state filter keeps an actor's current card even without a matching owner 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)"); +}); From c2a01202ce100a26a6310e10159508aa10ec1f9d Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 18:34:40 +0900 Subject: [PATCH 9/9] fix: move focus to the section tab when a section switch hides it A Team Board/Flow shortcut hid the Team section while focus stayed on the hidden shortcut. Focus now moves to the selected section tab. --- shell/space.js | 3 +++ test/space-team.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/shell/space.js b/shell/space.js index 6d27b2a..aee15c3 100644 --- a/shell/space.js +++ b/shell/space.js @@ -260,6 +260,9 @@ export function createSpaceRenderer(root, host) { root.dataset.spaceSection = selection.view; const room = selection.view === "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, ""); diff --git a/test/space-team.test.ts b/test/space-team.test.ts index ea953cc..f03e8d1 100644 --- a/test/space-team.test.ts +++ b/test/space-team.test.ts @@ -219,3 +219,10 @@ test("Team labels stale actors like Room and detail",()=>{ 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"); +});