nebulosity 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +11 -11
  3. package/.next/diagnostics/route-bundle-stats.json +10 -10
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +5 -5
  6. package/.next/server/app/_global-error/page/build-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page/build-manifest.json +3 -3
  14. package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
  15. package/.next/server/app/_not-found/page.js +3 -2
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +27 -20
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +29 -23
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
  25. package/.next/server/app/index.segments/_full.segment.rsc +29 -23
  26. package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
  27. package/.next/server/app/page/build-manifest.json +3 -3
  28. package/.next/server/app/page/next-font-manifest.json +2 -3
  29. package/.next/server/app/page.js +4 -4
  30. package/.next/server/app/page_client-reference-manifest.js +1 -1
  31. package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
  32. package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
  33. package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
  34. package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
  35. package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
  36. package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
  37. package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
  38. package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
  39. package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
  40. package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
  41. package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
  42. package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
  43. package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
  44. package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
  45. package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
  46. package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
  47. package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
  48. package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
  49. package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
  50. package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
  51. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
  52. package/.next/server/middleware-build-manifest.js +11 -11
  53. package/.next/server/next-font-manifest.js +1 -1
  54. package/.next/server/next-font-manifest.json +4 -6
  55. package/.next/server/pages/404.html +1 -1
  56. package/.next/server/pages/500.html +1 -1
  57. package/.next/server/preview-props.json +3 -3
  58. package/.next/server/server-reference-manifest.js +1 -1
  59. package/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
  61. package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
  62. package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
  63. package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
  64. package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
  65. package/.next/static/chunks/3w00e1w2125pp.js +11 -0
  66. package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
  67. package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
  68. package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
  69. package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
  70. package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
  71. package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
  72. package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
  73. package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
  74. package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
  75. package/.next/trace +1 -1
  76. package/.next/trace-build +1 -1
  77. package/README.md +13 -4
  78. package/app/globals.css +91 -54
  79. package/app/layout.tsx +20 -11
  80. package/app/map/BackButton.tsx +5 -5
  81. package/app/map/BoxActions.tsx +20 -12
  82. package/app/map/Breadcrumb.tsx +132 -46
  83. package/app/map/DetailPanel.tsx +408 -297
  84. package/app/map/Explorer.tsx +268 -0
  85. package/app/map/Header.tsx +105 -0
  86. package/app/map/HelpDialog.tsx +37 -34
  87. package/app/map/Legend.tsx +19 -75
  88. package/app/map/LineCard.tsx +187 -0
  89. package/app/map/MapBox.tsx +73 -38
  90. package/app/map/MapCanvas.tsx +344 -115
  91. package/app/map/MapToolbar.tsx +377 -0
  92. package/app/map/MapView.tsx +217 -213
  93. package/app/map/RepoMenu.tsx +13 -12
  94. package/app/map/RoutedEdge.tsx +48 -16
  95. package/app/map/SearchPalette.tsx +6 -6
  96. package/app/map/SourceViewer.tsx +19 -17
  97. package/app/map/StatusBar.tsx +166 -0
  98. package/app/map/embed.ts +22 -2
  99. package/app/map/emphasis.ts +94 -8
  100. package/app/map/explorerRows.ts +85 -0
  101. package/app/map/flowNodes.ts +12 -7
  102. package/app/map/format.ts +2 -2
  103. package/app/map/history.ts +4 -3
  104. package/app/map/inspector.ts +113 -0
  105. package/app/map/labels.ts +66 -7
  106. package/app/map/narrow.ts +165 -85
  107. package/app/map/opening.ts +16 -0
  108. package/app/map/portals.ts +2 -2
  109. package/app/map/routes.ts +1060 -55
  110. package/app/map/runs.ts +108 -0
  111. package/app/map/shell.ts +91 -0
  112. package/app/map/shortcuts.ts +2 -1
  113. package/app/map/summaries.ts +41 -5
  114. package/app/map/text.ts +9 -1
  115. package/app/map/theme.ts +197 -0
  116. package/app/map/useDismiss.ts +20 -6
  117. package/app/map/useEditorBridge.ts +12 -1
  118. package/app/map/useThemeKind.ts +28 -0
  119. package/app/map/useWindowSize.ts +37 -0
  120. package/app/map/viewport.ts +134 -16
  121. package/app/map/weights.ts +80 -0
  122. package/app/page.tsx +3 -5
  123. package/lib/background/child.ts +2 -0
  124. package/lib/background/service.ts +26 -10
  125. package/lib/contracts.ts +25 -2
  126. package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
  127. package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
  128. package/lib/db/migrations/meta/_journal.json +7 -0
  129. package/lib/db/schema.ts +2 -1
  130. package/lib/db/store/map.ts +6 -0
  131. package/lib/db/store/summaries.ts +14 -8
  132. package/lib/engine/describe.ts +2 -0
  133. package/lib/index/summarize/budget.ts +5 -2
  134. package/lib/index/summarize/claude.ts +19 -4
  135. package/lib/index/summarize/compare.ts +2 -2
  136. package/lib/index/summarize/plan.ts +73 -13
  137. package/lib/index/summarize/preview.ts +12 -9
  138. package/lib/index/summarize/prompts.ts +122 -25
  139. package/lib/index/summarize/run.ts +48 -18
  140. package/lib/index/summarize/snapshot.ts +7 -2
  141. package/lib/index/summarize/status.ts +3 -2
  142. package/lib/tools/text.ts +3 -2
  143. package/mcp/server.ts +1 -1
  144. package/package.json +4 -2
  145. package/scripts/commands/summarize-command.ts +27 -9
  146. package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
  147. package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
  148. package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
  149. package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
  150. package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
  151. package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
  152. package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
  153. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
  154. package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
  155. package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
  156. package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
  157. package/.next/static/chunks/10u7p2gj02qay.js +0 -1
  158. package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
  159. package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
  160. package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
  161. package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
  162. package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
  163. package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
  164. package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
  165. package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
  166. package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
  167. package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
  168. package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
  169. package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
  170. package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
  171. package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
  172. package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
  173. package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
  174. package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
  175. package/app/map/ActivityBar.tsx +0 -81
  176. package/app/map/StatusPill.tsx +0 -93
  177. package/app/map/TopBar.tsx +0 -84
  178. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
  179. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
  180. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
@@ -12,15 +12,16 @@ const LIST_WIDTH = 320;
12
12
 
13
13
  type RepoMenuProps = { repo: string; onSwitched: () => void; /** Go to the repo's top level. */ onHome: () => void };
14
14
 
15
- /** A repo as the top bar names it: "owner / repo" from its origin, or its folder name (VISUAL_REFRESH.md §6). */
15
+ /** A repo as the breadcrumb names it, in mono: "owner/repo" from its origin, or its folder name (ADR-070). */
16
16
  function RepoName({ entry, fallback }: { entry: RepoSummary | undefined; fallback: string }){
17
- if (!entry?.remote) return <span title={fallback} className="truncate text-[15px] font-medium text-text">{fallback}</span>;
18
- // on a narrow screen the owner (and its "/") gives way first, down to nothing, so the repo's own name keeps its room;
19
- // the name truncates only once it alone is too wide. The whole name is the hover title.
17
+ if (!entry?.remote) return <span title={fallback} className="truncate text-text">{fallback}</span>;
18
+ // in a narrow window the owner gives way first, whole (the breadcrumb says when, by data-owner, and narrows it to
19
+ // nothing so it can still be measured; under 360 px it's gone), so the repo's own name keeps its room; the name
20
+ // truncates only once it alone is too wide. The whole name is the hover title.
20
21
  return (
21
- <span title={`${entry.remote.owner} / ${entry.remote.name}`} className="flex min-w-0 items-baseline text-[15px]">
22
- <span className="truncate pr-1 text-text-2 after:pl-1 after:content-['/']">{entry.remote.owner}</span>
23
- <span className="max-w-full shrink-0 truncate font-medium text-text">{entry.remote.name}</span>
22
+ <span title={`${entry.remote.owner}/${entry.remote.name}`} className="flex min-w-0 items-baseline text-text">
23
+ <span data-repo-owner className="truncate after:content-['/'] group-data-[owner=hidden]/crumb:max-w-0 max-[360px]:hidden">{entry.remote.owner}</span>
24
+ <span className="max-w-full shrink-0 truncate">{entry.remote.name}</span>
24
25
  </span>
25
26
  );
26
27
  }
@@ -49,7 +50,7 @@ export default function RepoMenu({ repo, onSwitched, onHome }: RepoMenuProps){
49
50
  const name = repo.slice(repo.lastIndexOf("/") + 1);
50
51
  const label = <RepoName entry={list?.repos.find((r) => r.root === repo)} fallback={name} />;
51
52
  const home = (
52
- <button type="button" data-testid="repo-name" onClick={onHome} title="Go to the top level" className="flex min-w-0 rounded-md px-1 hover:bg-surface">
53
+ <button type="button" data-testid="repo-name" onClick={onHome} title="Go to the top level" className="flex min-w-0 rounded-[4px] px-0.5 hover:bg-node">
53
54
  {label}
54
55
  </button>
55
56
  );
@@ -74,18 +75,18 @@ export default function RepoMenu({ repo, onSwitched, onHome }: RepoMenuProps){
74
75
  }}
75
76
  aria-label="Switch repository"
76
77
  title="Switch repository"
77
- className="grid h-7 w-6 shrink-0 place-items-center rounded-md text-text-2 hover:bg-surface hover:text-text"
78
+ className="grid h-6 w-5 shrink-0 place-items-center rounded-[4px] text-text-3 hover:bg-node hover:text-text"
78
79
  >
79
80
  <IconChevronDown aria-hidden size={14} stroke={1.75} />
80
81
  </button>
81
82
  </div>
82
83
  {open && (
83
- <ul data-testid="repo-list" data-covers style={listPlace} className="absolute top-9 z-30 overflow-y-auto rounded-lg border border-border bg-surface py-1 text-[13px]">
84
+ <ul data-testid="repo-list" data-covers style={listPlace} className="absolute top-8 z-30 overflow-y-auto rounded-md border border-border bg-panel py-1 font-sans text-[13px]">
84
85
  {list.repos.map((r) => (
85
86
  <li key={r.root}>
86
- <button type="button" onClick={() => pick(r.root)} className="block w-full px-3 py-2 text-left hover:bg-border">
87
+ <button type="button" onClick={() => pick(r.root)} className="block w-full px-3 py-2 text-left hover:bg-node">
87
88
  <span className={r.root === repo ? "font-medium text-text" : "text-text"}>{r.remote ? `${r.remote.owner} / ${r.remote.name}` : r.name}</span>
88
- <span title={r.root} className="block truncate font-mono text-[12px] text-text-2">{r.root}</span>
89
+ <span title={r.root} className="block truncate font-mono text-[12px] text-text-3">{r.root}</span>
89
90
  </button>
90
91
  </li>
91
92
  ))}
@@ -2,33 +2,65 @@
2
2
 
3
3
  import { memo } from "react";
4
4
  import { BaseEdge, EdgeLabelRenderer, type Edge, type EdgeProps } from "@xyflow/react";
5
- import { midpointAlong, roundedPath, type Point } from "./routes";
5
+ import { roundedPath, type Point } from "./routes";
6
+ import { arrowHead } from "./weights";
6
7
 
7
- /** A line that follows its route (routes.ts) instead of React Flow's straight curve. */
8
- export type RoutedEdgeType = Edge<{ points: Point[] }, "routed">;
8
+ /**
9
+ * A line that follows its route (routes.ts) instead of React Flow's straight curve. `width` goes by how many imports it
10
+ * carries (weights.ts), `color` by its tone, and `count` is the number written on it at `labelAt`, when it's one of the
11
+ * lines that carry theirs (weights.ts's labelledLines) and its count has a place clear of everything else (routes.ts's
12
+ * placeCounts). `headWidth` is the width its box's one arrowhead is sized for (weights.ts's boxHeadWidths), shared by
13
+ * every line into that box, else its own.
14
+ */
15
+ export type RoutedEdgeData = { points: Point[]; width: number; color: string; count?: string; countStrong?: boolean; headWidth?: number; labelAt?: Point };
16
+ export type RoutedEdgeType = Edge<RoutedEdgeData, "routed">;
17
+
18
+ /** How far above the box's top edge the arrowhead's tip stops, so it never touches the border. */
19
+ const TIP_GAP = 3;
9
20
 
10
21
  /**
11
- * One import line on the Map (VISUAL_REFRESH.md §4): drawn along the route the layout gave it, through the channels
12
- * and gutters between boxes, never across one. Without a route (it can't happen on a laid-out level, but React Flow
13
- * may draw before the routes arrive) it falls back to a straight line between the two handles.
22
+ * One import line on the Map (the Workbench redesign, ADR-070): drawn along the route the layout gave it, through the
23
+ * channels and gutters between boxes, never across one. Its head is filled and wider than the line on both sides, and
24
+ * the line stops inside the head's base, so the two read as one arrow at any width. Every route ends going down into
25
+ * its box's top, so the head always points down. Without a route (React Flow may draw before the routes arrive) it
26
+ * falls back to a straight line between the two handles.
14
27
  *
15
- * The hover label ("16 imports") is HTML above the boxes, not SVG beside the line: the lines sit under the boxes, and
16
- * a label drawn with them was cut off by any box it overlapped (owner review).
28
+ * The count is HTML above the boxes, not SVG beside the line: the lines sit under the boxes, and a label drawn with
29
+ * them was cut off by any box it overlapped (owner review). What a hovered line means, in words, is the canvas's hover
30
+ * card (LineCard), beside the pointer rather than on the line.
17
31
  */
18
- function RoutedEdge({ id, sourceX, sourceY, targetX, targetY, data, style, markerEnd, label, interactionWidth }: EdgeProps<RoutedEdgeType>){
19
- const points = data && data.points.length >= 2 ? data.points : [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }];
20
- const middle = midpointAlong(points);
32
+ function RoutedEdge({ id, sourceX, sourceY, targetX, targetY, data, interactionWidth }: EdgeProps<RoutedEdgeType>){
33
+ const route = data && data.points.length >= 2 ? data.points : [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }];
34
+ const width = data?.width ?? 1;
35
+ const color = data?.color ?? "var(--edge-dim)";
36
+ // the lines into one box end at its one point under one head, so each draws that head, the same size whichever of
37
+ // them are drawn, and they coincide (the owner, 2026-10-28: a line that appears never changes its box's head)
38
+ const { half, length } = arrowHead(data?.headWidth ?? width);
39
+ const end = route[route.length - 1];
40
+ const tip = { x: end.x, y: end.y - TIP_GAP };
41
+ // the line stops a pixel inside the head's base, so no gap shows between them
42
+ const line = [...route.slice(0, -1), { x: tip.x, y: tip.y - length + 1 }];
43
+ // a count goes only where the Map placed it, clear of every other line, count and box (the owner, 2026-10-28)
44
+ const count = data?.labelAt !== undefined ? data.count : undefined;
45
+ const head = `M ${tip.x - half} ${tip.y - length} L ${tip.x + half} ${tip.y - length} L ${tip.x} ${tip.y} Z`;
21
46
  return (
22
47
  <>
23
- <BaseEdge id={id} path={roundedPath(points)} style={style} markerEnd={markerEnd} interactionWidth={interactionWidth} />
24
- {label && (
48
+ <BaseEdge
49
+ id={id}
50
+ path={roundedPath(line)}
51
+ style={{ stroke: color, strokeWidth: width, strokeLinecap: "butt", strokeLinejoin: "round" }}
52
+ interactionWidth={Math.max(interactionWidth ?? 20, width + 10)}
53
+ />
54
+ <path d={head} fill={color} stroke={color} strokeWidth={1} strokeLinejoin="round" className="pointer-events-none" />
55
+ {count && data?.labelAt && (
25
56
  <EdgeLabelRenderer>
26
57
  <div
27
58
  data-edge-label={id}
28
- style={{ transform: `translate(-50%, -50%) translate(${middle.x}px, ${middle.y}px)`, zIndex: 1001 }}
29
- className="pointer-events-none absolute rounded-md border border-border bg-surface px-1.5 py-0.5 text-[12px] text-text"
59
+ data-edge-count={count}
60
+ style={{ transform: `translate(-50%, -50%) translate(${data.labelAt.x}px, ${data.labelAt.y}px)`, zIndex: 1001 }}
61
+ className={`pointer-events-none absolute whitespace-nowrap rounded-[4px] border border-divider bg-canvas px-1.5 text-[11.5px] leading-[16px] tabular-nums ${data.countStrong ? "text-text" : "text-text-3"}`}
30
62
  >
31
- {label}
63
+ {count}
32
64
  </div>
33
65
  </EdgeLabelRenderer>
34
66
  )}
@@ -93,9 +93,9 @@ export default function SearchPalette({ onPick, onClose }: SearchPaletteProps){
93
93
  return (
94
94
  <div className="pointer-events-auto fixed inset-0 z-30 bg-canvas/70" onClick={onClose}>
95
95
  <div role="dialog" aria-label="Search" onClick={(event) => event.stopPropagation()}
96
- className="mx-auto mt-[12vh] w-[640px] max-w-[92vw] overflow-hidden rounded-lg border border-border bg-panel">
96
+ className="mx-auto mt-[12vh] w-[640px] max-w-[92vw] overflow-hidden rounded-[6px] border border-border bg-panel">
97
97
  <div ref={aboveRef}>
98
- <div className="flex items-center gap-3 border-b border-hairline px-4">
98
+ <div className="flex items-center gap-3 border-b border-divider px-4">
99
99
  <IconSearch aria-hidden size={16} stroke={1.75} className="shrink-0 text-text-2" />
100
100
  <input
101
101
  ref={input}
@@ -126,17 +126,17 @@ export default function SearchPalette({ onPick, onClose }: SearchPaletteProps){
126
126
  data-hit={hit.path}
127
127
  onMouseEnter={() => setActive(i)}
128
128
  onClick={() => onPick(hit)}
129
- className={`flex w-full gap-3 px-4 py-2 text-left ${i === current ? "bg-surface" : ""}`}
129
+ className={`flex w-full gap-3 px-4 py-2 text-left ${i === current ? "bg-row-sel" : "hover:bg-node"}`}
130
130
  >
131
131
  {hit.kind === "folder"
132
- ? <IconFolder aria-hidden size={16} stroke={1.75} className="mt-0.5 shrink-0 text-folder" />
132
+ ? <IconFolder aria-hidden size={16} stroke={1.75} className="mt-0.5 shrink-0 text-glyph-dim" />
133
133
  : <IconFile aria-hidden size={16} stroke={1.75} className="mt-0.5 shrink-0 text-text-2" />}
134
134
  <span className="min-w-0 flex-1">
135
135
  <span title={hit.path} className="block truncate font-mono text-[13px] text-text">{hit.path}</span>
136
136
  {hit.tagline && <span title={hit.tagline} className="block truncate text-[13px] text-text-2">{hit.tagline}</span>}
137
137
  {hit.code && (
138
- <span title={`${codeLabel(hit.code)}: ${hit.code.snippet}`} className="block truncate font-mono text-[12px] text-text-2">
139
- {codeLabel(hit.code)}:{" "}
138
+ <span title={`${codeLabel(hit.code)}, ${hit.code.snippet}`} className="block truncate font-mono text-[12px] text-text-2">
139
+ {codeLabel(hit.code)},{" "}
140
140
  {highlight
141
141
  // highlightCode escapes the snippet, so this HTML is only its spans (syntax.ts)
142
142
  ? <span className="syntax" dangerouslySetInnerHTML={{ __html: highlight(hit.code.snippet, languageFor(hit.path)) }} />
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
- import { IconExternalLink, IconFile, IconX } from "@tabler/icons-react";
5
+ import { IconExternalLink, IconX } from "@tabler/icons-react";
6
6
  import type { SourceResponse } from "@/lib/contracts";
7
7
  import { api, ApiError } from "./api";
8
8
  import { formatCount } from "./format";
@@ -14,6 +14,9 @@ import { useScrollLock } from "./useScrollLock";
14
14
  /** Each line's height in px: the gutter, the code and the highlighted range all use it, so they line up. */
15
15
  const LINE_HEIGHT = 20;
16
16
 
17
+ /** "Open in VS Code" as the inspector's secondary button (DetailPanel's SECONDARY), not an underlined link. */
18
+ const SECONDARY = "inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[5px] border border-border px-3 text-[13px] text-text hover:border-text-3";
19
+
17
20
  type SourceViewerProps = {
18
21
  path: string;
19
22
  /** The lines to highlight and scroll to: a declaration picked in the panel. */
@@ -83,51 +86,50 @@ export default function SourceViewer({ path, lines, onClose, onOpenInEditor }: S
83
86
  aria-label={`Source of ${path}`}
84
87
  onClick={(event) => event.stopPropagation()}
85
88
  // a narrow window (under 600 px) gives it the whole width: 80% of it would leave too little code
86
- className="absolute inset-y-0 right-0 flex w-[min(960px,80vw)] flex-col border-l border-hairline bg-panel max-[600px]:w-full"
89
+ className="absolute inset-y-0 right-0 flex w-[min(960px,80vw)] flex-col border-l border-divider bg-panel max-[600px]:w-full"
87
90
  >
88
- <header className="flex items-start justify-between gap-3 border-b border-hairline px-4 py-3">
91
+ {/* the inspector's header: the name in the code font, the path, what's shown, and the editor as a button */}
92
+ <header className="flex shrink-0 items-start justify-between gap-2 border-b border-divider px-4 pb-3 pt-4">
89
93
  <div className="min-w-0">
90
- <div className="flex items-center gap-2">
91
- <IconFile aria-hidden size={16} stroke={1.75} className="shrink-0 text-text-2" />
92
- <h2 title={path} className="truncate text-[15px] font-medium text-text">{nameOf(path, "")}</h2>
93
- </div>
94
- <div className="truncate font-mono text-[12px] text-text-2" title={path}>{path}</div>
95
- <div className="mt-1 flex flex-wrap items-center gap-x-3 text-[12px] text-text-2">
94
+ <h2 title={path} className="truncate font-mono text-[15px] font-medium leading-5 text-text">{nameOf(path, "")}</h2>
95
+ <div className="truncate font-mono text-[12px] leading-[18px] text-text-3" title={path}>{path}</div>
96
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12.5px] leading-[18px] text-text-2">
96
97
  {source && <span>{formatCount(count)} {count === 1 ? "line" : "lines"}</span>}
97
98
  {source?.truncated && <span>Only the first 1 MB is shown.</span>}
98
- {source && tooLargeToColor(text) && <span data-testid="source-plain">Shown without colors: too large to color quickly.</span>}
99
+ {source && tooLargeToColor(text) && <span data-testid="source-plain">Shown without colors, too large to color quickly.</span>}
99
100
  {lines && <span>Lines {lines.start}–{lines.end} highlighted</span>}
100
101
  {editorHref && (onOpenInEditor
101
102
  ? (
102
- <button type="button" onClick={() => onOpenInEditor(lines?.start)} className="inline-flex items-center gap-1 text-control underline underline-offset-2 hover:text-text">
103
+ <button type="button" onClick={() => onOpenInEditor(lines?.start)} className={SECONDARY}>
103
104
  Open in VS Code <IconExternalLink aria-hidden size={14} stroke={1.75} />
104
105
  </button>
105
106
  )
106
107
  : (
107
- <a href={editorHref} className="inline-flex items-center gap-1 text-control underline underline-offset-2 hover:text-text">
108
+ <a href={editorHref} className={SECONDARY}>
108
109
  Open in VS Code <IconExternalLink aria-hidden size={14} stroke={1.75} />
109
110
  </a>
110
111
  ))}
111
112
  </div>
112
113
  </div>
113
- <button type="button" onClick={onClose} aria-label="Close the source" className="shrink-0 rounded-md p-1 text-text-2 hover:bg-surface hover:text-text">
114
+ <button type="button" onClick={onClose} aria-label="Close the source" className="-mr-1 shrink-0 rounded-[5px] p-1 text-text-2 hover:bg-node hover:text-text">
114
115
  <IconX aria-hidden size={16} stroke={1.75} />
115
116
  </button>
116
117
  </header>
117
118
  {/* long lines scroll sideways here, as in an editor (data-scroll-x: the layout check expects it) */}
118
- <div ref={scroller} data-scroll-x className="flex-1 overflow-auto">
119
- {error && <p className="px-4 py-3 text-[13px] text-danger">{error.message}</p>}
119
+ <div ref={scroller} data-scroll-x className="min-h-0 flex-1 overflow-auto">
120
+ {error && <p className="px-4 py-3 text-[13px] text-danger [overflow-wrap:anywhere]">{error.message}</p>}
120
121
  {!source && !error && <p className="px-4 py-3 text-[13px] text-text-2">Loading…</p>}
121
122
  {source && (
122
123
  <div className="relative flex min-w-max font-mono text-[13px]" style={{ lineHeight: `${LINE_HEIGHT}px` }}>
123
124
  {lines && (
124
125
  <div
125
126
  aria-hidden
126
- className="absolute inset-x-0 bg-surface"
127
+ className="absolute inset-x-0 bg-node"
127
128
  style={{ top: (lines.start - 1) * LINE_HEIGHT + 12, height: (lines.end - lines.start + 1) * LINE_HEIGHT }}
128
129
  />
129
130
  )}
130
- <pre aria-hidden className="sticky left-0 select-none bg-panel py-3 pl-4 pr-3 text-right text-text-2">
131
+ {/* the line numbers stay put as the code scrolls sideways, quieter than the code, as in the editor */}
132
+ <pre aria-hidden className="sticky left-0 select-none bg-panel py-3 pl-4 pr-3 text-right text-text-3">
131
133
  {Array.from({ length: count }, (_, i) => i + 1).join("\n")}
132
134
  </pre>
133
135
  {highlighted?.text === text
@@ -0,0 +1,166 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState, type CSSProperties } from "react";
4
+ import type { IndexStatus, LiveEvent, StageName } from "@/lib/contracts";
5
+ import { api, ApiError } from "./api";
6
+ import { activityLabel, highlightedNote, newestTitle } from "./activity";
7
+ import { ago, formatCount, headline, stageLabel, type Tone } from "./format";
8
+ import { DEMO } from "./mode";
9
+ import { placePopover, usePlaceOnResize } from "./popover";
10
+ import { useDismiss } from "./useDismiss";
11
+
12
+ type StatusBarProps = {
13
+ status: IndexStatus | null;
14
+ recent: readonly LiveEvent[];
15
+ connected: boolean;
16
+ /** How many places show_on_map has highlighted, which Clear lets go of (Esc too). */
17
+ highlightCount: number;
18
+ onClear: () => void;
19
+ /** The boxes on screen and the lines Strong links only hides, for the counts on the right. */
20
+ items: number;
21
+ weakHidden: number;
22
+ /** VS Code's slim form: only what VS Code's own bar lacks (the owner's pick, 2026-10-27). */
23
+ embedded: boolean;
24
+ /** A very short window: only the two dots, at the header's end (shell.ts). */
25
+ dots: boolean;
26
+ };
27
+
28
+ /** The indexed dot means indexed and nothing else; a failure or nothing indexed is the error color. */
29
+ const DOTS: Record<Tone, string> = { ok: "bg-ok", busy: "bg-text-3", warn: "bg-danger", off: "bg-text-3" };
30
+ const STAGES: readonly StageName[] = ["structure", "summaries", "embeddings"];
31
+ /** The index menu's width at normal sizes, narrower only when the window is. */
32
+ const MENU_WIDTH = 340;
33
+
34
+ /**
35
+ * The status bar along the Map's bottom (the Workbench redesign, ADR-070), 24 px. Left: the index status with its dot
36
+ * (it opens the stages and Re-index), the repo's file count, and Claude Code's newest action as it works, with its
37
+ * orange dot; with show_on_map highlights up, how many and Clear. Right: the items on screen and the lines Strong links
38
+ * only hides, and the search shortcut. Inside VS Code's panel it's the slim form the owner picked: the index and Claude
39
+ * Code cells and the counts, VS Code's own bar having the rest.
40
+ *
41
+ * As the window narrows the cells give way in order, the shortcut under 1100 px, the counts under 800, the file count
42
+ * under 560, and the cells' words under 420, so the two dots, index and Claude Code, always stay (their titles say the
43
+ * rest). In a very short window the bar goes and the two dots move to the header's end.
44
+ */
45
+ export default function StatusBar({ status, recent, connected, highlightCount, onClear, items, weakHidden, embedded, dots }: StatusBarProps){
46
+ // the clock lives in state, ticking every second: reading Date.now() during render isn't allowed
47
+ const [now, setNow] = useState(0);
48
+ useEffect(() => {
49
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
50
+ return () => clearInterval(timer);
51
+ }, []);
52
+ const newest = recent[0];
53
+ const live = newest !== undefined
54
+ ? { text: activityLabel(newest), title: newestTitle(recent, now), active: true }
55
+ : {
56
+ // the static demo never connects: say why the cell is quiet, instead of "Not connected" (ADR-049)
57
+ text: DEMO ? "Static demo, no live Claude Code channel" : connected ? "Claude Code, no tool calls yet" : "Claude Code isn't connected",
58
+ title: DEMO ? "In the app, Claude Code's tool calls show up here as it works. This static demo has no live channel." : connected ? "Claude Code is connected to the live channel" : "Claude Code isn't connected to the live channel",
59
+ active: false,
60
+ };
61
+ const liveDot = <span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${live.active ? "bg-agent" : connected ? "bg-text-3" : "border border-text-3"}`} />;
62
+
63
+ if (dots){
64
+ return (
65
+ <span className="flex shrink-0 items-center gap-2.5 pl-1">
66
+ <IndexCell status={status} compact />
67
+ <span title={`${live.text}. ${live.title}`} className="grid h-6 w-4 place-items-center">{liveDot}</span>
68
+ </span>
69
+ );
70
+ }
71
+
72
+ const highlights = highlightedNote(highlightCount);
73
+ return (
74
+ <footer data-testid="activity-bar" className="relative z-20 flex h-6 shrink-0 items-center gap-4 border-t border-divider bg-panel px-3 text-[11.5px] text-text-3">
75
+ <IndexCell status={status} compact={false} />
76
+ {!embedded && status !== null && (
77
+ <span className="shrink-0 whitespace-nowrap max-[560px]:hidden">{formatCount(status.stages.structure.total)} files</span>
78
+ )}
79
+ <span className="flex min-w-0 items-center gap-1.5" title={live.title}>
80
+ {liveDot}
81
+ <span className={`truncate max-[420px]:hidden ${live.active ? "text-text-2" : ""}`}>{live.text}</span>
82
+ {live.active && now > 0 && <span className="shrink-0 max-[800px]:hidden">{ago(Math.max(0, now - Date.parse(newest!.at)))}</span>}
83
+ </span>
84
+ {highlights !== null && (
85
+ <span className="flex shrink-0 items-center gap-2">
86
+ <span className="max-[640px]:hidden">{highlights}</span>
87
+ <button type="button" onClick={onClear} title="Clear Claude Code's highlights (Esc)" className="text-text-2 underline decoration-text-3 underline-offset-2 hover:text-text">Clear</button>
88
+ </span>
89
+ )}
90
+ <span className="ml-auto shrink-0 whitespace-nowrap max-[800px]:hidden">
91
+ {formatCount(items)} {items === 1 ? "item" : "items"}{weakHidden > 0 ? `, ${formatCount(weakHidden)} weak ${weakHidden === 1 ? "link" : "links"} hidden` : ""}
92
+ </span>
93
+ {!embedded && <span className="shrink-0 whitespace-nowrap max-[1100px]:hidden">Ctrl K to search</span>}
94
+ </footer>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The index status: its dot and, but in the compact form, the stage that most needs attention in a few words. It
100
+ * opens the stages with their counts and Re-index (POST /api/index) with its progress, upward from the bar and kept
101
+ * inside the window. The static demo's index is frozen, so there it says so instead (ADR-049).
102
+ */
103
+ function IndexCell({ status, compact }: { status: IndexStatus | null; compact: boolean }){
104
+ const [open, setOpen] = useState(false);
105
+ const [reindex, setReindex] = useState<string | null>(null);
106
+ const ref = useDismiss<HTMLDivElement>(open, () => setOpen(false));
107
+ // at the header's end (a very short window) it opens downward, placed inside the window as it opens and as the
108
+ // window resizes, like the header's other popovers (popover.ts); in the bar it opens upward from the bar's start
109
+ const [place, setPlace] = useState<CSSProperties>({ right: 0, width: MENU_WIDTH });
110
+ const placeMenu = useCallback(() => {
111
+ if (ref.current) setPlace(placePopover(ref.current, MENU_WIDTH, "end"));
112
+ }, [ref]);
113
+ usePlaceOnResize(open && compact, placeMenu);
114
+ if (status === null) return null;
115
+ const head = headline(status);
116
+ const runReindex = (): void => {
117
+ setReindex("Starting…");
118
+ api.reindex((p) => setReindex(`${p.stage} ${formatCount(p.done)} / ${formatCount(p.total)}`)).then(
119
+ (done) => setReindex(`Re-indexed ${formatCount(done.files)} files in ${(done.totalMs / 1000).toFixed(1)} s`),
120
+ (err: unknown) => setReindex(err instanceof ApiError ? err.message : "The re-index failed"),
121
+ );
122
+ };
123
+ return (
124
+ <div ref={ref} className="relative flex min-w-0 shrink-0 items-center">
125
+ <button
126
+ type="button"
127
+ data-testid="status-pill"
128
+ onClick={() => {
129
+ if (!open && compact) placeMenu();
130
+ setOpen((o) => !o);
131
+ }}
132
+ aria-expanded={open}
133
+ title={`${head.text}. ${DEMO ? "The index status" : "The index status, and Re-index"}`}
134
+ className="flex min-w-0 items-center gap-1.5 rounded-[4px] px-0.5 hover:text-text"
135
+ >
136
+ <span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${DOTS[head.tone]}`} />
137
+ {!compact && <span className="truncate max-[420px]:hidden">{head.text}</span>}
138
+ </button>
139
+ {open && (
140
+ <div
141
+ data-testid="status-menu"
142
+ data-covers
143
+ style={compact ? place : { width: `min(${MENU_WIDTH}px, calc(100vw - 24px))`, maxHeight: "calc(100vh - 80px)" }}
144
+ className={`absolute z-30 space-y-3 overflow-y-auto rounded-md border border-border bg-panel p-3 text-[12px] text-text-2 ${compact ? "top-7" : "bottom-6 left-0"}`}
145
+ >
146
+ {STAGES.map((name) => (
147
+ <div key={name}>
148
+ <div className="text-[13px] text-text">{stageLabel(name, status.stages[name])}</div>
149
+ {status.stages[name].note && <div>{status.stages[name].note}</div>}
150
+ </div>
151
+ ))}
152
+ <div className="flex items-center justify-between gap-3 border-t border-divider pt-3">
153
+ {DEMO
154
+ ? <span>A static demo, its index frozen when it was exported.</span>
155
+ : (
156
+ <>
157
+ <span className="truncate" title={reindex ?? `Index version ${status.indexVersion}`}>{reindex ?? `Index version ${status.indexVersion}`}</span>
158
+ <button type="button" onClick={runReindex} className="shrink-0 rounded-[5px] border border-border px-2 py-0.5 text-[12.5px] text-text hover:border-text-3">Re-index</button>
159
+ </>
160
+ )}
161
+ </div>
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+ }
package/app/map/embed.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BRIDGE_VERSION, type BridgeFromMap, type BridgeToMap } from "@/lib/contracts";
1
+ import { BRIDGE_VERSION, type BridgeFromMap, type BridgeThemeColors, type BridgeToMap, type ThemeKind } from "@/lib/contracts";
2
2
 
3
3
  /**
4
4
  * The Map's side of the editor bridge (docs/API_CONTRACTS.md §20): when the Map is framed in a VS Code webview
@@ -24,6 +24,10 @@ export function isEmbedded(search: string): boolean {
24
24
  }
25
25
 
26
26
  const isPath = (value: unknown): value is string => typeof value === "string" && value.length <= MAX_PATH_LENGTH;
27
+
28
+ const THEME_KINDS: ReadonlySet<string> = new Set<ThemeKind>(["dark", "light", "hc", "hc-light"]);
29
+ /** A color as VS Code writes one (`#1f1f1f`, `rgba(128, 128, 128, 0.35)`): a short string. theme.ts reads it, or drops it. */
30
+ const isColorText = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 64;
27
31
  /** A path to jump to, never "": the repo is no editor's file, and focus("") would push the top level onto the history. */
28
32
  const isTarget = (value: unknown): value is string => isPath(value) && value !== "";
29
33
 
@@ -32,7 +36,8 @@ const isTarget = (value: unknown): value is string => isPath(value) && value !==
32
36
  * null, and a result carries only the fields checked. A `follow` or `show` with an empty path is null. A `highlight`
33
37
  * keeps its first 50 paths and drops entries that are not paths (not strings, or over 1,024 characters) rather than
34
38
  * refusing the message; it reads no further than its first 1,000 entries. A hello of another bridge version is null: a
35
- * Map that gets another version ignores the hello.
39
+ * Map that gets another version ignores the hello. A `theme` needs one of the four kinds and its editor, side and fg
40
+ * colors as strings of at most 64 characters; its border, desc and focus are kept only when they're such strings too.
36
41
  */
37
42
  export function parseToMap(data: unknown): BridgeToMap | null {
38
43
  if (typeof data !== "object" || data === null) return null;
@@ -57,6 +62,21 @@ export function parseToMap(data: unknown): BridgeToMap | null {
57
62
  }
58
63
  case "nebulosity.clear":
59
64
  return { type: "nebulosity.clear" };
65
+ case "nebulosity.theme": {
66
+ if (typeof message.kind !== "string" || !THEME_KINDS.has(message.kind)) return null;
67
+ if (typeof message.colors !== "object" || message.colors === null) return null;
68
+ const given = message.colors as Record<string, unknown>;
69
+ const colors: Partial<BridgeThemeColors> = {};
70
+ // the three every theme has are required, the rest are kept only when they're short strings
71
+ for (const name of ["editor", "side", "fg"] as const){
72
+ if (!isColorText(given[name])) return null;
73
+ colors[name] = given[name];
74
+ }
75
+ for (const name of ["border", "desc", "focus"] as const){
76
+ if (isColorText(given[name])) colors[name] = given[name];
77
+ }
78
+ return { type: "nebulosity.theme", kind: message.kind as ThemeKind, colors: colors as BridgeThemeColors };
79
+ }
60
80
  default:
61
81
  return null;
62
82
  }