@lapismd/design-core 0.1.0 → 0.2.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 (124) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE.md +3 -3
  3. package/README.md +21 -1
  4. package/package.json +34 -15
  5. package/spec/src/styling-and-themes.md +11 -9
  6. package/src/shared/ai/chat.css +25 -1
  7. package/src/shared/ai/chat.ts +12 -0
  8. package/src/shared/ai/composer-input/ComposerInput.svelte +3 -2
  9. package/src/shared/ai/context.svelte.ts +2 -0
  10. package/src/shared/ai/experimental/reaction-bar/ReactionBar.svelte +1 -1
  11. package/src/shared/ai/layout/Layout.svelte +116 -10
  12. package/src/shared/ai/message-list/MessageList.svelte +123 -43
  13. package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
  14. package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
  15. package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
  16. package/src/shared/ai/message-list/position.ts +7 -0
  17. package/src/shared/ai/new-messages.svelte.ts +24 -5
  18. package/src/shared/ai/stream-scroll.svelte.ts +135 -6
  19. package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
  20. package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
  21. package/src/shared/ai/tool-calls/ToolCalls.svelte +1 -1
  22. package/src/shared/ai/trigger-menu.ts +2 -3
  23. package/src/shared/diff/core/merge/actions.ts +4 -3
  24. package/src/shared/diff/core/merge/render-model.ts +14 -10
  25. package/src/shared/diff/file-diff/FileDiff.css +68 -1
  26. package/src/shared/diff/file-diff/FileDiff.svelte +27 -14
  27. package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
  28. package/src/shared/diff/file-diff/index.ts +2 -0
  29. package/src/shared/diff/file-diff/types.ts +13 -0
  30. package/src/shared/diff/file-listing/FileListingViewModeToggle.css +42 -5
  31. package/src/shared/diff/index.ts +2 -0
  32. package/src/shared/diff/merge-editor/MergeEditor.svelte +2 -7
  33. package/src/shared/filter/filter-query/filters.ts +2 -2
  34. package/src/shared/filter/filter-query/parser.ts +9 -8
  35. package/src/shared/filter/power-search/PowerSearch.svelte +1 -1
  36. package/src/shared/filter/search-filter-bar/SearchFilterBar.svelte +2 -2
  37. package/src/shared/filter/search-filter-bar/SearchFilterInput.svelte +1 -1
  38. package/src/shared/filter/search-filter-bar/SearchFilterPredicateEditor.svelte +1 -1
  39. package/src/shared/filter/search-filter-bar/search-filter-syntax.ts +1 -1
  40. package/src/shared/forms/autocomplete-input/AutocompleteInput.svelte +19 -7
  41. package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
  42. package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
  43. package/src/shared/forms/confirm-button/index.ts +1 -0
  44. package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
  45. package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
  46. package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
  47. package/src/shared/forms/confirm-dialog/index.ts +2 -0
  48. package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
  49. package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
  50. package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
  51. package/src/shared/forms/dialog-frame/index.ts +6 -0
  52. package/src/shared/forms/dialog.ts +8 -0
  53. package/src/shared/forms/index.ts +11 -0
  54. package/src/shared/shadcn/button/button.svelte +4 -0
  55. package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
  56. package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
  57. package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
  58. package/src/shared/shadcn/command/command-dialog.svelte +1 -1
  59. package/src/shared/shadcn/command/command-group.svelte +3 -1
  60. package/src/shared/shadcn/command-view/command-view.svelte +1 -6
  61. package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
  62. package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
  63. package/src/shared/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -691
  64. package/src/shared/shadcn/dropdown-menu/dropdown-menu-content.svelte +691 -0
  65. package/src/shared/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +1 -1
  66. package/src/shared/shadcn/scroll-area/scroll-area.svelte +2 -0
  67. package/src/shared/shadcn/spinner/spinner.svelte +3 -3
  68. package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
  69. package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
  70. package/src/shared/shell/app-shell/AppShell.css +112 -32
  71. package/src/shared/shell/app-shell/AppShell.demo.css +37 -0
  72. package/src/shared/shell/app-shell/AppShellBodyContent.svelte +5 -1
  73. package/src/shared/shell/app-shell/AppShellBodySidebar.svelte +5 -1
  74. package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
  75. package/src/shared/shell/app-shell/AppShellMobilePanelSelector.svelte +1 -1
  76. package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
  77. package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
  78. package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
  79. package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
  80. package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
  81. package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
  82. package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
  83. package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
  84. package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +269 -11
  85. package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
  86. package/src/shared/shell/app-shell/examples/AppShellBodyDemo.svelte +4 -0
  87. package/src/shared/shell/app-shell/examples/AppShellCompleteDemo.svelte +1 -0
  88. package/src/shared/shell/app-shell/examples/AppShellConversationDemo.svelte +1 -0
  89. package/src/shared/shell/app-shell/examples/AppShellExamples.css +624 -0
  90. package/src/shared/shell/app-shell/examples/AppShellFilesSidebarDemo.svelte +1 -0
  91. package/src/shared/shell/app-shell/examples/AppShellMarkdownDocumentDemo.svelte +6 -1
  92. package/src/shared/shell/app-shell/examples/AppShellMarkdownFilesDemo.svelte +1 -0
  93. package/src/shared/shell/app-shell/examples/AppShellModeSwitchDemo.svelte +1 -0
  94. package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
  95. package/src/shared/shell/app-shell/examples/AppShellProjectSidebarDemo.svelte +1 -0
  96. package/src/shared/shell/app-shell/examples/AppShellSidebarDemo.svelte +1 -0
  97. package/src/shared/shell/app-shell/examples/AppShellToolbarDemo.svelte +1 -0
  98. package/src/shared/shell/app-shell/index.ts +14 -0
  99. package/src/shared/shell/shell.tokens.css +2 -0
  100. package/src/shared/shell/shell.tokens.ts +5 -0
  101. package/src/shared/workspace/menu/WorkspaceContextMenuItems.svelte +2 -1
  102. package/src/shared/workspace/menu/WorkspaceMenuItems.svelte +2 -1
  103. package/src/shared/workspace/problems/ProblemsQuickFix.svelte +1 -4
  104. package/src/shared/workspace/settings/AppSettingsContent.svelte +6 -0
  105. package/src/shared/workspace/settings/AppSettingsNavigation.svelte +7 -0
  106. package/src/shared/workspace/settings/CopyableValue.svelte +81 -0
  107. package/src/shared/workspace/settings/DeclarativeSettingsSection.svelte +20 -0
  108. package/src/shared/workspace/settings/WorkspaceSettingField.svelte +110 -41
  109. package/src/shared/workspace/settings/WorkspaceSettingMultiSelect.svelte +7 -6
  110. package/src/shared/workspace/settings/WorkspaceSettingObjectTable.svelte +187 -23
  111. package/src/shared/workspace/settings/WorkspaceSettingSelect.svelte +29 -29
  112. package/src/shared/workspace/settings/WorkspaceSettings.css +190 -4
  113. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomField.svelte +2 -2
  114. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomPage.svelte +13 -0
  115. package/src/shared/workspace/settings/WorkspaceSettingsSurface.svelte +50 -0
  116. package/src/shared/workspace/settings/configuration.ts +5 -0
  117. package/src/shared/workspace/settings/copyable-value.ts +10 -0
  118. package/src/shared/workspace/settings/index.ts +5 -0
  119. package/src/shared/workspace/settings/settings-controller.svelte.ts +349 -15
  120. package/src/shared/workspace/settings/types.ts +90 -8
  121. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.css +25 -0
  122. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.svelte +48 -0
  123. package/src/storybook/app-shell-reference/index.ts +1 -0
  124. package/src/theme.css +10 -0
@@ -0,0 +1,7 @@
1
+ /** A consumer-owned reading position, relative to a stable message row. */
2
+ export interface MessageListPosition {
3
+ messageId: string;
4
+ index?: number;
5
+ offset: number;
6
+ atLatest: boolean;
7
+ }
@@ -1,6 +1,10 @@
1
1
  export type NewMessagesOptions = {
2
2
  isLocked: () => boolean;
3
3
  onResize?: () => void;
4
+ /** Opt in to coalescing geometry work outside the ResizeObserver delivery. */
5
+ deferResize?: boolean | (() => boolean);
6
+ requestAnimationFrame?: typeof globalThis.requestAnimationFrame;
7
+ cancelAnimationFrame?: typeof globalThis.cancelAnimationFrame;
4
8
  createResizeObserver?: (
5
9
  callback: ResizeObserverCallback,
6
10
  ) => Pick<ResizeObserver, "observe" | "disconnect">;
@@ -19,8 +23,8 @@ export function createNewMessages(
19
23
  ): NewMessagesController {
20
24
  let element: HTMLElement | null = null;
21
25
  let observer: Pick<ResizeObserver, "observe" | "disconnect"> | null = null;
22
- let initialized = false;
23
26
  let hasNewMessages = $state(false);
27
+ let resizeFrame = 0;
24
28
  const seenMessageIds = new Set<string>();
25
29
 
26
30
  const makeObserver =
@@ -44,20 +48,35 @@ export function createNewMessages(
44
48
 
45
49
  observer = makeObserver((entries) => {
46
50
  if (!entries.some((entry) => entry.target === element)) return;
47
- if (!initialized) {
48
- initialized = true;
51
+ const deferred =
52
+ typeof options.deferResize === "function"
53
+ ? options.deferResize()
54
+ : options.deferResize;
55
+ if (!deferred) {
56
+ options.onResize?.();
49
57
  return;
50
58
  }
51
- markNew();
59
+ if (resizeFrame) return;
60
+ const current = element;
61
+ resizeFrame = (options.requestAnimationFrame ?? requestAnimationFrame)(
62
+ () => {
63
+ resizeFrame = 0;
64
+ if (element === current) options.onResize?.();
65
+ },
66
+ );
52
67
  });
53
68
  observer?.observe(element);
54
69
  }
55
70
 
56
71
  function cleanup(): void {
72
+ if (resizeFrame)
73
+ (options.cancelAnimationFrame ?? cancelAnimationFrame)(resizeFrame);
74
+ resizeFrame = 0;
57
75
  observer?.disconnect();
58
76
  observer = null;
59
77
  element = null;
60
- initialized = false;
78
+ hasNewMessages = false;
79
+ seenMessageIds.clear();
61
80
  }
62
81
 
63
82
  return {
@@ -2,6 +2,8 @@ export type ScrollBehavior = "instant" | "spring";
2
2
 
3
3
  export type StreamScrollOptions = {
4
4
  enabled?: boolean;
5
+ /** Opt in to preserving a visible row during content resizing. */
6
+ anchorOnResize?: boolean | (() => boolean);
5
7
  lockThreshold?: number;
6
8
  buttonThreshold?: number;
7
9
  damping?: number;
@@ -17,6 +19,11 @@ export type ScrollToBottomOptions = {
17
19
  };
18
20
 
19
21
  export type StreamScrollController = {
22
+ /** Optional positioning extensions keep existing custom controllers assignable. */
23
+ readonly positionRevision?: number;
24
+ scrollToOffset?: (offset: number, preserveIntent?: boolean) => void;
25
+ captureAnchor?: () => () => void;
26
+ contentResized?: (options?: { restoreAnchor?: boolean }) => void;
20
27
  readonly isScrolledUp: boolean;
21
28
  readonly isLocked: boolean;
22
29
  attach: (element: HTMLElement | null) => void;
@@ -30,6 +37,14 @@ export type StreamScrollController = {
30
37
  unlock: () => void;
31
38
  };
32
39
 
40
+ export type PositionedStreamScrollController = StreamScrollController &
41
+ Required<
42
+ Pick<
43
+ StreamScrollController,
44
+ "positionRevision" | "scrollToOffset" | "captureAnchor" | "contentResized"
45
+ >
46
+ >;
47
+
33
48
  function reducedMotionDefault(): boolean {
34
49
  return (
35
50
  typeof window !== "undefined" &&
@@ -55,9 +70,10 @@ function defaultCaf(id: number): void {
55
70
 
56
71
  export function createStreamScroll(
57
72
  options: StreamScrollOptions = {},
58
- ): StreamScrollController {
73
+ ): PositionedStreamScrollController {
59
74
  const {
60
75
  enabled = true,
76
+ anchorOnResize = false,
61
77
  lockThreshold = 24,
62
78
  buttonThreshold = 80,
63
79
  damping = 26,
@@ -72,6 +88,12 @@ export function createStreamScroll(
72
88
  let frame = 0;
73
89
  let lastTime = 0;
74
90
  let velocity = 0;
91
+ let positionRevision = 0;
92
+ let restoreAnchor: (() => void) | undefined;
93
+ let pendingUserScroll = false;
94
+ let adjustedScrollTop: number | undefined;
95
+ const anchorsEnabled = () =>
96
+ typeof anchorOnResize === "function" ? anchorOnResize() : anchorOnResize;
75
97
  let isLocked = $state(true);
76
98
  let isScrolledUp = $state(false);
77
99
 
@@ -90,7 +112,7 @@ export function createStreamScroll(
90
112
  }
91
113
  const distance = distanceFromBottom();
92
114
  isScrolledUp = distance > buttonThreshold;
93
- isLocked = distance <= lockThreshold;
115
+ isLocked = !pendingUserScroll && distance <= lockThreshold;
94
116
  }
95
117
 
96
118
  function stopAnimation(): void {
@@ -109,6 +131,7 @@ export function createStreamScroll(
109
131
  0,
110
132
  element.scrollHeight - element.clientHeight,
111
133
  );
134
+ pendingUserScroll = false;
112
135
  isLocked = true;
113
136
  isScrolledUp = false;
114
137
  }
@@ -149,6 +172,8 @@ export function createStreamScroll(
149
172
  behavior = "spring",
150
173
  }: ScrollToBottomOptions = {}): void {
151
174
  if (!element || !enabled) return;
175
+ positionRevision++;
176
+ restoreAnchor = undefined;
152
177
  if (behavior === "instant" || prefersReducedMotion()) {
153
178
  jumpToBottom();
154
179
  return;
@@ -156,14 +181,83 @@ export function createStreamScroll(
156
181
  springToBottom();
157
182
  }
158
183
 
184
+ function captureAnchor(): () => void {
185
+ const viewport = element;
186
+ const revision = positionRevision;
187
+ const top = viewport?.getBoundingClientRect().top ?? 0;
188
+ const anchor = [
189
+ ...(viewport?.querySelectorAll<HTMLElement>(
190
+ "[data-message-id], [data-ui-component='ai-chat-message']",
191
+ ) ?? []),
192
+ ].find((row) => row.getBoundingClientRect().bottom > top);
193
+ const offset = anchor ? anchor.getBoundingClientRect().top - top : 0;
194
+ const height = viewport?.scrollHeight ?? 0;
195
+ const scrollTop = viewport?.scrollTop ?? 0;
196
+ return () => {
197
+ if (
198
+ !viewport ||
199
+ viewport !== element ||
200
+ revision !== positionRevision ||
201
+ isLocked
202
+ )
203
+ return;
204
+ if (anchor?.isConnected)
205
+ viewport.scrollTop +=
206
+ anchor.getBoundingClientRect().top -
207
+ viewport.getBoundingClientRect().top -
208
+ offset;
209
+ else viewport.scrollTop = scrollTop + viewport.scrollHeight - height;
210
+ update();
211
+ };
212
+ }
213
+
159
214
  function handleScroll(): void {
215
+ if (
216
+ adjustedScrollTop !== undefined &&
217
+ element?.scrollTop === adjustedScrollTop
218
+ ) {
219
+ adjustedScrollTop = undefined;
220
+ isScrolledUp = distanceFromBottom() > buttonThreshold;
221
+ return;
222
+ }
223
+ adjustedScrollTop = undefined;
224
+ pendingUserScroll = false;
160
225
  update();
226
+ restoreAnchor = !isLocked && anchorsEnabled() ? captureAnchor() : undefined;
161
227
  }
162
228
 
163
229
  function handleUserScrollIntent(): void {
230
+ adjustedScrollTop = undefined;
231
+ positionRevision++;
232
+ if (anchorsEnabled()) {
233
+ pendingUserScroll = true;
234
+ isLocked = false;
235
+ }
236
+ restoreAnchor = undefined;
237
+ if (frame) stopAnimation();
238
+ }
239
+
240
+ function handlePointerIntent(): void {
241
+ positionRevision++;
242
+ restoreAnchor = undefined;
164
243
  if (frame) stopAnimation();
165
244
  }
166
245
 
246
+ function handleKeydown(event: KeyboardEvent): void {
247
+ if (
248
+ [
249
+ "ArrowUp",
250
+ "ArrowDown",
251
+ "PageUp",
252
+ "PageDown",
253
+ "Home",
254
+ "End",
255
+ " ",
256
+ ].includes(event.key)
257
+ )
258
+ handleUserScrollIntent();
259
+ }
260
+
167
261
  function attach(next: HTMLElement | null): void {
168
262
  if (element === next) return;
169
263
  cleanup();
@@ -175,26 +269,60 @@ export function createStreamScroll(
175
269
  element?.addEventListener("touchstart", handleUserScrollIntent, {
176
270
  passive: true,
177
271
  });
178
- element?.addEventListener("pointerdown", handleUserScrollIntent, {
272
+ element?.addEventListener("pointerdown", handlePointerIntent, {
179
273
  passive: true,
180
274
  });
275
+ element?.addEventListener("keydown", handleKeydown);
181
276
  if (element && enabled) {
277
+ const revision = positionRevision;
182
278
  raf(() => {
183
- if (element === next) jumpToBottom();
279
+ if (element === next && revision === positionRevision) jumpToBottom();
184
280
  });
185
281
  }
186
282
  }
187
283
 
188
284
  function cleanup(): void {
189
285
  element?.removeEventListener("scroll", handleScroll);
286
+ element?.removeEventListener("keydown", handleKeydown);
190
287
  element?.removeEventListener("wheel", handleUserScrollIntent);
191
288
  element?.removeEventListener("touchstart", handleUserScrollIntent);
192
- element?.removeEventListener("pointerdown", handleUserScrollIntent);
289
+ element?.removeEventListener("pointerdown", handlePointerIntent);
193
290
  stopAnimation();
291
+ positionRevision++;
292
+ pendingUserScroll = false;
293
+ adjustedScrollTop = undefined;
294
+ restoreAnchor = undefined;
194
295
  element = null;
195
296
  }
196
297
 
197
298
  return {
299
+ get positionRevision() {
300
+ return positionRevision;
301
+ },
302
+ captureAnchor,
303
+ contentResized({ restoreAnchor: shouldRestore = true } = {}) {
304
+ if (isLocked) {
305
+ jumpToBottom();
306
+ update();
307
+ } else {
308
+ if (shouldRestore && anchorsEnabled()) restoreAnchor?.();
309
+ // Geometry changes cannot acknowledge that the reader returned to latest.
310
+ isLocked = false;
311
+ isScrolledUp = distanceFromBottom() > buttonThreshold;
312
+ }
313
+ },
314
+ scrollToOffset(offset, preserveIntent = false) {
315
+ if (!element) return;
316
+ if (!preserveIntent) positionRevision++;
317
+ stopAnimation();
318
+ element.scrollTop = Math.max(0, offset);
319
+ if (preserveIntent) {
320
+ // A measurement correction can clamp against the previous DOM height.
321
+ // It must not relock following before the resized rows have rendered.
322
+ adjustedScrollTop = element.scrollTop;
323
+ isScrolledUp = distanceFromBottom() > buttonThreshold;
324
+ } else update();
325
+ },
198
326
  get isScrolledUp() {
199
327
  return isScrolledUp;
200
328
  },
@@ -208,6 +336,7 @@ export function createStreamScroll(
208
336
  scrollToMessage(message) {
209
337
  if (!element) return;
210
338
  stopAnimation();
339
+ positionRevision++;
211
340
  const offset =
212
341
  message.getBoundingClientRect().top -
213
342
  element.getBoundingClientRect().top +
@@ -226,7 +355,7 @@ export function createStreamScroll(
226
355
  if (last) this.scrollToMessage(last);
227
356
  },
228
357
  scrollIfLocked() {
229
- if (isLocked) scrollToBottom({ behavior: "instant" });
358
+ if (isLocked) jumpToBottom();
230
359
  },
231
360
  lock() {
232
361
  isLocked = true;
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import { CodeBlock } from "@lapismd/design-core/shadcn/code-block";
3
+ import {
4
+ isOneLineToolAlert,
5
+ presentToolPayload,
6
+ } from "./tool-call-detail.js";
7
+
8
+ let {
9
+ input,
10
+ output,
11
+ error,
12
+ toolName,
13
+ }: {
14
+ input?: string;
15
+ output?: string;
16
+ error?: string;
17
+ toolName?: string;
18
+ } = $props();
19
+
20
+ const hint = $derived({
21
+ ...(toolName === undefined ? {} : { toolName }),
22
+ ...(input === undefined ? {} : { input }),
23
+ });
24
+ const inputPayload = $derived(presentToolPayload(input));
25
+ const outputPayload = $derived(presentToolPayload(output, hint));
26
+ const errorPayload = $derived(presentToolPayload(error, hint));
27
+ </script>
28
+
29
+ <div data-ui-component="ai-chat-tool-call-detail" data-ui-part="root">
30
+ {#if inputPayload}
31
+ <CodeBlock
32
+ code={inputPayload.code}
33
+ language={inputPayload.language}
34
+ title="Input"
35
+ size="sm"
36
+ width="full"
37
+ isWrapped
38
+ maxHeight="16rem"
39
+ />
40
+ {/if}
41
+ {#if outputPayload}
42
+ <CodeBlock
43
+ code={outputPayload.code}
44
+ language={outputPayload.language}
45
+ title="Output"
46
+ size="sm"
47
+ width="full"
48
+ isWrapped
49
+ maxHeight="16rem"
50
+ />
51
+ {/if}
52
+ {#if errorPayload && !isOneLineToolAlert(errorPayload)}
53
+ <CodeBlock
54
+ code={errorPayload.code}
55
+ language={errorPayload.language}
56
+ title="Error"
57
+ size="sm"
58
+ width="full"
59
+ isWrapped
60
+ maxHeight="16rem"
61
+ />
62
+ {/if}
63
+ </div>
64
+
65
+ <style>
66
+ [data-ui-component="ai-chat-tool-call-detail"] {
67
+ display: grid;
68
+ gap: 0.75rem;
69
+ min-width: 0;
70
+ }
71
+ </style>
@@ -0,0 +1,209 @@
1
+ export type ToolPayloadLanguage = "json" | "bash" | "plaintext";
2
+
3
+ export type PresentedToolPayload = {
4
+ code: string;
5
+ language: ToolPayloadLanguage;
6
+ };
7
+
8
+ export type ToolPayloadHint = {
9
+ toolName?: string;
10
+ input?: string;
11
+ };
12
+
13
+ const SUMMARY_KEYS = [
14
+ "command",
15
+ "path",
16
+ "file",
17
+ "query",
18
+ "url",
19
+ "pattern",
20
+ "target",
21
+ ] as const;
22
+ const PRIMARY_KEYS = [
23
+ "output",
24
+ "content",
25
+ "text",
26
+ "result",
27
+ "stdout",
28
+ "aggregated",
29
+ ] as const;
30
+ const METADATA_KEYS = new Set([
31
+ "ok",
32
+ "status",
33
+ "success",
34
+ "isError",
35
+ "server",
36
+ "tool",
37
+ "name",
38
+ ]);
39
+ const STRUCTURAL_SIBLINGS = new Set([
40
+ "path",
41
+ "command",
42
+ "query",
43
+ "file",
44
+ "url",
45
+ "pattern",
46
+ "target",
47
+ "locations",
48
+ ]);
49
+ const RUNNER_TOOL_NAMES = new Set([
50
+ "bash",
51
+ "sh",
52
+ "shell",
53
+ "exec",
54
+ "command",
55
+ "terminal",
56
+ "run",
57
+ ]);
58
+ const MAX_PARSE_DEPTH = 3;
59
+
60
+ export function presentToolPayload(
61
+ value?: string,
62
+ hint?: ToolPayloadHint,
63
+ ): PresentedToolPayload | undefined {
64
+ if (value == null || value === "") return undefined;
65
+ const extracted = extractDisplayValue(value, 0);
66
+ if (extracted == null || extracted === "") return undefined;
67
+ if (isRecord(extracted) || Array.isArray(extracted)) {
68
+ return { code: JSON.stringify(extracted, null, 2), language: "json" };
69
+ }
70
+ const text = String(extracted);
71
+ if (!text) return undefined;
72
+ return { code: text, language: detectTextLanguage(text, hint) };
73
+ }
74
+
75
+ export function isOneLineToolAlert(payload: PresentedToolPayload): boolean {
76
+ return payload.language === "plaintext" && !payload.code.includes("\n");
77
+ }
78
+
79
+ export function toolCallTarget(
80
+ input?: string,
81
+ server?: string,
82
+ ): string | undefined {
83
+ const summary = summarizeToolInput(input);
84
+ return summary || server || undefined;
85
+ }
86
+
87
+ function extractDisplayValue(value: unknown, depth: number): unknown {
88
+ const parsed = tryParseJson(value);
89
+ if (parsed === undefined) return value;
90
+ if (typeof parsed === "string") {
91
+ return depth + 1 >= MAX_PARSE_DEPTH
92
+ ? parsed
93
+ : extractDisplayValue(parsed, depth + 1);
94
+ }
95
+ if (Array.isArray(parsed) || !isRecord(parsed)) return parsed;
96
+ const mcpText = mcpContentText(parsed);
97
+ if (mcpText != null) return extractDisplayValue(mcpText, depth + 1);
98
+ const unwrapped = unwrapEnvelope(parsed);
99
+ return unwrapped === undefined
100
+ ? parsed
101
+ : extractDisplayValue(unwrapped, depth + 1);
102
+ }
103
+
104
+ function tryParseJson(value: unknown): unknown {
105
+ if (typeof value !== "string") return value;
106
+ const trimmed = value.trim();
107
+ if (!trimmed) return undefined;
108
+ if (
109
+ !trimmed.startsWith("{") &&
110
+ !trimmed.startsWith("[") &&
111
+ !trimmed.startsWith('"')
112
+ )
113
+ return undefined;
114
+ try {
115
+ return JSON.parse(trimmed);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ function unwrapEnvelope(record: Record<string, unknown>): unknown {
122
+ const primary = PRIMARY_KEYS.find(
123
+ (key) => record[key] != null && record[key] !== "",
124
+ );
125
+ if (!primary) return undefined;
126
+ if (
127
+ Object.keys(record).some(
128
+ (key) => key !== primary && !METADATA_KEYS.has(key),
129
+ )
130
+ )
131
+ return undefined;
132
+ return record[primary];
133
+ }
134
+
135
+ function mcpContentText(record: Record<string, unknown>): string | undefined {
136
+ if (!Array.isArray(record.content) || record.content.length === 0) return;
137
+ const rest = Object.keys(record).filter((key) => key !== "content");
138
+ if (
139
+ rest.some((key) => STRUCTURAL_SIBLINGS.has(key) || !METADATA_KEYS.has(key))
140
+ )
141
+ return;
142
+ const parts = record.content.flatMap((entry) => {
143
+ if (typeof entry === "string" && entry) return [entry];
144
+ if (isRecord(entry) && typeof entry.text === "string" && entry.text)
145
+ return [entry.text];
146
+ return [];
147
+ });
148
+ return parts.length ? parts.join("\n") : undefined;
149
+ }
150
+
151
+ function detectTextLanguage(
152
+ text: string,
153
+ hint?: ToolPayloadHint,
154
+ ): ToolPayloadLanguage {
155
+ if (isRunnerToolName(hint?.toolName) || hasCommandInput(hint?.input))
156
+ return "bash";
157
+ if (
158
+ text.includes("\n") ||
159
+ /^[$%] /u.test(text.trim()) ||
160
+ /\b(?:stderr|exit \d+)\b/iu.test(text)
161
+ )
162
+ return "bash";
163
+ return "plaintext";
164
+ }
165
+
166
+ function isRunnerToolName(name?: string): boolean {
167
+ if (!name) return false;
168
+ const normalized = name.trim().toLowerCase();
169
+ const leaf = normalized.split(/[:/]/u).at(-1);
170
+ return (
171
+ RUNNER_TOOL_NAMES.has(normalized) ||
172
+ (leaf != null && RUNNER_TOOL_NAMES.has(leaf))
173
+ );
174
+ }
175
+
176
+ function hasCommandInput(input?: string): boolean {
177
+ if (!input) return false;
178
+ try {
179
+ const parsed: unknown = JSON.parse(input);
180
+ return (
181
+ isRecord(parsed) &&
182
+ typeof parsed.command === "string" &&
183
+ !!parsed.command.trim()
184
+ );
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ function summarizeToolInput(input?: string): string | undefined {
191
+ if (!input) return;
192
+ try {
193
+ const parsed: unknown = JSON.parse(input);
194
+ if (!isRecord(parsed)) return;
195
+ for (const key of SUMMARY_KEYS) {
196
+ if (typeof parsed[key] === "string" && parsed[key]) return parsed[key];
197
+ }
198
+ if (Array.isArray(parsed.locations)) {
199
+ const first = parsed.locations.find(isRecord);
200
+ if (first && typeof first.path === "string") return first.path;
201
+ }
202
+ } catch {
203
+ return;
204
+ }
205
+ }
206
+
207
+ function isRecord(value: unknown): value is Record<string, unknown> {
208
+ return value != null && typeof value === "object" && !Array.isArray(value);
209
+ }
@@ -141,7 +141,7 @@
141
141
  >
142
142
  {#if !hasMultipleCalls}
143
143
  <ul data-ui-part="call-list" data-single-call>
144
- {@render callRow(calls[0], 0)}
144
+ {@render callRow(calls[0]!, 0)}
145
145
  </ul>
146
146
  {:else}
147
147
  <Collapsible.Root open={expanded} onOpenChange={setExpanded}>
@@ -40,9 +40,8 @@ export function nearestClipBottom(
40
40
  let bottom = viewportHeight;
41
41
  let current = element?.parentElement ?? null;
42
42
  while (current) {
43
- const overflowY = current.ownerDocument.defaultView
44
- ?.getComputedStyle(current)
45
- .overflowY;
43
+ const overflowY =
44
+ current.ownerDocument.defaultView?.getComputedStyle(current).overflowY;
46
45
  if (overflowY && CLIPPING_OVERFLOW.has(overflowY)) {
47
46
  bottom = Math.min(bottom, current.getBoundingClientRect().bottom);
48
47
  }
@@ -32,7 +32,7 @@ function blockRangeForIndex(
32
32
  }
33
33
  for (let rangeIndex = ranges.length - 1; rangeIndex >= 0; rangeIndex -= 1) {
34
34
  const range = ranges[rangeIndex];
35
- if (range.end <= index) {
35
+ if (range && range.end <= index) {
36
36
  return range;
37
37
  }
38
38
  }
@@ -59,6 +59,7 @@ function replaceCenterContent(model: MergeModel, content: string): MergeModel {
59
59
  let lineIndex = 0;
60
60
  for (let blockIndex = 0; blockIndex < model.blocks.length; blockIndex += 1) {
61
61
  const block = model.blocks[blockIndex];
62
+ if (!block) continue;
62
63
  const start = lineIndex;
63
64
  lineIndex += linesFromBlock(block, "base").length;
64
65
  ranges.push({ blockIndex, start, end: lineIndex });
@@ -69,7 +70,7 @@ function replaceCenterContent(model: MergeModel, content: string): MergeModel {
69
70
  if (op.kind === "same") {
70
71
  const range = blockRangeForIndex(ranges, currentIndex);
71
72
  if (range) {
72
- linesByBlock[range.blockIndex].push(op.right);
73
+ linesByBlock[range.blockIndex]?.push(op.right);
73
74
  }
74
75
  currentIndex += 1;
75
76
  } else if (op.kind === "left") {
@@ -77,7 +78,7 @@ function replaceCenterContent(model: MergeModel, content: string): MergeModel {
77
78
  } else {
78
79
  const range = blockRangeForIndex(ranges, currentIndex);
79
80
  if (range) {
80
- linesByBlock[range.blockIndex].push(op.value);
81
+ linesByBlock[range.blockIndex]?.push(op.value);
81
82
  }
82
83
  }
83
84
  }
@@ -67,7 +67,7 @@ function isPendingNavigationComponent(component: RenderComponent): boolean {
67
67
  function preferPendingComponent(
68
68
  candidates: readonly RenderComponent[],
69
69
  ): RenderComponent {
70
- return (
70
+ const preferred =
71
71
  candidates.find(
72
72
  (component) =>
73
73
  component.side === "left" && component.action?.kind === "merge",
@@ -76,8 +76,11 @@ function preferPendingComponent(
76
76
  (component) =>
77
77
  component.side === "right" && component.action?.kind === "merge",
78
78
  ) ??
79
- candidates[0]
80
- );
79
+ candidates[0];
80
+ if (!preferred) {
81
+ throw new Error("Pending merge navigation requires a component");
82
+ }
83
+ return preferred;
81
84
  }
82
85
 
83
86
  export function pendingMergeNavigationTargets(
@@ -520,6 +523,13 @@ export function createMergeRenderModel(
520
523
  placeholder,
521
524
  workingCopyCenter,
522
525
  );
526
+ const action = actionFor(
527
+ block,
528
+ side,
529
+ visualKind,
530
+ model.mode,
531
+ workingCopyCenter,
532
+ );
523
533
  const component: RenderComponent = {
524
534
  id: `${block.id}:${side}`,
525
535
  blockId: block.id,
@@ -534,13 +544,7 @@ export function createMergeRenderModel(
534
544
  side === "base" &&
535
545
  block.kind === "modified" &&
536
546
  centerIncludesMergedChange(block, workingCopyCenter),
537
- action: actionFor(
538
- block,
539
- side,
540
- visualKind,
541
- model.mode,
542
- workingCopyCenter,
543
- ),
547
+ ...(action === undefined ? {} : { action }),
544
548
  };
545
549
  renderSides[side].push(component);
546
550
  blockComponents.set(side, component);