From 2650aff4d7139cc1da264a0ee370eab889a5018b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:20:07 +0800 Subject: [PATCH 1/7] fix(frontend): make long Goal workspaces readable and navigable Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../dashboard/src/data/goal-work-map.ts | 53 +++++++++++++++ .../personal-workspace/channel-header.tsx | 4 +- .../delivery-review-copy.ts | 4 +- .../personal-workspace/delivery-review.css | 2 +- .../personal-workspace/goal-masthead.css | 18 ++++- .../personal-workspace/goal-masthead.tsx | 11 +-- .../personal-workspace/goal-overview.css | 13 ++-- .../personal-workspace/goal-overview.tsx | 14 ++-- .../personal-workspace/goal-work-map.css | 32 ++++++--- .../personal-workspace/goal-work-map.tsx | 68 +++++++++---------- .../src/features/personal-workspace/i18n.tsx | 4 ++ .../personal-workspace-page.tsx | 3 +- .../personal-workspace/personal-workspace.css | 5 +- 13 files changed, 163 insertions(+), 68 deletions(-) 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}

} }
+