diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index c3d0cf675d..d6b9d31312 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -40,7 +40,7 @@ "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", diff --git a/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-desktop.png b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-desktop.png new file mode 100644 index 0000000000..eece9d90d6 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-details.png b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-details.png new file mode 100644 index 0000000000..144b4f20e8 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-details.png differ diff --git a/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-phone.png b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-phone.png new file mode 100644 index 0000000000..bcb7a8927b Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/after-phone.png differ diff --git a/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/before-desktop.png b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/before-desktop.png new file mode 100644 index 0000000000..07b061ecb8 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/before-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/before-details.png b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/before-details.png new file mode 100644 index 0000000000..b94d606c6d Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/goal-workspace-craft/before-details.png differ diff --git a/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs b/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs index aaec8ce379..8abc3d04e5 100644 --- a/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs @@ -66,3 +66,35 @@ for (const copy of Object.values(deliveryReviewCopy)) { console.log("delivery review: identity, scope, relationships, partial coverage, filtering, export and negative contracts passed"); await import("./goal-work-map-smoke.mjs"); await import("./goal-acceptance-contract-smoke.mjs"); + +// A visual route must not run through another task, irrespective of title, +// state, projected depth, duplicate relation or prerequisite direction. +const { goalWorkMapGeometry, goalWorkMapCard, goalWorkMapLayout } = await import("../node_modules/.cache/delivery-review/data/goal-work-map.js"); +const geometryNodes = Array.from({ length: 12 }, (_, index) => ({ + node_id: `node-${index}`, kind: "deliverable", state: "open", depth: index % 4, + title: "A long task with a recorded prerequisite", refs: {}, +})); +const geometryEdges = geometryNodes.flatMap((node, index) => [1, 3, 5].map(offset => ({ + edge_id: `${index}-${offset}`, from_node_id: node.node_id, to_node_id: geometryNodes[(index + offset) % geometryNodes.length].node_id, + relation: "depends_on", enforcement: "typed_condition", reason: "Recorded input", +}))); +for (const available of [320, 700, 1200, 2000]) { + const input = { nodes: geometryNodes, edges: geometryEdges }; + const original = JSON.stringify(input); + const geometry = goalWorkMapGeometry(goalWorkMapLayout(input, "all"), available); + assert.equal(JSON.stringify(input), original, "Layout cannot mutate task authority"); + assert.equal(geometry.positions.size, geometryNodes.length); + assert.equal(geometry.routes.size, geometryEdges.length); + for (const points of geometry.routes.values()) for (let index = 1; index < points.length; index++) { + const a = points[index - 1], b = points[index]; + assert.ok(a.x === b.x || a.y === b.y, "Segments stay in orthogonal routing gutters"); + for (const { x, y } of geometry.positions.values()) { + const crosses = a.x === b.x + ? a.x > x && a.x < x + goalWorkMapCard.width && Math.max(a.y, b.y) > y && Math.min(a.y, b.y) < y + goalWorkMapCard.height + : a.y > y && a.y < y + goalWorkMapCard.height && Math.max(a.x, b.x) > x && Math.min(a.x, b.x) < x + goalWorkMapCard.width; + assert.equal(crosses, false, "A relation cannot cross a card interior"); + } + for (const point of [a, b]) assert.ok(point.x >= 0 && point.y >= 0 && point.x <= geometry.width && point.y <= geometry.height, "Routes remain inside their scrollable canvas"); + } +} +console.log("Work map geometry: skip, reverse, same-column and dense multi-row routes avoid every card; small viewports preserve dimensions"); diff --git a/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs new file mode 100644 index 0000000000..8e758bf090 --- /dev/null +++ b/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs @@ -0,0 +1,103 @@ +// Packaged visual/interaction contract. All task names are invented; source +// request/recovery and state parity are covered by the owning work-map smoke. +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { startServer, port, outputDir } from '../../../../examples/personal-workspace-browser/fixture.mjs'; +import { launchBrowser, loadPlaywright, waitForHttp } from '../../../../examples/dashboard-browser-smoke-support.mjs'; +import { openWorkspacePage } from '../../../../examples/personal-workspace-browser/scenario-context.mjs'; +import { installShowcaseWorkspace, openShowcaseGoal, showcaseGoalId, showcaseGoalTitle } from '../../../../examples/personal-workspace-browser/showcase-workspace.mjs'; + +const longTitle = `${showcaseGoalTitle}: preserve all consumer requirements, reconcile historical exports, qualify replay safety and prove recovery across two independent workers before retiring the old export service`; +const nodes = Array.from({ length: 8 }, (_, index) => ({ + node_id: `craft-${index}`, kind: 'deliverable', state: 'open', depth: index % 4, + title: ['Inventory every downstream export consumer and preserve the complete original requirements', 'Verify replay safety with repeated writes and independent acceptance evidence', 'Reconcile archived totals and investigate every missing or inconsistent record', 'Qualify reconnect, interrupted delivery and result adoption by the original requester'][index % 4], + owner_agent: 'ledger-migrator', task_domain: 'validation', refs: {}, +})); +const edges = nodes.slice(1).map((node, index) => ({ edge_id: `craft-edge-${index}`, from_node_id: node.node_id, to_node_id: nodes[index].node_id, + relation: 'depends_on', enforcement: 'typed_condition', reason: 'Recorded prerequisite' })); +edges.push({ edge_id: 'craft-skip', from_node_id: nodes[3].node_id, to_node_id: nodes[0].node_id, relation: 'continues', enforcement: 'lineage_only', reason: 'Recorded successor' }); +const server = await startServer(); +let browser; +try { + const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + await mkdir(outputDir, { recursive: true }); + browser = await launchBrowser(loadPlaywright().chromium); + const state = { done: 23, cost: 24.8, deliveryFails: false }; + const session = await openWorkspacePage({ newPage: options => browser.newPage({ locale: 'zh-CN', ...options }) }, url, { + viewport: { width: 1440, height: 1000 }, + beforeGoto: async (api, page) => { + await installShowcaseWorkspace(api, page, state); + const transform = api.transformStatus; + api.transformStatus = fixture => { + transform(fixture); + fixture.run_history.goals.find(goal => goal.id === showcaseGoalId).display_name = longTitle; + }; + await page.route('**/api/chat/delivery-review?*', route => route.fulfill({ json: { + ok: true, goal_id: showcaseGoalId, observed_at: '2026-09-01T10:00:00Z', acceptance: null, graph: null, + goal_map: { schema_version: 'goal_task_map_v0', mode: 'read_only', goal_id: showcaseGoalId, + limits: { node_limit: 100, emitted_node_count: nodes.length, omitted_node_count: 0, source_truncated: false, missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: true }, nodes, edges }, + } })); + }, + }); + const { page } = session; + try { + await openShowcaseGoal(page); + const heading = page.locator('.personal-channel-title h1'); + assert.equal(await heading.innerText(), longTitle, 'The original Goal title remains complete'); + assert.doesNotMatch(await page.locator('.goal-masthead-breadcrumb').evaluate(element => [...element.children].filter(child => child.getBoundingClientRect().width > 2).map(child => child.textContent).join(' ')), /preserve all consumer/, 'Visible breadcrumb does not repeat the long request'); + assert.equal(await page.locator('.goal-masthead-breadcrumb [aria-current]').textContent(), longTitle, 'Assistive readers retain the complete current-page identity'); + assert.doesNotMatch(await page.getByTestId('overview-composer-pill').innerText(), /ledger|consumer/, 'The input invitation stays concise'); + const map = page.locator('.work-map'); + await map.scrollIntoViewIfNeeded(); + const scroll = page.locator('.personal-channel-scroll'); + const target = map.locator('.work-map-canvas .work-map-node').last(); + await target.scrollIntoViewIfNeeded(); + const scrollBefore = await scroll.evaluate(element => element.scrollTop); + await target.focus(); + await page.keyboard.press('Enter'); + await map.getByRole('button', { name: '关闭事项详情' }).waitFor(); + assert.ok(Math.abs((await scroll.evaluate(element => element.scrollTop)) - scrollBefore) <= 2, 'Selecting a task preserves the reading position'); + const visible = await target.evaluate(element => { const node = element.getBoundingClientRect(), viewport = element.closest('.work-map-scroll').getBoundingClientRect(); return node.left >= viewport.left && node.right <= viewport.right && node.top >= viewport.top && node.bottom <= viewport.bottom; }); + assert.ok(visible, 'The selected task stays inside the canvas when the inspector opens'); + const card = await target.evaluate(element => { + const title = element.querySelector('strong').getBoundingClientRect(), footer = element.querySelector('small').getBoundingClientRect(); + return { titleBottom: title.bottom, footerTop: footer.top, font: parseFloat(getComputedStyle(element.querySelector('strong')).fontSize), scale: getComputedStyle(element.closest('.work-map-canvas')).transform }; + }); + assert.ok(card.titleBottom <= card.footerTop, 'Task title cannot overlap its metadata'); + assert.ok(card.font >= 14, 'Task text stays at the design minimum'); + assert.equal(card.scale, 'matrix(1, 0, 0, 1, 0, 0)', 'A wide graph scrolls instead of shrinking text'); + await page.screenshot({ path: resolve(outputDir, 'goal-craft-map-desktop.png'), animations: 'disabled' }); + await map.getByRole('button', { name: '关闭事项详情' }).click(); + assert.equal(await map.locator('.work-map-node[aria-pressed="true"]').count(), 0); + assert.ok(await target.evaluate(element => element === document.activeElement), 'Closing details returns focus to the task'); + await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); + assert.equal(await map.locator('.work-map-node[aria-pressed="true"]').count(), 0, 'Escape closes the map selection'); + await page.setViewportSize({ width: 1024, height: 768 }); + const measured = await heading.evaluate(element => ({ height: element.clientHeight, line: parseFloat(getComputedStyle(element).lineHeight), overflow: element.scrollWidth - element.clientWidth })); + assert.ok(measured.height > measured.line && measured.overflow <= 1, 'A long title wraps without clipping or page overflow'); + await page.setViewportSize({ width: 390, height: 844 }); + const headerHeight = await page.locator('.personal-channel-header').evaluate(element => element.getBoundingClientRect().height); + assert.ok(headerHeight <= 220, 'A long Goal title cannot consume the phone viewport'); + assert.equal(await heading.evaluate(element => getComputedStyle(element).overflow), 'hidden', 'Phone title clipping must survive the packaged CSS cascade, not just constrain the box height'); + await page.getByRole('button', { name: '查看完整 Goal' }).click(); + await page.locator('.personal-context-drawer').waitFor({ state: 'visible' }); + assert.match(await page.locator('.personal-context-drawer').innerText(), /prove recovery across two independent workers/, 'The compact title opens the original Goal in its existing details'); + await page.locator('.personal-context-drawer').getByRole('button', { name: /关闭|Close/ }).first().click(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await map.locator('.work-map-list').waitFor({ state: 'visible' }); + assert.equal(await map.locator('.work-map-canvas').isVisible(), false, 'Phone uses a readable task list'); + const mobile = map.locator('.work-map-list .work-map-node').first(); + assert.equal(await mobile.locator('strong').evaluate(element => getComputedStyle(element).maxHeight), 'none', 'Phone titles are not line-clipped'); + await mobile.click(); + await page.waitForFunction(() => document.activeElement?.classList.contains('work-map-inspector')); + assert.ok(await map.locator('.work-map-inspector').evaluate(element => element === document.activeElement), 'Phone selection reaches keyboard-accessible details'); + await page.screenshot({ path: resolve(outputDir, 'goal-craft-map-phone.png'), animations: 'disabled' }); + await map.getByRole('button', { name: '关闭事项详情' }).click(); + assert.ok(await mobile.evaluate(element => element === document.activeElement)); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'The phone has no page-wide horizontal overflow'); + assert.deepEqual(session.errors, [], 'Packaged composition emits no browser errors'); + console.log('Goal workspace craft: packaged long-title, readable geometry, stationary desktop selection, close/Escape/focus, 390px and reduced-motion checks passed'); + } finally { await session.close(); } +} finally { await browser?.close(); server.kill('SIGTERM'); } diff --git a/apps/presentation/dashboard/src/data/goal-work-map.ts b/apps/presentation/dashboard/src/data/goal-work-map.ts index 65027646da..0df9a8c118 100644 --- a/apps/presentation/dashboard/src/data/goal-work-map.ts +++ b/apps/presentation/dashboard/src/data/goal-work-map.ts @@ -121,3 +121,56 @@ export function goalWorkMapSharedOwner(map: GoalWorkMap) { const owners = new Set(map.nodes.flatMap(node => node.owner_agent ? [node.owner_agent] : [])); return owners.size === 1 && map.nodes.filter(node => node.owner_agent).length > 1 ? [...owners][0] : null; } + +/** Presentation geometry only: roomy cards and routes confined to empty gutters. + * Keep one set of dimensions for the canvas and CSS; never shrink text to fit. + */ +export const goalWorkMapCard = { width: 236, height: 136 }; +export function goalWorkMapGeometry(layout: ReturnType, available: number) { + const { width: cardWidth, height: cardHeight } = goalWorkMapCard; + const columnStep = cardWidth + 64, rowStep = cardHeight + 36, padding = 24; + const positions = new Map(); + const routes = new Map(); + const frames: { x: number; y: number; w: number; h: number }[] = []; + let x = 0, y = 0, shelf = 0, right = 0; + for (const group of layout.groups) { + const columnIndex = new Map(group.columns.flatMap((column, index) => column.map(node => [node.node_id, index] as const))); + const pairs = new Map(); + for (const edge of layout.edges) { + if (!columnIndex.has(edge.from_node_id)) continue; + const key = `${edge.from_node_id}\0${edge.to_node_id}`; + pairs.set(key, [...pairs.get(key) ?? [], edge]); + } + const detours = [...pairs.values()].filter(([edge]) => columnIndex.get(edge.from_node_id)! !== columnIndex.get(edge.to_node_id)! + 1); + const headroom = detours.length ? 20 + detours.length * 8 : 0; + const w = group.columns.length * columnStep - 64; + const h = Math.max(...group.columns.map(column => column.length)) * rowStep - 36 + headroom; + if (x > 0 && x + w > available - padding * 2) { y += shelf + 48; x = 0; shelf = 0; } + const left = padding + x, top = padding + y; + group.columns.forEach((column, c) => column.forEach((node, r) => positions.set(node.node_id, { x: left + c * columnStep, y: top + headroom + r * rowStep }))); + let detour = 0; + for (const edges of pairs.values()) { + const edge = edges[0], source = positions.get(edge.to_node_id)!, target = positions.get(edge.from_node_id)!; + const start = { x: source.x + cardWidth, y: source.y + cardHeight / 2 }; + const end = { x: target.x, y: target.y + cardHeight / 2 }; + let points; + if (columnIndex.get(edge.from_node_id)! === columnIndex.get(edge.to_node_id)! + 1) { + const middle = (start.x + end.x) / 2; + points = [start, { x: middle, y: start.y }, { x: middle, y: end.y }, end]; + } else { + // Skip-column, reverse and same-column links travel above the group. + // Their vertical sections stay outside all card columns, including + // other rows in the source and destination columns. + const track = top + 8 + detour++ * 8; + points = [start, { x: start.x + 16, y: start.y }, { x: start.x + 16, y: track }, + { x: end.x - 16, y: track }, { x: end.x - 16, y: end.y }, end]; + } + edges.forEach(item => routes.set(item.edge_id, points)); + } + frames.push({ x: left - 12, y: top - 12, w: w + 24, h: h + 24 }); + right = Math.max(right, x + w); + x += w + 48; + shelf = Math.max(shelf, h); + } + return { positions, routes, frames, width: padding * 2 + right, height: padding * 2 + y + shelf }; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index af389bb1c6..01bbbdbe1f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -15,6 +15,7 @@ export function ChannelHeader({ managerRuntime, mobileNavigationOpen, onOpenGoalCapabilities, + onOpenGoalDetails, onOpenManagerChat, onOpenManagerSettings, onOpenWorkspace, @@ -43,6 +44,7 @@ export function ChannelHeader({ managerRuntime?: ManagerRuntimeSessionReadback | null; mobileNavigationOpen?: boolean; onOpenGoalCapabilities?: () => void; + onOpenGoalDetails?: () => void; onOpenManagerChat?: () => void; onOpenManagerSettings?: () => void; onOpenWorkspace?: () => void; @@ -179,7 +181,7 @@ export function ChannelHeader({
- {selectedGoal ? undefined)} /> :

{t("header.manager")}

} + {selectedGoal ? undefined)} /> :

{t("header.manager")}

} {workspaceGrantLabel ?

{t("workspace.conversation", { grant: workspaceGrantLabel })}

: null}
{stewardBindingVisible && managerChannelBinding ? ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts index 8cbc02599b..4e77581b33 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts @@ -54,7 +54,7 @@ const en = { updating: "Workspace facts changed. Updating this snapshot…", workMap: { title: "Work map", canvas: "Work map canvas", focus: "Map focus", current: "Current work", all: "Everything", - zoom: "Map zoom", zoomOut: "Zoom out", zoomIn: "Zoom in", fit: "Fit", + zoom: "Map zoom", zoomOut: "Zoom out", zoomIn: "Zoom in", fit: "Reset", close: "Close item details", boundary: "Arrows follow recorded task links. The map shows relationships; it does not decide what can run or whether the Goal is accepted.", tasksDone: "tasks done", blocked: "blocked", waiting: "waiting", decisions: "need your decision", watches: "watching", hidden: "finished or deferred, hidden", unlinked: "Not linked to other work", empty: "This Goal has no tasks yet.", @@ -96,7 +96,7 @@ const zh: typeof en = { updating: "工作区状态已变化,正在更新此快照…", workMap: { title: "工作地图", canvas: "工作地图画布", focus: "地图范围", current: "当前工作", all: "全部", - zoom: "地图缩放", zoomOut: "缩小", zoomIn: "放大", fit: "适配", + zoom: "地图缩放", zoomOut: "缩小", zoomIn: "放大", fit: "重置", close: "关闭事项详情", boundary: "箭头来自已记录的任务关联。地图只展示关系,不决定哪些工作可以执行,也不代表 Goal 已通过验收。", tasksDone: "项任务已完成", blocked: "受阻", waiting: "等待", decisions: "需你决策", watches: "持续监控", hidden: "项已完成或延后的工作已隐藏", unlinked: "未与其他工作关联", empty: "此 Goal 还没有任务。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css index 12dca96f77..4e3382fa7a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.css @@ -22,7 +22,7 @@ .delivery-loading i:nth-child(1) { width: 42%; } .delivery-loading i:nth-child(2) { width: 68%; } .delivery-loading i:nth-child(3) { width: 54%; } -.goal-overview .delivery-review { padding-top: 24px; border-top: 1px solid var(--pw-line); } +.goal-overview .delivery-review { gap: 16px; padding-top: 16px; border-top: 1px solid var(--pw-line); } .goal-overview .delivery-review-toolbar h2 { font-size: var(--text-lead); line-height: var(--leading-lead); } .goal-overview .delivery-review-toolbar button { border-color: transparent; background: transparent; color: var(--pw-muted); transition: background-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); } .goal-overview .delivery-review-toolbar button:hover:not(:disabled) { background: var(--pw-hover, var(--pw-card)); color: var(--pw-text); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css index d1bd1ec4b9..d29d070b20 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css @@ -1,6 +1,6 @@ .goal-masthead-breadcrumb { display: flex; align-items: center; gap: 4px; min-width: 0; margin: 0 0 2px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } .goal-masthead-breadcrumb svg { flex: none; color: var(--pw-faint); } -.goal-masthead-breadcrumb button { padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; transition: color var(--motion-fast) var(--ease-standard); } +.goal-masthead-breadcrumb button { flex: none; white-space: nowrap; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; transition: color var(--motion-fast) var(--ease-standard); } .goal-masthead-breadcrumb button:hover { color: var(--pw-text); } .goal-masthead-breadcrumb [aria-current] { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -22,7 +22,7 @@ .goal-vital:hover { background: var(--pw-hover); } .goal-vital-value { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; overflow: hidden; font-size: var(--text-heading); font-variant-numeric: tabular-nums; font-weight: 600; letter-spacing: -0.02em; line-height: var(--leading-heading); text-overflow: ellipsis; white-space: nowrap; } .goal-vital-value small { color: var(--pw-faint); font-size: var(--text-body); font-weight: 500; letter-spacing: 0; } -.goal-vital-meta { display: flex; align-items: baseline; gap: 0; width: 100%; min-width: 0; overflow: hidden; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); white-space: nowrap; } +.goal-vital-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0; width: 100%; min-width: 0; overflow: hidden; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); white-space: normal; } .goal-vital-label { flex: none; font-weight: 500; } .goal-vital-sub { min-width: 0; overflow: hidden; color: var(--pw-faint); text-overflow: ellipsis; } .goal-vital-sub::before { content: "·"; padding: 0 5px; } @@ -83,3 +83,17 @@ .goal-vital-value { font-size: var(--text-lead); line-height: var(--leading-lead); } .personal-channel:has(.personal-channel-scroll:not([data-active-goal-view="overview"])) .goal-vital-sub { display: none; } } + +.personal-channel-header:has(.goal-masthead-breadcrumb) { align-items: flex-start; } +.personal-channel-title .goal-masthead-context { white-space: normal; overflow-wrap: anywhere; } + +.goal-masthead-title { min-width: 0; } +.goal-masthead-title > button { display: none; } +@media (max-width: 720px) { + .goal-masthead-title { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 8px; align-items: start; } + .personal-channel-header[data-goal-selected="true"] .personal-channel-title .goal-masthead-title h1 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-wrap: wrap; } + .goal-masthead-title > button { display: grid; place-items: center; min-height: 32px; width: 28px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--pw-muted); cursor: pointer; } + .goal-masthead-title > button:hover { background: var(--pw-hover); color: var(--pw-text); } + .goal-masthead-title > button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } + .personal-channel-title .goal-masthead-context { white-space: nowrap; } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx index e5d96f276e..7035d86d6a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, ChevronRight, LoaderCircle, Pause, RotateCcw } from "lucide-react"; +import { AlertCircle, ChevronRight, Info, LoaderCircle, Pause, RotateCcw } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { compileGoalWorkspaceDigest, @@ -35,7 +35,7 @@ export function useGoalWorkspaceDigest(goal: WorkspaceGoal, userTodos: Workspace }), [goal, userTodos, items]); } -export function GoalMastheadIdentity({ goal, onOpenWorkspace }: { goal: WorkspaceGoal; onOpenWorkspace: () => void }) { +export function GoalMastheadIdentity({ goal, onOpenWorkspace, onOpenDetails }: { goal: WorkspaceGoal; onOpenWorkspace: () => void; onOpenDetails?: () => void }) { const { t } = useWorkspaceI18n(); const agents = (goal.agentLanes ?? []).map((lane) => lane.label); const context = [ @@ -48,10 +48,11 @@ export function GoalMastheadIdentity({ goal, onOpenWorkspace }: { goal: Workspac {t("masthead.goals")} - - {goal.title} + {/* Announce the current page to assistive readers; show its long identity once in the heading. */} + {goal.title} + -

{goal.title}

+

{goal.title}

{onOpenDetails ? : null}
{context.length ?

{context.join(" · ")}

: null} ); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css index 219e100b15..1ac1e5bb5a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css @@ -4,7 +4,7 @@ .personal-goal-view-panel[data-goal-panel="tasks"] { height: 100%; } /* A document, not a card grid: the Direction is the one hero; the rest recedes. */ -.goal-overview { display: grid; gap: 32px; min-width: 0; padding-top: 8px; } +.goal-overview { display: grid; gap: 24px; min-width: 0; padding-top: 8px; } .goal-overview h2, .goal-overview h3, .goal-overview p { margin: 0; } .goal-overview button { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; cursor: pointer; text-align: left; transition: background-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); } .goal-overview button:hover { border-color: var(--pw-line-strong); background: var(--pw-hover); } @@ -42,10 +42,9 @@ .goal-overview-criteria .is-verified > span { color: var(--pw-muted); } .goal-overview-criteria .is-failing svg, .goal-overview-criteria .is-failing small { color: var(--pw-red); } -.goal-overview-summary .goal-overview-run { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; margin-top: 4px; padding: 12px 14px; } +.goal-overview-summary .goal-overview-run { display: inline-flex; justify-self: start; gap: 8px; margin: 0; padding: 0; min-height: 28px; border: 0; background: transparent; } .goal-overview-run small { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } -.goal-overview-run strong { grid-row: 2; font-size: var(--text-body); font-weight: 500; line-height: var(--leading-body); } -.goal-overview-run svg { grid-column: 2; grid-row: 1 / 3; color: var(--pw-faint); transition: transform var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); } +.goal-overview-run svg { grid-column: 2; grid-row: 1; color: var(--pw-faint); transition: transform var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out); } .goal-overview-run:hover svg { color: var(--pw-text); transform: translateX(2px); } .goal-overview-attention { padding-left: 24px; border-left: 1px solid var(--pw-line); } @@ -101,3 +100,9 @@ .goal-overview { gap: 24px; } .goal-overview-objective { font-size: var(--text-lead); line-height: var(--leading-lead); } } + +.goal-overview-agent-note { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.goal-overview-agent-note summary { width: fit-content; cursor: pointer; } +.goal-overview-agent-note p { margin-top: 8px; } + +.goal-overview-current { font-size: var(--text-lead); line-height: var(--leading-lead); font-weight: 500; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx index fbe7ea574b..bfca736a00 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -28,13 +28,13 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD }) { const { t, locale } = useWorkspaceI18n(); const copy = locale === "zh-CN" ? { - direction: "方向", agentSummary: "Agent 的进展摘要", attention: "需要你", details: "Goal 信息", + direction: "方向", current: "当前推进", agentSummary: "Agent 的进展摘要", attention: "需要你", details: "Goal 信息", tasks: "查看任务", execution: "执行记录", optional: "{count} 项可选", noTarget: "尚未声明可衡量目标。目标通过 Goal 的验收配置声明。", verified: "{passed}/{total} 项条件已核验", criterion: { verified: "已核验", failing: "未通过", unverified: "尚未核验" }, remote: "此来源仅提供同步的状态与验收观察,交付链需要实时本机来源。", } : { - direction: "Direction", agentSummary: "Agent's progress summary", attention: "Needs you", details: "Goal information", + direction: "Direction", current: "Current work", agentSummary: "Agent's progress summary", attention: "Needs you", details: "Goal information", tasks: "View tasks", execution: "Execution", optional: "{count} optional", noTarget: "No measurable target declared. Targets are declared through the Goal's acceptance configuration.", verified: "{passed} of {total} criteria verified", criterion: { verified: "Verified", failing: "Failing", unverified: "Not yet verified" }, @@ -57,7 +57,7 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD
-

{copy.direction}

+

{contract ? copy.direction : copy.current}

{contract ? {acceptanceCopy.verificationState[contract.status]} : null}
@@ -75,11 +75,11 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD : null}

{copy.agentSummary} {goal.nextSentence}

: <> -

{goal.nextSentence}

- {goal.agentSentence && goal.agentSentence !== goal.nextSentence ?

{goal.agentSentence}

: null} -

{copy.agentSummary} {copy.noTarget}

+

{goal.nextSentence}

+ {goal.agentSentence && goal.agentSentence !== goal.nextSentence ?
{copy.agentSummary}

{goal.agentSentence}

: null} +

{copy.noTarget}

} - {run ? : null} + {run ? : null}
{showAttention ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css index 83de59ae81..25f5d2ee52 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.css @@ -21,33 +21,33 @@ .work-map-notice dl > div { display: flex; gap: 6px; } .work-map-notice dt { color: var(--pw-muted); } .work-map-notice dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; } -.work-map-scroll { position: relative; overflow: auto; max-height: max(420px, min(720px, 72vh)); overscroll-behavior: contain; border-block: 1px solid var(--pw-line); - background-color: var(--pw-bg); background-image: radial-gradient(circle, var(--pw-line) 1px, transparent 1.2px); background-size: 20px 20px; cursor: grab; } +.work-map-scroll { position: relative; overflow: auto; max-height: max(420px, min(720px, 72vh)); overscroll-behavior: contain; border-top: 1px solid var(--pw-line); + background-color: var(--pw-bg); cursor: grab; } .work-map-scroll[hidden] { display: none; } .work-map-scroll:active { cursor: grabbing; } .work-map-stage { position: relative; } .work-map-canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; } .work-map-canvas > svg { position: absolute; inset: 0; overflow: visible; pointer-events: none; } -.work-map-canvas > svg path { fill: none; stroke: var(--pw-line-strong, var(--pw-muted)); stroke-width: 1.5; transition: stroke .16s ease, opacity .16s ease; } +.work-map-canvas > svg path { fill: none; stroke: var(--pw-faint); stroke-width: 1.5; transition: stroke .16s ease, opacity .16s ease; } .work-map svg path[data-relation="continues"] { stroke-dasharray: 2 4; stroke-linecap: round; } .work-map svg path[data-relation="supersedes"] { stroke-dasharray: 8 4; } .work-map-canvas > svg path[data-related] { stroke: var(--pw-text); stroke-width: 2; } .work-map-canvas > svg path[data-dimmed] { opacity: .25; } .work-map-canvas > svg rect.work-map-group { fill: var(--pw-card); fill-opacity: .55; stroke: var(--pw-line); stroke-width: 1; } -.work-map-arrow path { fill: var(--pw-line-strong, var(--pw-muted)); stroke: none; } +.work-map-arrow path { fill: var(--pw-faint); stroke: none; } .work-map-arrow.is-hot path { fill: var(--pw-text); } -.work-map .work-map-node { position: absolute; margin: 0; min-height: 0; color: var(--pw-text); align-items: stretch; justify-content: stretch; width: 176px; height: 88px; display: grid; grid-template-rows: auto 1fr auto; gap: 4px; padding: 8px 10px; text-align: left; +.work-map .work-map-node { position: absolute; margin: 0; min-height: 0; color: var(--pw-text); align-items: stretch; justify-content: stretch; width: var(--work-map-card-width); height: var(--work-map-card-height); display: grid; grid-template-rows: auto 1fr auto; gap: 8px; padding: 12px; text-align: left; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-card); box-shadow: 0 1px 2px rgb(0 0 0 / .04); transition: border-color .16s ease, opacity .16s ease, box-shadow .16s ease, transform .16s ease; } .work-map .work-map-node:hover { border-color: var(--pw-line-strong, var(--pw-muted)); box-shadow: 0 4px 12px rgb(0 0 0 / .06); } -.work-map .work-map-node[aria-pressed="true"] { border-color: var(--pw-text); box-shadow: 0 0 0 1px var(--pw-text), 0 6px 16px rgb(0 0 0 / .08); } -.work-map .work-map-node[data-dimmed] { opacity: .38; } +.work-map .work-map-node[aria-pressed="true"] { border-color: var(--pw-text); box-shadow: 0 0 0 1px var(--pw-text); } +.work-map .work-map-node[data-dimmed] { box-shadow: none; } .work-map-node-meta svg { flex: none; } .work-map-node-meta { display: flex; flex: none; align-items: center; gap: 4px; font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); min-width: 0; } .work-map-node-meta em { margin-left: auto; font-style: normal; font-weight: 500; white-space: nowrap; } -.work-map .work-map-node > strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; max-height: 32px; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 500; overflow-wrap: anywhere; } +.work-map .work-map-node > strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; max-height: calc(3 * var(--leading-body)); font-size: var(--text-body); line-height: var(--leading-body); font-weight: 500; overflow-wrap: anywhere; } .work-map .work-map-node[data-bare] { grid-template-rows: auto 1fr; } -.work-map .work-map-node[data-bare] > strong { -webkit-line-clamp: 3; max-height: 48px; } +.work-map .work-map-node[data-bare] > strong { -webkit-line-clamp: 4; max-height: calc(4 * var(--leading-body)); } .work-map .work-map-node > small { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); white-space: nowrap; overflow: hidden; } .work-map .work-map-node > small i { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; border-radius: 50%; background: var(--pw-bg); border: 1px solid var(--pw-line); font-style: normal; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 600; color: var(--pw-text); } .work-map .work-map-node > small span { overflow: hidden; text-overflow: ellipsis; } @@ -93,6 +93,7 @@ .work-map-list { list-style: none; margin: 0; padding: 12px 16px; display: grid; gap: 10px; border-top: 1px solid var(--pw-line); } .work-map-list li { display: grid; gap: 4px; } .work-map .work-map-list .work-map-node { position: static; width: 100%; height: auto; min-height: 72px; } +.work-map .work-map-list .work-map-node > strong { display: block; max-height: none; } .work-map-list p { font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); padding-left: 12px; border-left: 2px solid var(--pw-line); overflow-wrap: anywhere; } .work-map-list p span { color: var(--pw-text); font-weight: 500; } @media (max-width: 720px) { @@ -103,3 +104,16 @@ @media (prefers-reduced-motion: reduce) { .work-map .work-map-node, .work-map-canvas > svg path { transition: none; } } + +.work-map-body { min-width: 0; } +.work-map-inspector header { position: relative; padding-right: 32px; } +.work-map .work-map-close { position: absolute; top: -4px; right: -4px; display: grid; place-items: center; width: 32px; height: 32px; min-height: 32px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--pw-muted); } +.work-map .work-map-close:hover { background: var(--pw-hover); color: var(--pw-text); } +@media (min-width: 1200px) { + .work-map-body[data-selected] { display: grid; grid-template-columns: minmax(0, 1fr) 300px; align-items: start; } + .work-map-body[data-selected] .work-map-inspector { max-height: 440px; overflow: auto; border-left: 1px solid var(--pw-line); } + .work-map-body[data-selected] .work-map-relations { grid-template-columns: 1fr; gap: 20px; } + .work-map-body[data-selected] .work-map-relations li button { grid-template-columns: minmax(0, 1fr) auto; } + .work-map-body[data-selected] .work-map-relations li button em { grid-column: 1 / -1; } + .work-map-body[data-selected] .work-map-scroll { min-height: 280px; max-height: 440px; } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx index 5e22301c47..a2d75b0847 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-work-map.tsx @@ -1,12 +1,12 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; -import { Circle, CircleCheck, CircleDashed, ExternalLink, Eye, Hand, OctagonAlert, ZoomIn, ZoomOut } from "lucide-react"; -import { goalWorkMapCoverage, goalWorkMapLayout, goalWorkMapLineage, goalWorkMapSharedOwner, goalWorkMapSummary, goalWorkMapTone, +import { Circle, CircleCheck, CircleDashed, ExternalLink, Eye, Hand, OctagonAlert, X, ZoomIn, ZoomOut } from "lucide-react"; +import { goalWorkMapCard, goalWorkMapGeometry, goalWorkMapCoverage, goalWorkMapLayout, goalWorkMapLineage, goalWorkMapSharedOwner, goalWorkMapSummary, goalWorkMapTone, type GoalWorkMap, type GoalWorkMapEdge, type GoalWorkMapFocus, type GoalWorkMapNode, type GoalWorkMapTone } from "../../data/goal-work-map"; import type { deliveryReviewCopy } from "./delivery-review-copy"; import "./goal-work-map.css"; type Copy = (typeof deliveryReviewCopy)["en"]["workMap"]; -const CARD_W = 176, CARD_H = 88, COLUMN = 212, ROW = 102, PAD = 20, GAP_X = 44, GAP_Y = 36, UNLINKED_PREVIEW = 8; +const UNLINKED_PREVIEW = 8; const relationRank: Record = { depends_on: 0, supersedes: 1, continues: 2 }; const toneIcon: Record = { decision: Hand, blocked: OctagonAlert, open: Circle, waiting: CircleDashed, done: CircleCheck, unknown: Circle, @@ -37,6 +37,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp const [scale, setScale] = useState(null); const scrollRef = useRef(null); const inspectorRef = useRef(null); + const rootRef = useRef(null); const [available, setAvailable] = useState(0); const [compact, setCompact] = useState(() => window.matchMedia("(max-width: 720px)").matches); useEffect(() => { @@ -52,28 +53,13 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp const unlinked = allUnlinked ? layout.unlinked : layout.unlinked.slice(0, UNLINKED_PREVIEW); const nodeById = new Map(map.nodes.map(node => [node.node_id, node])); const placedNodes = layout.groups.flatMap(group => group.columns.flat()); - // Shelf-pack chains left to right within the visible width, most urgent first. - const { position, frames, width, height } = useMemo(() => { - const limit = available ? available - PAD * 2 : 1040; - const position = new Map(); - const frames: { x: number; y: number; w: number; h: number }[] = []; - let x = 0, y = 0, shelf = 0, right = 0; - for (const group of layout.groups) { - const w = group.columns.length * COLUMN - (COLUMN - CARD_W); - const h = Math.max(...group.columns.map(column => column.length)) * ROW - (ROW - CARD_H); - if (x > 0 && x + w > limit) { y += shelf + GAP_Y; x = 0; shelf = 0; } - group.columns.forEach((column, c) => column.forEach((node, r) => position.set(node.node_id, { x: PAD + x + c * COLUMN, y: PAD + y + r * ROW }))); - frames.push({ x: PAD + x - 10, y: PAD + y - 10, w: w + 20, h: h + 20 }); - right = Math.max(right, x + w); - x += w + GAP_X; - shelf = Math.max(shelf, h); - } - return { position, frames, width: PAD * 2 + right, height: PAD * 2 + y + shelf }; - }, [layout, available]); - const fit = available ? Math.min(1, available / width) : 1; - // Fit when the whole map stays legible; otherwise keep full size and scroll. - const zoom = scale ?? (fit >= 0.86 ? fit : 1); + const { positions: position, routes, frames, width, height } = useMemo(() => goalWorkMapGeometry(layout, available || 1040), [layout, available]); + const zoom = scale ?? 1; const selected = selectedId ? nodeById.get(selectedId) ?? null : null; + const closeSelection = () => { + rootRef.current?.querySelector('.work-map-node[aria-pressed="true"]')?.focus(); + onSelect(null); + }; const lineage = goalWorkMapLineage(layout.edges, selected && position.has(selected.node_id) ? selected.node_id : null); const strokes = useMemo(() => { const pairs = new Map(); @@ -88,10 +74,22 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp setAvailable(element.clientWidth); return () => observer.disconnect(); }, []); + useLayoutEffect(() => { + const viewport = scrollRef.current, point = selectedId ? position.get(selectedId) : null; + if (!viewport || !point || compact) return; + // The inspector changes the canvas width. Keep the selected card in this + // viewport without scrolling the surrounding Goal document. + const left = point.x * zoom, right = (point.x + goalWorkMapCard.width) * zoom; + if (left < viewport.scrollLeft) viewport.scrollLeft = Math.max(0, left - 12); + else if (right > viewport.scrollLeft + viewport.clientWidth) viewport.scrollLeft = right - viewport.clientWidth + 12; + const top = point.y * zoom, bottom = (point.y + goalWorkMapCard.height) * zoom; + if (top < viewport.scrollTop) viewport.scrollTop = Math.max(0, top - 12); + else if (bottom > viewport.scrollTop + viewport.clientHeight) viewport.scrollTop = bottom - viewport.clientHeight + 12; + }, [selectedId, position, zoom, compact]); useEffect(() => setScale(null), [focus]); useEffect(() => { - if (selectedId) window.requestAnimationFrame(() => inspectorRef.current?.scrollIntoView({ block: "nearest" })); - }, [selectedId]); + if (compact && selectedId) window.requestAnimationFrame(() => { inspectorRef.current?.scrollIntoView({ block: "nearest" }); inspectorRef.current?.focus({ preventScroll: true }); }); + }, [selectedId, compact]); const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null); const startPan = (event: ReactPointerEvent) => { const element = scrollRef.current; @@ -113,7 +111,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp ; })} :

{empty}

; const limits = map.limits; - return
+ return
{ if (event.key === "Escape" && selected) { event.stopPropagation(); closeSelection(); } }} style={{ "--work-map-card-width": `${goalWorkMapCard.width}px`, "--work-map-card-height": `${goalWorkMapCard.height}px` } as CSSProperties}>

{copy.title}

@@ -131,8 +129,8 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp {(["current", "all"] as const).map(value => )}
@@ -144,6 +142,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
{copy.cycles}
{limits.cycle_edge_count}
: null} {!map.nodes.length ?

{copy.empty}

: <> +
{compact ?
    {placedNodes.map(node => { const needs = layout.edges.filter(edge => edge.from_node_id === node.node_id).map(edge => nodeById.get(edge.to_node_id)!.title); return
  1. @@ -158,11 +157,8 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp {["base", "hot"].map(kind => )} {strokes.map(group => { const edge = group[0]; - const from = position.get(edge.to_node_id)!, to = position.get(edge.from_node_id)!; - const sx = from.x + CARD_W, sy = from.y + CARD_H / 2, ex = to.x, ey = to.y + CARD_H / 2; - const bend = Math.max(28, (ex - sx) / 2); - const d = ex > sx ? `M ${sx} ${sy} C ${sx + bend} ${sy}, ${ex - bend} ${ey}, ${ex} ${ey}` - : `M ${from.x + CARD_W / 2} ${from.y + CARD_H} C ${from.x + CARD_W / 2} ${from.y + CARD_H + 48}, ${to.x + CARD_W / 2} ${to.y + CARD_H + 48}, ${to.x + CARD_W / 2} ${to.y + CARD_H}`; + const points = routes.get(edge.edge_id)!; + const d = points.map((point, index) => `${index ? "L" : "M"} ${point.x} ${point.y}`).join(" "); const related = group.some(item => lineage.edges.has(item.edge_id)); return ; @@ -173,9 +169,10 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp
:

{copy.noLinks}

}
-
+
{!selected ?

{copy.select}

: <>
+ {copy.kind[selected.kind]} · {copy.tone[goalWorkMapTone(selected)]}

{selected.title}

{selected.owner_agent || selected.task_domain ?

{[selected.owner_agent, selected.task_domain].filter(Boolean).join(" · ")}

: null} @@ -187,6 +184,7 @@ export function GoalWorkMapView({ map, copy, selectedId, onSelect, onOpen, canOp {canOpen(selected) ? :

{copy.noSource}

} }
+