@grackle-ai/web-components 0.107.2

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 (279) hide show
  1. package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
  2. package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
  3. package/.rush/temp/operation/_phase_build/all.log +18 -0
  4. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
  5. package/.rush/temp/operation/_phase_build/state.json +3 -0
  6. package/.rush/temp/operation/_phase_test/all.log +121 -0
  7. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
  8. package/.rush/temp/operation/_phase_test/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +938 -0
  10. package/.storybook/main.ts +22 -0
  11. package/.storybook/preview.tsx +30 -0
  12. package/config/rig.json +4 -0
  13. package/config/rush-project.json +12 -0
  14. package/dist/index.css +1 -0
  15. package/dist/index.js +39221 -0
  16. package/eslint.config.cjs +5 -0
  17. package/package.json +83 -0
  18. package/rush-logs/web-components._phase_build.cache.log +4 -0
  19. package/rush-logs/web-components._phase_test.cache.log +4 -0
  20. package/src/components/chat/ChatInput.module.scss +81 -0
  21. package/src/components/chat/ChatInput.stories.tsx +91 -0
  22. package/src/components/chat/ChatInput.tsx +168 -0
  23. package/src/components/chat/index.ts +6 -0
  24. package/src/components/dag/DagView.module.scss +149 -0
  25. package/src/components/dag/DagView.stories.tsx +125 -0
  26. package/src/components/dag/DagView.tsx +109 -0
  27. package/src/components/dag/TaskNode.stories.tsx +133 -0
  28. package/src/components/dag/TaskNode.tsx +40 -0
  29. package/src/components/dag/useDagLayout.ts +139 -0
  30. package/src/components/display/Breadcrumbs.module.scss +71 -0
  31. package/src/components/display/Breadcrumbs.stories.tsx +80 -0
  32. package/src/components/display/Breadcrumbs.tsx +46 -0
  33. package/src/components/display/Button.module.scss +110 -0
  34. package/src/components/display/Button.stories.tsx +88 -0
  35. package/src/components/display/Button.tsx +40 -0
  36. package/src/components/display/ConfirmDialog.module.scss +67 -0
  37. package/src/components/display/ConfirmDialog.stories.tsx +81 -0
  38. package/src/components/display/ConfirmDialog.tsx +88 -0
  39. package/src/components/display/CopyButton.module.scss +41 -0
  40. package/src/components/display/CopyButton.stories.tsx +78 -0
  41. package/src/components/display/CopyButton.tsx +64 -0
  42. package/src/components/display/DemoBanner.module.scss +37 -0
  43. package/src/components/display/DemoBanner.stories.tsx +40 -0
  44. package/src/components/display/DemoBanner.tsx +23 -0
  45. package/src/components/display/EventHoverRow.module.scss +102 -0
  46. package/src/components/display/EventHoverRow.stories.tsx +99 -0
  47. package/src/components/display/EventHoverRow.tsx +154 -0
  48. package/src/components/display/EventRenderer.module.scss +272 -0
  49. package/src/components/display/EventRenderer.stories.tsx +186 -0
  50. package/src/components/display/EventRenderer.tsx +271 -0
  51. package/src/components/display/EventStream.module.scss +93 -0
  52. package/src/components/display/EventStream.stories.tsx +249 -0
  53. package/src/components/display/EventStream.tsx +369 -0
  54. package/src/components/display/FloatingActionBar.module.scss +107 -0
  55. package/src/components/display/FloatingActionBar.stories.tsx +122 -0
  56. package/src/components/display/FloatingActionBar.tsx +119 -0
  57. package/src/components/display/SessionAttemptSelector.module.scss +50 -0
  58. package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
  59. package/src/components/display/SessionAttemptSelector.tsx +49 -0
  60. package/src/components/display/SessionPicker.module.scss +200 -0
  61. package/src/components/display/SessionPicker.stories.tsx +169 -0
  62. package/src/components/display/SessionPicker.tsx +214 -0
  63. package/src/components/display/Skeleton.module.scss +58 -0
  64. package/src/components/display/Skeleton.stories.tsx +94 -0
  65. package/src/components/display/Skeleton.tsx +127 -0
  66. package/src/components/display/Spinner.module.scss +41 -0
  67. package/src/components/display/Spinner.stories.tsx +66 -0
  68. package/src/components/display/Spinner.tsx +32 -0
  69. package/src/components/display/SplashScreen.module.scss +20 -0
  70. package/src/components/display/SplashScreen.stories.tsx +26 -0
  71. package/src/components/display/SplashScreen.tsx +16 -0
  72. package/src/components/display/SplitButton.module.scss +166 -0
  73. package/src/components/display/SplitButton.stories.tsx +95 -0
  74. package/src/components/display/SplitButton.tsx +128 -0
  75. package/src/components/display/Tooltip.module.scss +84 -0
  76. package/src/components/display/Tooltip.stories.tsx +240 -0
  77. package/src/components/display/Tooltip.tsx +184 -0
  78. package/src/components/display/extractText.test.tsx +48 -0
  79. package/src/components/display/index.ts +20 -0
  80. package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
  81. package/src/components/editable/EditableCheckbox.tsx +39 -0
  82. package/src/components/editable/EditableField.module.scss +135 -0
  83. package/src/components/editable/EditableSelect.tsx +164 -0
  84. package/src/components/editable/EditableTextArea.stories.tsx +50 -0
  85. package/src/components/editable/EditableTextArea.tsx +148 -0
  86. package/src/components/editable/EditableTextField.stories.tsx +62 -0
  87. package/src/components/editable/EditableTextField.tsx +153 -0
  88. package/src/components/editable/EnvironmentSelect.module.scss +17 -0
  89. package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
  90. package/src/components/editable/EnvironmentSelect.tsx +87 -0
  91. package/src/components/editable/index.ts +13 -0
  92. package/src/components/editable/useEditableField.test.tsx +233 -0
  93. package/src/components/editable/useEditableField.ts +173 -0
  94. package/src/components/index.ts +20 -0
  95. package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
  96. package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
  97. package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
  98. package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
  99. package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
  100. package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
  101. package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
  102. package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
  103. package/src/components/knowledge/KnowledgeNav.tsx +138 -0
  104. package/src/components/knowledge/index.ts +3 -0
  105. package/src/components/layout/AppNav.module.scss +82 -0
  106. package/src/components/layout/AppNav.stories.tsx +115 -0
  107. package/src/components/layout/AppNav.tsx +133 -0
  108. package/src/components/layout/BottomStatusBar.module.scss +58 -0
  109. package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
  110. package/src/components/layout/BottomStatusBar.tsx +206 -0
  111. package/src/components/layout/Sidebar.module.scss +60 -0
  112. package/src/components/layout/Sidebar.stories.tsx +46 -0
  113. package/src/components/layout/Sidebar.tsx +84 -0
  114. package/src/components/layout/StatusBar.module.scss +108 -0
  115. package/src/components/layout/StatusBar.stories.tsx +119 -0
  116. package/src/components/layout/StatusBar.tsx +70 -0
  117. package/src/components/layout/index.ts +9 -0
  118. package/src/components/lists/EnvironmentNav.module.scss +118 -0
  119. package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
  120. package/src/components/lists/EnvironmentNav.tsx +133 -0
  121. package/src/components/lists/FindingsNav.module.scss +126 -0
  122. package/src/components/lists/FindingsNav.tsx +146 -0
  123. package/src/components/lists/TaskList.module.scss +206 -0
  124. package/src/components/lists/TaskList.stories.tsx +401 -0
  125. package/src/components/lists/TaskList.tsx +509 -0
  126. package/src/components/lists/index.ts +6 -0
  127. package/src/components/lists/listHelpers.tsx +130 -0
  128. package/src/components/notifications/Callout.module.scss +83 -0
  129. package/src/components/notifications/Callout.stories.tsx +81 -0
  130. package/src/components/notifications/Callout.tsx +64 -0
  131. package/src/components/notifications/Toast.module.scss +86 -0
  132. package/src/components/notifications/Toast.stories.tsx +71 -0
  133. package/src/components/notifications/Toast.tsx +51 -0
  134. package/src/components/notifications/ToastContainer.module.scss +23 -0
  135. package/src/components/notifications/ToastContainer.stories.tsx +66 -0
  136. package/src/components/notifications/ToastContainer.tsx +29 -0
  137. package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
  138. package/src/components/notifications/UpdateBanner.test.tsx +64 -0
  139. package/src/components/notifications/UpdateBanner.tsx +44 -0
  140. package/src/components/notifications/index.ts +8 -0
  141. package/src/components/panels/AboutPanel.stories.tsx +70 -0
  142. package/src/components/panels/AboutPanel.tsx +66 -0
  143. package/src/components/panels/AppearancePanel.stories.tsx +45 -0
  144. package/src/components/panels/AppearancePanel.tsx +97 -0
  145. package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
  146. package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
  147. package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
  148. package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
  149. package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
  150. package/src/components/panels/FindingsPanel.module.scss +94 -0
  151. package/src/components/panels/FindingsPanel.stories.tsx +109 -0
  152. package/src/components/panels/FindingsPanel.tsx +76 -0
  153. package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
  154. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
  155. package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
  156. package/src/components/panels/PluginsPanel.tsx +77 -0
  157. package/src/components/panels/SettingsPanel.module.scss +336 -0
  158. package/src/components/panels/TaskActionButtons.module.scss +22 -0
  159. package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
  160. package/src/components/panels/TaskActionButtons.tsx +87 -0
  161. package/src/components/panels/TaskEditPanel.module.scss +202 -0
  162. package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
  163. package/src/components/panels/TaskEditPanel.tsx +328 -0
  164. package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
  165. package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
  166. package/src/components/panels/TaskOverviewPanel.tsx +270 -0
  167. package/src/components/panels/TokensPanel.stories.tsx +131 -0
  168. package/src/components/panels/TokensPanel.tsx +143 -0
  169. package/src/components/panels/WorkpadPanel.module.scss +39 -0
  170. package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
  171. package/src/components/panels/WorkpadPanel.tsx +63 -0
  172. package/src/components/panels/index.ts +13 -0
  173. package/src/components/personas/McpToolSelector.module.scss +109 -0
  174. package/src/components/personas/McpToolSelector.stories.tsx +129 -0
  175. package/src/components/personas/McpToolSelector.tsx +180 -0
  176. package/src/components/personas/PersonaManager.module.scss +233 -0
  177. package/src/components/personas/PersonaManager.stories.tsx +139 -0
  178. package/src/components/personas/PersonaManager.tsx +122 -0
  179. package/src/components/schedules/ScheduleManager.module.scss +98 -0
  180. package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
  181. package/src/components/schedules/ScheduleManager.tsx +160 -0
  182. package/src/components/settings/SettingsNav.module.scss +82 -0
  183. package/src/components/settings/SettingsNav.stories.tsx +83 -0
  184. package/src/components/settings/SettingsNav.tsx +104 -0
  185. package/src/components/streams/StreamDetailPanel.module.scss +206 -0
  186. package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
  187. package/src/components/streams/StreamDetailPanel.tsx +119 -0
  188. package/src/components/streams/StreamList.module.scss +92 -0
  189. package/src/components/streams/StreamList.stories.tsx +99 -0
  190. package/src/components/streams/StreamList.tsx +114 -0
  191. package/src/components/streams/index.ts +10 -0
  192. package/src/components/tools/AgentToolCard.module.scss +118 -0
  193. package/src/components/tools/AgentToolCard.stories.tsx +304 -0
  194. package/src/components/tools/AgentToolCard.tsx +247 -0
  195. package/src/components/tools/FileEditCard.stories.tsx +138 -0
  196. package/src/components/tools/FileEditCard.tsx +160 -0
  197. package/src/components/tools/FileReadCard.stories.tsx +120 -0
  198. package/src/components/tools/FileReadCard.tsx +106 -0
  199. package/src/components/tools/FindingCard.stories.tsx +124 -0
  200. package/src/components/tools/FindingCard.tsx +178 -0
  201. package/src/components/tools/GenericToolCard.stories.tsx +80 -0
  202. package/src/components/tools/GenericToolCard.tsx +111 -0
  203. package/src/components/tools/IpcCard.stories.tsx +129 -0
  204. package/src/components/tools/IpcCard.tsx +178 -0
  205. package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
  206. package/src/components/tools/KnowledgeCard.tsx +165 -0
  207. package/src/components/tools/MetadataCard.stories.tsx +32 -0
  208. package/src/components/tools/MetadataCard.tsx +39 -0
  209. package/src/components/tools/SearchCard.stories.tsx +74 -0
  210. package/src/components/tools/SearchCard.tsx +86 -0
  211. package/src/components/tools/ShellCard.stories.tsx +112 -0
  212. package/src/components/tools/ShellCard.tsx +106 -0
  213. package/src/components/tools/TaskCard.stories.tsx +123 -0
  214. package/src/components/tools/TaskCard.tsx +203 -0
  215. package/src/components/tools/TodoCard.module.scss +131 -0
  216. package/src/components/tools/TodoCard.stories.tsx +202 -0
  217. package/src/components/tools/TodoCard.tsx +200 -0
  218. package/src/components/tools/ToolCard.stories.tsx +177 -0
  219. package/src/components/tools/ToolCard.tsx +60 -0
  220. package/src/components/tools/ToolCardProps.ts +20 -0
  221. package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
  222. package/src/components/tools/ToolSearchCard.tsx +86 -0
  223. package/src/components/tools/WorkpadCard.stories.tsx +106 -0
  224. package/src/components/tools/WorkpadCard.tsx +125 -0
  225. package/src/components/tools/classifyTool.test.ts +44 -0
  226. package/src/components/tools/classifyTool.ts +134 -0
  227. package/src/components/tools/parseDiff.ts +95 -0
  228. package/src/components/tools/parseShellOutput.ts +28 -0
  229. package/src/components/tools/toolCardHelpers.test.ts +53 -0
  230. package/src/components/tools/toolCards.module.scss +234 -0
  231. package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
  232. package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
  233. package/src/components/workspace/WorkspaceBoard.tsx +232 -0
  234. package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
  235. package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
  236. package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
  237. package/src/context/GrackleContext.ts +28 -0
  238. package/src/context/GrackleContextTypes.ts +64 -0
  239. package/src/context/SidebarContext.tsx +53 -0
  240. package/src/context/ThemeContext.tsx +21 -0
  241. package/src/context/ToastContext.tsx +56 -0
  242. package/src/hooks/types.ts +864 -0
  243. package/src/hooks/useEventSelection.test.ts +204 -0
  244. package/src/hooks/useEventSelection.ts +158 -0
  245. package/src/hooks/useSmartScroll.ts +151 -0
  246. package/src/hooks/useTheme.ts +228 -0
  247. package/src/index.ts +210 -0
  248. package/src/mocks/MockGrackleProvider.tsx +1397 -0
  249. package/src/mocks/mockData.ts +1966 -0
  250. package/src/mocks/mockKnowledgeData.ts +294 -0
  251. package/src/scss.d.ts +12 -0
  252. package/src/styles/global.scss +244 -0
  253. package/src/styles/mixins.scss +278 -0
  254. package/src/styles/prism-theme.scss +148 -0
  255. package/src/styles/theme.scss +1102 -0
  256. package/src/test-utils/storybook-decorators.tsx +50 -0
  257. package/src/test-utils/storybook-helpers.ts +262 -0
  258. package/src/themes.ts +142 -0
  259. package/src/utils/boardColumns.ts +141 -0
  260. package/src/utils/breadcrumbs.test.ts +285 -0
  261. package/src/utils/breadcrumbs.ts +222 -0
  262. package/src/utils/dashboard.test.ts +156 -0
  263. package/src/utils/dashboard.ts +195 -0
  264. package/src/utils/eventContent.test.ts +353 -0
  265. package/src/utils/eventContent.ts +209 -0
  266. package/src/utils/findingCategory.ts +33 -0
  267. package/src/utils/format.ts +27 -0
  268. package/src/utils/iconSize.ts +18 -0
  269. package/src/utils/navigation.ts +205 -0
  270. package/src/utils/route-config.test.ts +128 -0
  271. package/src/utils/scrollUtils.test.ts +65 -0
  272. package/src/utils/scrollUtils.ts +49 -0
  273. package/src/utils/sessionEvents.test.ts +302 -0
  274. package/src/utils/sessionEvents.ts +233 -0
  275. package/src/utils/taskStatus.tsx +137 -0
  276. package/src/utils/time.ts +92 -0
  277. package/tsconfig.json +8 -0
  278. package/vite.config.ts +20 -0
  279. package/vitest.config.ts +10 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Utilities for classifying session events and formatting them for clipboard copy.
3
+ *
4
+ * Pure functions with no React or DOM dependencies.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import type { SessionEvent } from "../hooks/types.js";
10
+ import type { DisplayEvent } from "./sessionEvents.js";
11
+
12
+ /** Event types that carry meaningful, copyable content. */
13
+ const CONTENT_BEARING_TYPES: ReadonlySet<string> = new Set([
14
+ "text",
15
+ "output",
16
+ "user_input",
17
+ "tool_use",
18
+ "tool_result",
19
+ "error",
20
+ ]);
21
+
22
+ /**
23
+ * Returns true when an event's type represents copyable content.
24
+ *
25
+ * Content-bearing: text, output, user_input, tool_use, tool_result, error.
26
+ * Non-content: status, signal, usage, system, and anything else.
27
+ */
28
+ export function isContentBearingEvent(event: SessionEvent): boolean {
29
+ return CONTENT_BEARING_TYPES.has(event.eventType);
30
+ }
31
+
32
+ /**
33
+ * Extracts the raw text that should be placed on the clipboard when a single
34
+ * event is copied via the hover action row.
35
+ *
36
+ * This returns the plain content without labels or timestamps — it mirrors
37
+ * what the old per-event CopyButton provided.
38
+ */
39
+ export function getEventCopyText(event: DisplayEvent): string {
40
+ switch (event.eventType) {
41
+ case "tool_result": {
42
+ // Prefer detailedResult when available (e.g. Copilot unified diffs)
43
+ if (event.toolUseCtx?.detailedResult) {
44
+ return event.toolUseCtx.detailedResult;
45
+ }
46
+ // When paired, the result content may be JSON-wrapped. Extract the
47
+ // displayable content the same way EventRenderer does.
48
+ let resultContent = event.content;
49
+ if (event.content.trimStart().startsWith("{")) {
50
+ try {
51
+ const parsed = JSON.parse(event.content) as Record<string, unknown>;
52
+ if (typeof parsed.content === "string") {
53
+ resultContent = parsed.content;
54
+ }
55
+ } catch { /* use as-is */ }
56
+ }
57
+ return resultContent;
58
+ }
59
+ case "tool_use": {
60
+ // Show the tool name and args in a readable form
61
+ try {
62
+ const parsed = JSON.parse(event.content) as { tool?: string; args?: unknown };
63
+ const tool = parsed.tool ?? "tool";
64
+ const args = parsed.args !== undefined ? JSON.stringify(parsed.args, undefined, 2) : "";
65
+ return `${tool}\n${args}`;
66
+ } catch {
67
+ return event.content;
68
+ }
69
+ }
70
+ default:
71
+ return event.content;
72
+ }
73
+ }
74
+
75
+ /** Extracts a one-line args summary for tool events (e.g. file path, command). */
76
+ function toolArgsSummary(args: unknown): string {
77
+ if (args === null || args === undefined) {
78
+ return "";
79
+ }
80
+ if (typeof args !== "object") {
81
+ return String(args);
82
+ }
83
+ const obj = args as Record<string, unknown>;
84
+ // Common arg patterns across tool cards
85
+ if (typeof obj.command === "string") {
86
+ return `\`${obj.command}\``;
87
+ }
88
+ if (typeof obj.file_path === "string" || typeof obj.filePath === "string") {
89
+ return `\`${(obj.file_path ?? obj.filePath) as string}\``;
90
+ }
91
+ if (typeof obj.path === "string") {
92
+ return `\`${obj.path}\``;
93
+ }
94
+ if (typeof obj.query === "string") {
95
+ return `\`${obj.query}\``;
96
+ }
97
+ if (typeof obj.pattern === "string") {
98
+ return `\`${obj.pattern}\``;
99
+ }
100
+ return "";
101
+ }
102
+
103
+ /**
104
+ * Formats a list of events as well-structured markdown for clipboard copy.
105
+ *
106
+ * Each event gets a label and timestamp header, followed by its content.
107
+ * Events are separated by blank lines. Non-content-bearing events are skipped.
108
+ */
109
+ export function formatEventsAsMarkdown(events: DisplayEvent[]): string {
110
+ const parts: string[] = [];
111
+
112
+ for (const event of events) {
113
+ if (!isContentBearingEvent(event)) {
114
+ continue;
115
+ }
116
+
117
+ const time = new Date(event.timestamp).toLocaleTimeString();
118
+
119
+ switch (event.eventType) {
120
+ case "text":
121
+ case "output": {
122
+ parts.push(`**Assistant** (${time}):\n${event.content}`);
123
+ break;
124
+ }
125
+ case "user_input": {
126
+ parts.push(`**User** (${time}):\n${event.content}`);
127
+ break;
128
+ }
129
+ case "tool_result": {
130
+ // Prefer detailedResult (e.g. Copilot unified diffs)
131
+ let resultContent = event.toolUseCtx?.detailedResult ?? undefined;
132
+ if (!resultContent) {
133
+ // Extract displayable content from JSON-wrapped results
134
+ resultContent = event.content;
135
+ if (event.content.trimStart().startsWith("{")) {
136
+ try {
137
+ const parsed = JSON.parse(event.content) as Record<string, unknown>;
138
+ if (typeof parsed.content === "string") {
139
+ resultContent = parsed.content;
140
+ }
141
+ } catch { /* use as-is */ }
142
+ }
143
+ }
144
+
145
+ if (event.toolUseCtx) {
146
+ const summary = toolArgsSummary(event.toolUseCtx.args);
147
+ const label = summary
148
+ ? `**Tool: ${event.toolUseCtx.tool}** ${summary}`
149
+ : `**Tool: ${event.toolUseCtx.tool}**`;
150
+ parts.push(`${label} (${time}):\n${resultContent}`);
151
+ } else {
152
+ parts.push(`**Tool output** (${time}):\n${resultContent}`);
153
+ }
154
+ break;
155
+ }
156
+ case "tool_use": {
157
+ let tool = "tool";
158
+ let args: unknown;
159
+ try {
160
+ const parsed = JSON.parse(event.content) as { tool?: string; args?: unknown };
161
+ tool = parsed.tool ?? "tool";
162
+ args = parsed.args;
163
+ } catch { /* use defaults */ }
164
+ const summary = toolArgsSummary(args);
165
+ const label = summary ? `**Tool: ${tool}** ${summary}` : `**Tool: ${tool}**`;
166
+ if (args !== undefined) {
167
+ parts.push(`${label} (${time}):\n\`\`\`json\n${JSON.stringify(args, undefined, 2)}\n\`\`\``);
168
+ } else {
169
+ parts.push(`${label} (${time}):`);
170
+ }
171
+ break;
172
+ }
173
+ case "error": {
174
+ parts.push(`**Error** (${time}):\n${event.content}`);
175
+ break;
176
+ }
177
+ default:
178
+ break;
179
+ }
180
+ }
181
+
182
+ return parts.join("\n\n");
183
+ }
184
+
185
+ /**
186
+ * Wraps formatted event markdown in a forwarding envelope.
187
+ *
188
+ * The envelope identifies the source session and delimits the forwarded
189
+ * content so the receiving agent can distinguish it from new input.
190
+ *
191
+ * @param sourceLabel - A human-readable label for the source (e.g. environment name).
192
+ * @param events - The events to format and enclose.
193
+ * @returns The complete envelope string ready to pass to `sendInput`.
194
+ */
195
+ export function formatForwardEnvelope(sourceLabel: string, events: DisplayEvent[]): string {
196
+ const safeLabel = sanitizeSourceLabel(sourceLabel);
197
+ const body = formatEventsAsMarkdown(events);
198
+ return `--- Forwarded from ${safeLabel} ---\n\n${body}\n\n--- End forwarded ---`;
199
+ }
200
+
201
+ /**
202
+ * Ensures a source label is a single line and cannot break envelope delimiters.
203
+ *
204
+ * Strips newlines and replaces `---` sequences with an em-dash so the label
205
+ * cannot be confused with the envelope's own `---` markers.
206
+ */
207
+ function sanitizeSourceLabel(label: string): string {
208
+ return label.replace(/[\r\n]+/g, " ").trim().replace(/---/g, "\u2014");
209
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared category-to-color mapping for findings.
3
+ *
4
+ * Centralized here so FindingsPanel, FindingsNav, and consuming pages
5
+ * all use the same palette and stay in sync.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ /** Color pair for a finding category. */
11
+ export interface CategoryColor {
12
+ /** Foreground / text color (CSS custom property). */
13
+ text: string;
14
+ /** Background / badge color (CSS custom property). */
15
+ bg: string;
16
+ }
17
+
18
+ /** Category color mapping using CSS custom property values. */
19
+ export const CATEGORY_COLORS: Record<string, CategoryColor> = {
20
+ architecture: { text: "var(--accent-blue)", bg: "var(--accent-blue-dim)" },
21
+ api: { text: "var(--accent-green)", bg: "var(--accent-green-dim)" },
22
+ bug: { text: "var(--accent-red)", bg: "var(--accent-red-dim)" },
23
+ decision: { text: "var(--accent-yellow)", bg: "var(--accent-yellow-dim)" },
24
+ dependency: { text: "var(--accent-purple)", bg: "var(--accent-purple-dim)" },
25
+ pattern: { text: "var(--accent-cyan)", bg: "var(--accent-cyan-dim)" },
26
+ general: { text: "var(--text-secondary)", bg: "var(--bg-elevated)" },
27
+ };
28
+
29
+ /** Look up the color pair for a category, falling back to `general`. */
30
+ export function getCategoryColor(category: string): CategoryColor {
31
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- category may not be in the map
32
+ return CATEGORY_COLORS[category] || CATEGORY_COLORS.general;
33
+ }
@@ -0,0 +1,27 @@
1
+ /** Format a token count in compact notation (e.g. 1952 → "2.0k", 1234567 → "1.2M"). */
2
+ export function formatTokens(n: number): string {
3
+ if (n >= 1_000_000) {
4
+ return `${(n / 1_000_000).toFixed(1)}M`;
5
+ }
6
+ if (n >= 1_000) {
7
+ const kValue = Number((n / 1_000).toFixed(1));
8
+ if (kValue >= 1_000) {
9
+ return `${(n / 1_000_000).toFixed(1)}M`;
10
+ }
11
+ return `${kValue.toFixed(1)}k`;
12
+ }
13
+ return String(n);
14
+ }
15
+
16
+ /** Format an integer millicent cost for display (e.g. 500 → "$0.0050", 123000 → "$1.23"). */
17
+ export function formatCost(millicents: number): string {
18
+ if (millicents === 0) {
19
+ return "-";
20
+ }
21
+ const usd = millicents / 100_000;
22
+ if (usd < 0.01) {
23
+ const decimals = millicents < 5 ? 5 : 4;
24
+ return `$${usd.toFixed(decimals)}`;
25
+ }
26
+ return `$${usd.toFixed(2)}`;
27
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Standardized icon sizes for lucide-react SVG icons.
3
+ *
4
+ * Use these constants instead of magic numbers so that icon sizing
5
+ * stays consistent across the UI.
6
+ */
7
+
8
+ /** Extra-small icon size (8px) — connection dots, status indicators. */
9
+ export const ICON_XS: number = 8;
10
+
11
+ /** Small icon size (12px) — inline status dots, small indicators, expand chevrons. */
12
+ export const ICON_SM: number = 12;
13
+
14
+ /** Medium icon size (14px) — tool card icons, close buttons, copy buttons. */
15
+ export const ICON_MD: number = 14;
16
+
17
+ /** Large icon size (16px) — navigation icons, notification icons. */
18
+ export const ICON_LG: number = 16;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Centralized URL builder functions and navigation helpers for all application routes.
3
+ *
4
+ * Every component that needs to navigate should import from here
5
+ * instead of hardcoding URL strings.
6
+ */
7
+
8
+ import { useCallback } from "react";
9
+ import { useNavigate, type NavigateOptions, type To } from "react-router";
10
+
11
+ /**
12
+ * Wrapper around react-router's `useNavigate` that returns a fire-and-forget
13
+ * navigate function. This avoids lint conflicts between `no-floating-promises`
14
+ * (which wants the returned `Promise<void> | void` handled) and `no-void`
15
+ * (which forbids the `void` operator).
16
+ */
17
+ export function useAppNavigate(): (to: To | number, options?: NavigateOptions) => void {
18
+ const nav = useNavigate();
19
+ return useCallback(
20
+ (to: To | number, options?: NavigateOptions) => {
21
+ if (typeof to === "number") {
22
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
23
+ nav(to);
24
+ } else {
25
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
26
+ nav(to, options);
27
+ }
28
+ },
29
+ [nav],
30
+ );
31
+ }
32
+
33
+ /** Build URL for a session detail page. */
34
+ export function sessionUrl(sessionId: string): string {
35
+ return `/sessions/${encodeURIComponent(sessionId)}`;
36
+ }
37
+
38
+ /** Build URL for a workspace overview page, nested under its environment when available. */
39
+ export function workspaceUrl(workspaceId: string, environmentId?: string): string {
40
+ if (environmentId) {
41
+ return `/environments/${encodeURIComponent(environmentId)}/workspaces/${encodeURIComponent(workspaceId)}`;
42
+ }
43
+ // Fallback to legacy route so WorkspaceRedirect can resolve the environment.
44
+ return `/workspaces/${encodeURIComponent(workspaceId)}`;
45
+ }
46
+
47
+ /** Build URL for a task detail page, optionally targeting a specific tab and workspace/environment scope. */
48
+ export function taskUrl(taskId: string, tab?: "stream" | "findings", workspaceId?: string, environmentId?: string): string {
49
+ const encodedTaskId = encodeURIComponent(taskId);
50
+ let base: string;
51
+ if (workspaceId && environmentId) {
52
+ base = `/environments/${encodeURIComponent(environmentId)}/workspaces/${encodeURIComponent(workspaceId)}/tasks/${encodedTaskId}`;
53
+ } else if (workspaceId) {
54
+ // Fallback to legacy route so WorkspaceRedirect can resolve the environment.
55
+ base = `/workspaces/${encodeURIComponent(workspaceId)}/tasks/${encodedTaskId}`;
56
+ } else {
57
+ base = `/tasks/${encodedTaskId}`;
58
+ }
59
+ if (tab) {
60
+ return `${base}/${tab}`;
61
+ }
62
+ return base;
63
+ }
64
+
65
+ /** Build URL for the task edit page. */
66
+ export function taskEditUrl(taskId: string, workspaceId?: string, environmentId?: string): string {
67
+ if (workspaceId && environmentId) {
68
+ return `/environments/${encodeURIComponent(environmentId)}/workspaces/${encodeURIComponent(workspaceId)}/tasks/${encodeURIComponent(taskId)}/edit`;
69
+ }
70
+ if (workspaceId) {
71
+ // Fallback to legacy route so WorkspaceRedirect can resolve the environment.
72
+ return `/workspaces/${encodeURIComponent(workspaceId)}/tasks/${encodeURIComponent(taskId)}/edit`;
73
+ }
74
+ return `/tasks/${encodeURIComponent(taskId)}/edit`;
75
+ }
76
+
77
+ /** Build URL for the new task form. */
78
+ export function newTaskUrl(workspaceId?: string, parentTaskId?: string, environmentId?: string): string {
79
+ const params = new URLSearchParams();
80
+ if (workspaceId) {
81
+ params.set("workspace", workspaceId);
82
+ }
83
+ if (parentTaskId) {
84
+ params.set("parent", parentTaskId);
85
+ }
86
+ const qs = params.toString();
87
+ if (workspaceId && environmentId) {
88
+ const base = `/environments/${encodeURIComponent(environmentId)}/workspaces/${encodeURIComponent(workspaceId)}/tasks/new`;
89
+ return parentTaskId ? `${base}?parent=${encodeURIComponent(parentTaskId)}` : base;
90
+ }
91
+ return qs ? `/tasks/new?${qs}` : "/tasks/new";
92
+ }
93
+
94
+ /** Build URL for the new chat form. */
95
+ export function newChatUrl(environmentId: string): string {
96
+ const params = new URLSearchParams({ env: environmentId });
97
+ return `/sessions/new?${params.toString()}`;
98
+ }
99
+
100
+ /** URL for the environments landing page. */
101
+ export const ENVIRONMENTS_URL: string = "/environments";
102
+
103
+ /** URL for the new environment form. */
104
+ export const NEW_ENVIRONMENT_URL: string = "/environments/new";
105
+
106
+ /** Build URL for an environment detail page. */
107
+ export function environmentUrl(environmentId: string): string {
108
+ return `/environments/${encodeURIComponent(environmentId)}`;
109
+ }
110
+
111
+ /** Build URL for the environment edit page. */
112
+ export function environmentEditUrl(environmentId: string): string {
113
+ return `/environments/${encodeURIComponent(environmentId)}/edit`;
114
+ }
115
+
116
+ /** URL for the settings page. */
117
+ export const SETTINGS_URL: string = "/settings";
118
+
119
+ /** URL for the settings environments tab. */
120
+ export const SETTINGS_ENVIRONMENTS_URL: string = "/settings/environments";
121
+
122
+ /** URL for the settings credentials tab. */
123
+ export const SETTINGS_CREDENTIALS_URL: string = "/settings/credentials";
124
+
125
+ /** URL for the persona management tab. */
126
+ export const PERSONAS_URL: string = "/settings/personas";
127
+
128
+ /** URL for the new persona form. */
129
+ export const NEW_PERSONA_URL: string = "/settings/personas/new";
130
+
131
+ /** Build URL for a persona detail page. */
132
+ export function personaUrl(personaId: string): string {
133
+ return `/settings/personas/${encodeURIComponent(personaId)}`;
134
+ }
135
+
136
+ /** URL for the schedule management tab. */
137
+ export const SCHEDULES_URL: string = "/settings/schedules";
138
+
139
+ /** URL for the new schedule form. */
140
+ export const NEW_SCHEDULE_URL: string = "/settings/schedules/new";
141
+
142
+ /** Build URL for a schedule detail page. */
143
+ export function scheduleUrl(scheduleId: string): string {
144
+ return `/settings/schedules/${encodeURIComponent(scheduleId)}`;
145
+ }
146
+
147
+ /** URL for the settings appearance tab. */
148
+ export const SETTINGS_APPEARANCE_URL: string = "/settings/appearance";
149
+
150
+ /** URL for the settings about tab. */
151
+ export const SETTINGS_ABOUT_URL: string = "/settings/about";
152
+
153
+ /** URL for the keyboard shortcuts reference tab. */
154
+ export const SETTINGS_SHORTCUTS_URL: string = "/settings/shortcuts";
155
+
156
+ /** URL for the device pairing page. */
157
+ export const PAIR_PATH: string = "/pair";
158
+
159
+ /** URL for the root-task chat page. */
160
+ export const CHAT_URL: string = "/chat";
161
+
162
+ /** Build URL for a specific IPC stream's chat page. */
163
+ export function chatStreamUrl(streamId: string): string {
164
+ return `/chat/${encodeURIComponent(streamId)}`;
165
+ }
166
+
167
+ /** URL for the home dashboard page. */
168
+ export const HOME_URL: string = "/";
169
+
170
+ /** URL for the tasks landing page. */
171
+ export const TASKS_URL: string = "/tasks";
172
+
173
+ /** URL for the workspaces landing page. */
174
+ export const WORKSPACES_URL: string = "/workspaces";
175
+
176
+ /** URL for the new workspace form. */
177
+ export const NEW_WORKSPACE_URL: string = "/workspaces/new";
178
+
179
+ /** URL for the knowledge graph explorer page. */
180
+ export const KNOWLEDGE_URL: string = "/knowledge";
181
+
182
+ /** URL for the findings landing page. */
183
+ export const FINDINGS_URL: string = "/findings";
184
+
185
+ /** Build URL for the findings list page, optionally scoped to a workspace. */
186
+ export function findingsUrl(workspaceId?: string, environmentId?: string): string {
187
+ if (workspaceId && environmentId) {
188
+ return `/environments/${encodeURIComponent(environmentId)}/workspaces/${encodeURIComponent(workspaceId)}/findings`;
189
+ }
190
+ return FINDINGS_URL;
191
+ }
192
+
193
+ /** Build URL for a finding detail page, optionally scoped to a workspace. */
194
+ export function findingUrl(findingId: string, workspaceId?: string, environmentId?: string): string {
195
+ const encodedFindingId = encodeURIComponent(findingId);
196
+ if (workspaceId && environmentId) {
197
+ return `/environments/${encodeURIComponent(environmentId)}/workspaces/${encodeURIComponent(workspaceId)}/findings/${encodedFindingId}`;
198
+ }
199
+ return `/findings/${encodedFindingId}`;
200
+ }
201
+
202
+ /** Build URL for the root-task chat page. */
203
+ export function chatUrl(): string {
204
+ return CHAT_URL;
205
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ sessionUrl,
4
+ workspaceUrl,
5
+ taskUrl,
6
+ taskEditUrl,
7
+ newTaskUrl,
8
+ newChatUrl,
9
+ personaUrl,
10
+ findingUrl,
11
+ findingsUrl,
12
+ HOME_URL,
13
+ SETTINGS_URL,
14
+ PERSONAS_URL,
15
+ NEW_PERSONA_URL,
16
+ NEW_ENVIRONMENT_URL,
17
+ NEW_WORKSPACE_URL,
18
+ FINDINGS_URL,
19
+ } from "./navigation.js";
20
+
21
+ describe("URL builder functions", () => {
22
+ it("sessionUrl encodes sessionId", () => {
23
+ expect(sessionUrl("abc-123")).toBe("/sessions/abc-123");
24
+ expect(sessionUrl("has space")).toBe("/sessions/has%20space");
25
+ expect(sessionUrl("special/chars")).toBe("/sessions/special%2Fchars");
26
+ });
27
+
28
+ it("workspaceUrl encodes workspaceId under environments", () => {
29
+ expect(workspaceUrl("proj-1", "env-1")).toBe("/environments/env-1/workspaces/proj-1");
30
+ expect(workspaceUrl("proj with space", "env-1")).toBe("/environments/env-1/workspaces/proj%20with%20space");
31
+ });
32
+
33
+ it("taskUrl without tab produces base path", () => {
34
+ expect(taskUrl("task-1")).toBe("/tasks/task-1");
35
+ });
36
+
37
+ it("taskUrl with stream tab produces correct path", () => {
38
+ expect(taskUrl("task-1", "stream")).toBe("/tasks/task-1/stream");
39
+ });
40
+
41
+ it("taskUrl with findings tab produces correct path", () => {
42
+ expect(taskUrl("task-1", "findings")).toBe("/tasks/task-1/findings");
43
+ });
44
+
45
+ it("taskUrl encodes taskId", () => {
46
+ expect(taskUrl("has space")).toBe("/tasks/has%20space");
47
+ expect(taskUrl("has space", "stream")).toBe("/tasks/has%20space/stream");
48
+ });
49
+
50
+ it("taskUrl with workspace and environment produces environment-scoped path", () => {
51
+ expect(taskUrl("task-1", undefined, "ws-1", "env-1")).toBe("/environments/env-1/workspaces/ws-1/tasks/task-1");
52
+ expect(taskUrl("task-1", "stream", "ws-1", "env-1")).toBe("/environments/env-1/workspaces/ws-1/tasks/task-1/stream");
53
+ expect(taskUrl("task-1", "findings", "ws-1", "env-1")).toBe("/environments/env-1/workspaces/ws-1/tasks/task-1/findings");
54
+ });
55
+
56
+ it("taskUrl with only workspaceId (no environmentId) falls back to legacy workspace path", () => {
57
+ expect(taskUrl("task-1", undefined, "ws-1")).toBe("/workspaces/ws-1/tasks/task-1");
58
+ });
59
+
60
+ it("taskEditUrl produces correct path", () => {
61
+ expect(taskEditUrl("task-1")).toBe("/tasks/task-1/edit");
62
+ });
63
+
64
+ it("taskEditUrl with workspace and environment produces scoped path", () => {
65
+ expect(taskEditUrl("task-1", "ws-1", "env-1")).toBe("/environments/env-1/workspaces/ws-1/tasks/task-1/edit");
66
+ });
67
+
68
+ it("newTaskUrl with no params produces base path", () => {
69
+ expect(newTaskUrl()).toBe("/tasks/new");
70
+ });
71
+
72
+ it("newTaskUrl includes workspace param", () => {
73
+ expect(newTaskUrl("proj-1")).toBe("/tasks/new?workspace=proj-1");
74
+ });
75
+
76
+ it("newTaskUrl includes workspace and parent params", () => {
77
+ const url = newTaskUrl("proj-1", "parent-task");
78
+ expect(url).toBe("/tasks/new?workspace=proj-1&parent=parent-task");
79
+ });
80
+
81
+ it("newTaskUrl with environment produces scoped path", () => {
82
+ expect(newTaskUrl("ws-1", undefined, "env-1")).toBe("/environments/env-1/workspaces/ws-1/tasks/new");
83
+ expect(newTaskUrl("ws-1", "parent-1", "env-1")).toBe("/environments/env-1/workspaces/ws-1/tasks/new?parent=parent-1");
84
+ });
85
+
86
+ it("newChatUrl includes env param", () => {
87
+ const url = newChatUrl("env-1");
88
+ expect(url).toBe("/sessions/new?env=env-1");
89
+ });
90
+
91
+ it("constant URLs are correct", () => {
92
+ expect(SETTINGS_URL).toBe("/settings");
93
+ expect(HOME_URL).toBe("/");
94
+ expect(PERSONAS_URL).toBe("/settings/personas");
95
+ expect(NEW_PERSONA_URL).toBe("/settings/personas/new");
96
+ expect(NEW_ENVIRONMENT_URL).toBe("/environments/new");
97
+ expect(NEW_WORKSPACE_URL).toBe("/workspaces/new");
98
+ });
99
+
100
+ it("personaUrl encodes personaId", () => {
101
+ expect(personaUrl("p-1")).toBe("/settings/personas/p-1");
102
+ expect(personaUrl("has space")).toBe("/settings/personas/has%20space");
103
+ });
104
+
105
+ it("FINDINGS_URL constant is correct", () => {
106
+ expect(FINDINGS_URL).toBe("/findings");
107
+ });
108
+
109
+ it("findingUrl produces base path without scope", () => {
110
+ expect(findingUrl("f1")).toBe("/findings/f1");
111
+ });
112
+
113
+ it("findingUrl with workspace and environment produces scoped path", () => {
114
+ expect(findingUrl("f1", "ws1", "env1")).toBe("/environments/env1/workspaces/ws1/findings/f1");
115
+ });
116
+
117
+ it("findingUrl encodes special characters", () => {
118
+ expect(findingUrl("has space")).toBe("/findings/has%20space");
119
+ });
120
+
121
+ it("findingsUrl without scope returns base findings path", () => {
122
+ expect(findingsUrl()).toBe("/findings");
123
+ });
124
+
125
+ it("findingsUrl with workspace and environment produces scoped path", () => {
126
+ expect(findingsUrl("ws1", "env1")).toBe("/environments/env1/workspaces/ws1/findings");
127
+ });
128
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ isNearAnchor,
4
+ computeScrollCompensation,
5
+ SCROLL_ANCHOR_THRESHOLD_PX,
6
+ } from "./scrollUtils.js";
7
+
8
+ describe("isNearAnchor", () => {
9
+ // Default mode — anchor at bottom
10
+ it("returns true when near the bottom in default mode", () => {
11
+ // scrollHeight=1000, clientHeight=400, scrollTop=570 → distance = 1000-570-400 = 30 < 50
12
+ expect(isNearAnchor(570, 1000, 400, false)).toBe(true);
13
+ });
14
+
15
+ it("returns false when scrolled well above the bottom in default mode", () => {
16
+ // distance = 1000 - 200 - 400 = 400 > 50
17
+ expect(isNearAnchor(200, 1000, 400, false)).toBe(false);
18
+ });
19
+
20
+ it("returns true when fully scrolled to bottom in default mode", () => {
21
+ // distance = 1000 - 600 - 400 = 0 < 50
22
+ expect(isNearAnchor(600, 1000, 400, false)).toBe(true);
23
+ });
24
+
25
+ // Reverse mode — anchor at top
26
+ it("returns true when near the top in reverse mode", () => {
27
+ // scrollTop = 20 < 50
28
+ expect(isNearAnchor(20, 1000, 400, true)).toBe(true);
29
+ });
30
+
31
+ it("returns false when scrolled well below the top in reverse mode", () => {
32
+ // scrollTop = 300 > 50
33
+ expect(isNearAnchor(300, 1000, 400, true)).toBe(false);
34
+ });
35
+
36
+ it("returns true at exact top in reverse mode", () => {
37
+ expect(isNearAnchor(0, 1000, 400, true)).toBe(true);
38
+ });
39
+
40
+ // Custom threshold
41
+ it("respects a custom threshold parameter", () => {
42
+ // distance = 1000 - 880 - 100 = 20, threshold=10 → 20 >= 10 → false
43
+ expect(isNearAnchor(880, 1000, 100, false, 10)).toBe(false);
44
+ // distance = 1000 - 895 - 100 = 5, threshold=10 → 5 < 10 → true
45
+ expect(isNearAnchor(895, 1000, 100, false, 10)).toBe(true);
46
+ });
47
+
48
+ it("uses default threshold of SCROLL_ANCHOR_THRESHOLD_PX", () => {
49
+ expect(SCROLL_ANCHOR_THRESHOLD_PX).toBe(50);
50
+ });
51
+ });
52
+
53
+ describe("computeScrollCompensation", () => {
54
+ it("returns positive delta when content was prepended", () => {
55
+ expect(computeScrollCompensation(1000, 1200)).toBe(200);
56
+ });
57
+
58
+ it("returns 0 when no change occurred", () => {
59
+ expect(computeScrollCompensation(1000, 1000)).toBe(0);
60
+ });
61
+
62
+ it("returns 0 when scrollHeight decreased", () => {
63
+ expect(computeScrollCompensation(1000, 800)).toBe(0);
64
+ });
65
+ });