@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE.md +3 -3
- package/README.md +21 -1
- package/package.json +34 -15
- package/spec/src/styling-and-themes.md +11 -9
- package/src/shared/ai/chat.css +25 -1
- package/src/shared/ai/chat.ts +12 -0
- package/src/shared/ai/composer-input/ComposerInput.svelte +3 -2
- package/src/shared/ai/context.svelte.ts +2 -0
- package/src/shared/ai/experimental/reaction-bar/ReactionBar.svelte +1 -1
- package/src/shared/ai/layout/Layout.svelte +116 -10
- package/src/shared/ai/message-list/MessageList.svelte +123 -43
- package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
- package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
- package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
- package/src/shared/ai/message-list/position.ts +7 -0
- package/src/shared/ai/new-messages.svelte.ts +24 -5
- package/src/shared/ai/stream-scroll.svelte.ts +135 -6
- package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
- package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
- package/src/shared/ai/tool-calls/ToolCalls.svelte +1 -1
- package/src/shared/ai/trigger-menu.ts +2 -3
- package/src/shared/diff/core/merge/actions.ts +4 -3
- package/src/shared/diff/core/merge/render-model.ts +14 -10
- package/src/shared/diff/file-diff/FileDiff.css +68 -1
- package/src/shared/diff/file-diff/FileDiff.svelte +27 -14
- package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
- package/src/shared/diff/file-diff/index.ts +2 -0
- package/src/shared/diff/file-diff/types.ts +13 -0
- package/src/shared/diff/file-listing/FileListingViewModeToggle.css +42 -5
- package/src/shared/diff/index.ts +2 -0
- package/src/shared/diff/merge-editor/MergeEditor.svelte +2 -7
- package/src/shared/filter/filter-query/filters.ts +2 -2
- package/src/shared/filter/filter-query/parser.ts +9 -8
- package/src/shared/filter/power-search/PowerSearch.svelte +1 -1
- package/src/shared/filter/search-filter-bar/SearchFilterBar.svelte +2 -2
- package/src/shared/filter/search-filter-bar/SearchFilterInput.svelte +1 -1
- package/src/shared/filter/search-filter-bar/SearchFilterPredicateEditor.svelte +1 -1
- package/src/shared/filter/search-filter-bar/search-filter-syntax.ts +1 -1
- package/src/shared/forms/autocomplete-input/AutocompleteInput.svelte +19 -7
- package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
- package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
- package/src/shared/forms/confirm-button/index.ts +1 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
- package/src/shared/forms/confirm-dialog/index.ts +2 -0
- package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
- package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
- package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
- package/src/shared/forms/dialog-frame/index.ts +6 -0
- package/src/shared/forms/dialog.ts +8 -0
- package/src/shared/forms/index.ts +11 -0
- package/src/shared/shadcn/button/button.svelte +4 -0
- package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
- package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
- package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
- package/src/shared/shadcn/command/command-dialog.svelte +1 -1
- package/src/shared/shadcn/command/command-group.svelte +3 -1
- package/src/shared/shadcn/command-view/command-view.svelte +1 -6
- package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
- package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
- package/src/shared/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -691
- package/src/shared/shadcn/dropdown-menu/dropdown-menu-content.svelte +691 -0
- package/src/shared/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +1 -1
- package/src/shared/shadcn/scroll-area/scroll-area.svelte +2 -0
- package/src/shared/shadcn/spinner/spinner.svelte +3 -3
- package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
- package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
- package/src/shared/shell/app-shell/AppShell.css +112 -32
- package/src/shared/shell/app-shell/AppShell.demo.css +37 -0
- package/src/shared/shell/app-shell/AppShellBodyContent.svelte +5 -1
- package/src/shared/shell/app-shell/AppShellBodySidebar.svelte +5 -1
- package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
- package/src/shared/shell/app-shell/AppShellMobilePanelSelector.svelte +1 -1
- package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
- package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
- package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
- package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
- package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
- package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
- package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +269 -11
- package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
- package/src/shared/shell/app-shell/examples/AppShellBodyDemo.svelte +4 -0
- package/src/shared/shell/app-shell/examples/AppShellCompleteDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellConversationDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellExamples.css +624 -0
- package/src/shared/shell/app-shell/examples/AppShellFilesSidebarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellMarkdownDocumentDemo.svelte +6 -1
- package/src/shared/shell/app-shell/examples/AppShellMarkdownFilesDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellModeSwitchDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
- package/src/shared/shell/app-shell/examples/AppShellProjectSidebarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellSidebarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellToolbarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/index.ts +14 -0
- package/src/shared/shell/shell.tokens.css +2 -0
- package/src/shared/shell/shell.tokens.ts +5 -0
- package/src/shared/workspace/menu/WorkspaceContextMenuItems.svelte +2 -1
- package/src/shared/workspace/menu/WorkspaceMenuItems.svelte +2 -1
- package/src/shared/workspace/problems/ProblemsQuickFix.svelte +1 -4
- package/src/shared/workspace/settings/AppSettingsContent.svelte +6 -0
- package/src/shared/workspace/settings/AppSettingsNavigation.svelte +7 -0
- package/src/shared/workspace/settings/CopyableValue.svelte +81 -0
- package/src/shared/workspace/settings/DeclarativeSettingsSection.svelte +20 -0
- package/src/shared/workspace/settings/WorkspaceSettingField.svelte +110 -41
- package/src/shared/workspace/settings/WorkspaceSettingMultiSelect.svelte +7 -6
- package/src/shared/workspace/settings/WorkspaceSettingObjectTable.svelte +187 -23
- package/src/shared/workspace/settings/WorkspaceSettingSelect.svelte +29 -29
- package/src/shared/workspace/settings/WorkspaceSettings.css +190 -4
- package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomField.svelte +2 -2
- package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomPage.svelte +13 -0
- package/src/shared/workspace/settings/WorkspaceSettingsSurface.svelte +50 -0
- package/src/shared/workspace/settings/configuration.ts +5 -0
- package/src/shared/workspace/settings/copyable-value.ts +10 -0
- package/src/shared/workspace/settings/index.ts +5 -0
- package/src/shared/workspace/settings/settings-controller.svelte.ts +349 -15
- package/src/shared/workspace/settings/types.ts +90 -8
- package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.css +25 -0
- package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.svelte +48 -0
- package/src/storybook/app-shell-reference/index.ts +1 -0
- package/src/theme.css +10 -0
|
@@ -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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
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",
|
|
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",
|
|
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)
|
|
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
|
+
}
|
|
@@ -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 =
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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:
|
|
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);
|