mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
feat(ai-chat): show native script edit diffs (#11278)
* feat(ai-chat): show native script edit diffs * fix: render streamed edit diffs * fix: render in-editor script edit diffs * style: soften diff context separators * fix: render full script update diffs * fix: support empty script diffs * fix: bound streamed tool diffs * fix: preserve trailing diff context * fix: defer collapsed tool diffs * fix: retain full-code streaming previews * fix: preserve created script diffs * fix: disambiguate collapsed diff keys * fix: render final newline diffs * fix: preserve multiline diff highlighting * fix: highlight diff sides as complete sources * fix: bound completed tool diffs * fix: diff large completed tool edits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: bound tool diff rendering Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: bound tool diff highlighting and expand omitted rows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: reveal omitted diff rows in chunks and bound long-line highlighting Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: collapse written scripts, skip empty diff bodies and fit line numbers Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep whole-file tool diffs collapsed while running Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: classify whole-file tool calls by argument shape Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: reopen failed diff cards and keep empty streamed replacements Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fa0fc24269
commit
1de54eeea4
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCodeDiff } from './shared'
|
||||
import {
|
||||
toolDiffLines,
|
||||
visibleToolDiffRows,
|
||||
type CharacterRange,
|
||||
type ToolDiffLine
|
||||
} from './toolCodeDiff'
|
||||
import { toolCodeDiffLanguage } from './toolCodeDiffLanguage'
|
||||
import { highlightedSourceLines } from './toolCodeDiffHighlight'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
|
||||
interface Props {
|
||||
diff: ToolCodeDiff
|
||||
streaming?: boolean
|
||||
diffLines?: ToolDiffLine[]
|
||||
}
|
||||
|
||||
let { diff, streaming = false, diffLines }: Props = $props()
|
||||
|
||||
let expansions = $state<Map<string, number>>(new Map())
|
||||
const diffRows = $derived(diffLines ?? toolDiffLines(diff, streaming))
|
||||
const visibleRows = $derived(visibleToolDiffRows(diffRows, expansions))
|
||||
const language = $derived(toolCodeDiffLanguage(diff.lang))
|
||||
const lastLines = $derived.by(() => {
|
||||
let old = 0
|
||||
let current = 0
|
||||
for (const row of diffRows) {
|
||||
if (row.oldLine) old = Math.max(old, row.oldLine)
|
||||
if (row.newLine) current = Math.max(current, row.newLine)
|
||||
}
|
||||
return { old, current }
|
||||
})
|
||||
// Both number columns fit the longest line number, so it never runs into the +/- column.
|
||||
const lineNumberWidth = $derived(
|
||||
`max(0.875rem, ${String(Math.max(lastLines.old, lastLines.current)).length}ch)`
|
||||
)
|
||||
const highlighted = $derived.by(() => {
|
||||
if (!hljs.getLanguage(language.name)) hljs.registerLanguage(language.name, language.register)
|
||||
|
||||
return {
|
||||
original: highlightedSourceLines(diff.before, language.name, lastLines.old),
|
||||
modified: highlightedSourceLines(diff.after, language.name, lastLines.current)
|
||||
}
|
||||
})
|
||||
|
||||
function highlightedLine(row: ToolDiffLine): string {
|
||||
const source = row.kind === 'removed' ? highlighted.original : highlighted.modified
|
||||
const lineNumber = row.kind === 'removed' ? row.oldLine : row.newLine
|
||||
return lineNumber === undefined ? '' : (source[lineNumber - 1] ?? '')
|
||||
}
|
||||
|
||||
function expand(key: string): void {
|
||||
expansions = new Map(expansions).set(key, (expansions.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Tab stops are measured from the line start, so a range's width is its end column minus its
|
||||
// start column, never the width of the range text on its own. Counts UTF-16 units, the unit
|
||||
// of Monaco's ranges and of `slice`, so an emoji ahead of a change does not shift it.
|
||||
function displayColumns(content: string): number {
|
||||
let columns = 0
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
columns += content[index] === '\t' ? 4 - (columns % 4) : 1
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
function rangeStyle(content: string, range: CharacterRange): string {
|
||||
const left = displayColumns(content.slice(0, range.start))
|
||||
const width = range.extendsToEnd
|
||||
? 'calc(100% - ' + left + 'ch)'
|
||||
: displayColumns(content.slice(0, range.start + range.length)) - left + 'ch'
|
||||
return 'left: ' + left + 'ch; width: ' + width
|
||||
}
|
||||
</script>
|
||||
|
||||
<HighlightTheme />
|
||||
|
||||
<div
|
||||
class="tool-code-diff max-h-[400px] overflow-auto bg-surface-tertiary text-xs leading-[18px] text-primary"
|
||||
style="--line-number: {lineNumberWidth}"
|
||||
>
|
||||
{#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)}
|
||||
{#if row.kind === 'collapsed' || row.kind === 'omitted'}
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
btnClasses="grid min-h-7 w-full grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)] items-center gap-0 rounded-none bg-surface-secondary px-0 py-1 text-left text-xs font-normal leading-[18px] text-hint hover:bg-surface-hover"
|
||||
onclick={() => expand(row.key)}
|
||||
>
|
||||
<span class="text-right">...</span>
|
||||
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem]">
|
||||
<span></span><span class="text-right">...</span><span>...</span>
|
||||
</span>
|
||||
<span>{row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'}</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<div
|
||||
class="diff-line-{row.kind} grid grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)]"
|
||||
>
|
||||
<span class="text-right text-hint">{row.oldLine ?? ''}</span>
|
||||
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem] text-hint">
|
||||
<span></span><span class="text-right">{row.newLine ?? ''}</span><span
|
||||
>{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="relative min-w-0 whitespace-pre {row.kind === 'added'
|
||||
? 'bg-green-500/20'
|
||||
: row.kind === 'removed'
|
||||
? 'bg-red-500/20'
|
||||
: ''}"
|
||||
>
|
||||
{#each row.changedRanges ?? [] as range}
|
||||
<span
|
||||
class="pointer-events-none absolute top-0 z-0 h-[18px] {row.kind === 'added'
|
||||
? 'bg-green-500/25'
|
||||
: 'bg-red-500/20'}"
|
||||
style={rangeStyle(row.content, range)}
|
||||
></span>
|
||||
{/each}
|
||||
<span class="relative z-[1]">{@html highlightedLine(row) || ' '}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-code-diff {
|
||||
font-family: Menlo, Monaco, 'Courier New', monospace;
|
||||
/* Must match the tab stops `displayColumns` uses to place intraline highlights. */
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.diff-line-added > span:nth-child(-n + 2),
|
||||
.diff-line-removed > span:nth-child(-n + 2) {
|
||||
@apply text-hint;
|
||||
}
|
||||
|
||||
.tool-code-diff :global(.hljs-keyword),
|
||||
.tool-code-diff :global(.hljs-literal),
|
||||
.tool-code-diff :global(.hljs-built_in) {
|
||||
@apply text-blue-700;
|
||||
}
|
||||
|
||||
.tool-code-diff :global(.hljs-string) {
|
||||
@apply text-red-700;
|
||||
}
|
||||
|
||||
.tool-code-diff :global(.hljs-title.class_),
|
||||
.tool-code-diff :global(.hljs-type) {
|
||||
@apply text-cyan-800;
|
||||
}
|
||||
|
||||
.tool-code-diff :global(.hljs-title.function_) {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.tool-code-diff :global(.hljs-attr),
|
||||
.tool-code-diff :global(.hljs-subst) {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.tool-code-diff :global(.hljs-number) {
|
||||
@apply text-green-700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import type { ToolDisplayMessage } from './shared'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import ToolCodeDiffView from './ToolCodeDiffView.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import {
|
||||
argumentsCarryWholeFile,
|
||||
diffLineCounts,
|
||||
toolCodeDiff,
|
||||
toolDiffLineCounts,
|
||||
toolDiffLines
|
||||
} from './toolCodeDiff'
|
||||
|
||||
interface Props {
|
||||
message: ToolDisplayMessage
|
||||
}
|
||||
|
||||
let { message }: Props = $props()
|
||||
|
||||
const diff = $derived(toolCodeDiff(message))
|
||||
const isStreaming = $derived(Boolean(message.isStreamingArguments))
|
||||
const hasDiff = $derived(Boolean(diff && (diff.before !== '' || diff.after !== '')))
|
||||
// The card body is a bordered box: opened with nothing in it, it draws an empty line.
|
||||
const hasBody = $derived(hasDiff || Boolean(message.error))
|
||||
|
||||
// Keyed by call id: a bare flag would carry the expansion onto the next message that
|
||||
// reuses this instance. An active or failed call opens by itself, and remains open
|
||||
// when it settles so the new diff does not disappear under the user. A call whose arguments
|
||||
// are a whole file (`write_script`, full-code `edit_code`) opens only on failure. The key
|
||||
// also changes on failure, so a collapse made while running cannot hide the error.
|
||||
const toggleKey = $derived(`${message.tool_call_id}:${message.error ? 'error' : ''}`)
|
||||
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
|
||||
const opensByDefault = $derived(
|
||||
Boolean(
|
||||
message.error ||
|
||||
(hasDiff &&
|
||||
!argumentsCarryWholeFile(message) &&
|
||||
(message.isLoading || message.isQueued || message.isStreamingArguments))
|
||||
)
|
||||
)
|
||||
$effect(() => {
|
||||
if (opensByDefault && toggled?.id !== toggleKey) {
|
||||
toggled = { id: toggleKey, open: true }
|
||||
}
|
||||
})
|
||||
const expanded = $derived(
|
||||
hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)
|
||||
)
|
||||
const lines = $derived(diff && expanded ? toolDiffLines(diff, isStreaming) : undefined)
|
||||
const counts = $derived(
|
||||
diff && expanded
|
||||
? isStreaming
|
||||
? diffLineCounts(diff, true)
|
||||
: toolDiffLineCounts(lines ?? [])
|
||||
: undefined
|
||||
)
|
||||
|
||||
const isRunning = $derived(Boolean(message.isLoading && !message.needsConfirmation))
|
||||
const showPreviewChip = $derived(
|
||||
Boolean(message.previewCard && !message.isLoading && !message.error)
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet headerRight()}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if counts && (counts.added > 0 || counts.removed > 0)}
|
||||
<span class="font-mono text-2xs">
|
||||
<span class="text-green-600 dark:text-green-400">+{counts.added}</span>
|
||||
<span class="text-red-600 dark:text-red-400">−{counts.removed}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if showPreviewChip && message.previewCard}
|
||||
<ToolPreviewCard card={message.previewCard} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<ChatCollapsibleCard
|
||||
label={message.content}
|
||||
{expanded}
|
||||
onToggle={() => (toggled = { id: toggleKey, open: !expanded })}
|
||||
toggleable={hasBody}
|
||||
shimmer={isRunning}
|
||||
class={message.isQueued && !message.error
|
||||
? 'opacity-60 hover:opacity-100 transition-opacity'
|
||||
: ''}
|
||||
labelClass="truncate"
|
||||
contentClass="p-0 overflow-hidden space-y-0"
|
||||
{headerRight}
|
||||
>
|
||||
{#if diff && hasDiff && expanded}
|
||||
<ToolCodeDiffView {diff} diffLines={lines} streaming={isStreaming} />
|
||||
{/if}
|
||||
{#if message.error}
|
||||
<div
|
||||
class="px-3 py-2 text-2xs text-red-600 dark:text-red-400 whitespace-pre-wrap break-words {hasDiff
|
||||
? 'border-t border-border-light'
|
||||
: ''}"
|
||||
>
|
||||
{message.error}
|
||||
</div>
|
||||
{/if}
|
||||
</ChatCollapsibleCard>
|
||||
@@ -40,6 +40,8 @@
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
import RunScriptCard from './RunScriptCard.svelte'
|
||||
import ToolDiffCard from './ToolDiffCard.svelte'
|
||||
import { hasToolCodeDiff } from './toolCodeDiff'
|
||||
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte'
|
||||
@@ -132,6 +134,7 @@
|
||||
// A call that inspected a run rather than starting one gets the same card, bound to
|
||||
// the job it named — what happened in a run reads the same either way.
|
||||
const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun))
|
||||
const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName))
|
||||
|
||||
// The preview chip sits on the header row (to the right of the tool-call text);
|
||||
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
|
||||
@@ -170,6 +173,8 @@
|
||||
</div>
|
||||
{:else if isRunCard}
|
||||
<RunScriptCard {message} />
|
||||
{:else if isDiffCard}
|
||||
<ToolDiffCard {message} />
|
||||
{:else if planState}
|
||||
<!-- Same lean shape as a tool call below: a header row that collapses into the
|
||||
transcript, with everything else in one box under it. -->
|
||||
|
||||
@@ -2024,6 +2024,24 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('records a creation diff for a new script draft', async () => {
|
||||
const statuses: any[] = []
|
||||
|
||||
await callGlobalTool(
|
||||
'write_script',
|
||||
{
|
||||
path: 'f/scripts/new-script',
|
||||
language: 'python3',
|
||||
content: 'print("hello")'
|
||||
},
|
||||
{ ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) }
|
||||
)
|
||||
|
||||
expect(statuses).toContainEqual({
|
||||
codeDiff: { before: '', after: 'print("hello")', lang: 'python' }
|
||||
})
|
||||
})
|
||||
|
||||
it('tells a code app from a drag-and-drop app', async () => {
|
||||
vi.mocked(AppService.listApps).mockResolvedValueOnce([
|
||||
{ path: 'f/apps/code', summary: 'Code app', raw_app: true },
|
||||
@@ -3080,12 +3098,17 @@ describe('global AI tools', () => {
|
||||
kind: 'script'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'new summary',
|
||||
language: 'bun',
|
||||
content: 'new content'
|
||||
})
|
||||
const statuses: any[] = []
|
||||
await callGlobalTool(
|
||||
'write_script',
|
||||
{
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'new summary',
|
||||
language: 'bun',
|
||||
content: 'new content'
|
||||
},
|
||||
{ ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) }
|
||||
)
|
||||
|
||||
expect(
|
||||
getBackendDraft<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
|
||||
@@ -3097,6 +3120,9 @@ describe('global AI tools', () => {
|
||||
content: 'new content',
|
||||
language: 'bun'
|
||||
})
|
||||
expect(statuses).toContainEqual({
|
||||
codeDiff: { before: 'old deployed content', after: 'new content', lang: 'typescript' }
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale-draft deploy guard and rebase', () => {
|
||||
|
||||
@@ -137,8 +137,10 @@ import {
|
||||
type RunFormDisplay,
|
||||
type Tool,
|
||||
type ToolCallbacks,
|
||||
type ToolCodeDiff,
|
||||
type ToolDisplayAction
|
||||
} from '../shared'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import { searchDocsTool, readDocsPageTool } from '../docs/core'
|
||||
import { createDbSchemaTool } from '../script/core'
|
||||
import type { ContextElement } from '../context'
|
||||
@@ -5029,10 +5031,12 @@ function finishAppDraftWrite(
|
||||
function finishDraftWrite(
|
||||
result: DraftPersistResult,
|
||||
existed: boolean,
|
||||
ctx: WriteDraftCtx
|
||||
ctx: WriteDraftCtx,
|
||||
codeDiff?: ToolCodeDiff
|
||||
): string {
|
||||
const failure = draftWriteFailure(result, ctx)
|
||||
if (failure) return failure
|
||||
if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff })
|
||||
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
|
||||
maybeAttachPreviewCard(ctx, result.itemKind, result.item.path)
|
||||
const stored = result.item
|
||||
@@ -5078,7 +5082,11 @@ async function writeDraft<T, A>(
|
||||
path: string,
|
||||
args: A,
|
||||
ctx: WriteDraftCtx,
|
||||
opts: { triggerKind?: TriggerKind; override?: boolean } = {}
|
||||
opts: {
|
||||
triggerKind?: TriggerKind
|
||||
override?: boolean
|
||||
codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined)
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const { workspace } = ctx
|
||||
startDraftWrite(ctx, type, path)
|
||||
@@ -5097,7 +5105,8 @@ async function writeDraft<T, A>(
|
||||
triggerKind: opts.triggerKind,
|
||||
force: opts.override
|
||||
})
|
||||
return finishDraftWrite(result, existed, ctx)
|
||||
const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff
|
||||
return finishDraftWrite(result, existed, ctx, codeDiff)
|
||||
}
|
||||
|
||||
type ScriptDraftArgs = {
|
||||
@@ -5150,8 +5159,21 @@ const SCRIPT_SPEC: WriteSpec<NewScript, ScriptDraftArgs> = {
|
||||
}
|
||||
}
|
||||
|
||||
function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise<string> {
|
||||
return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override })
|
||||
function writeScriptDraft(
|
||||
args: ScriptDraftArgs,
|
||||
ctx: WriteDraftCtx,
|
||||
codeDiff?: ToolCodeDiff
|
||||
): Promise<string> {
|
||||
return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, {
|
||||
override: args.override,
|
||||
codeDiff:
|
||||
codeDiff ??
|
||||
((base, draft) => ({
|
||||
before: base?.content ?? '',
|
||||
after: draft.content,
|
||||
lang: scriptLangToEditorLang(draft.language)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
type FlowDraftArgs = {
|
||||
@@ -5368,7 +5390,8 @@ async function editScript(
|
||||
language: base.language,
|
||||
content: updated
|
||||
},
|
||||
ctx
|
||||
ctx,
|
||||
{ before: base.content, after: updated, lang: scriptLangToEditorLang(base.language) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {},
|
||||
languages: {},
|
||||
KeyCode: {},
|
||||
Uri: { parse: (value: string) => ({ toString: () => value }) },
|
||||
MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }
|
||||
}))
|
||||
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
|
||||
getTypeScriptWorker: async () => async () => ({}),
|
||||
typescriptVersion: 'test'
|
||||
}))
|
||||
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
import { editCodeToolWithDiff } from './core'
|
||||
|
||||
describe('editCodeToolWithDiff', () => {
|
||||
it('records the complete script diff after applying replacements', async () => {
|
||||
const statuses: unknown[] = []
|
||||
const applyCode = vi.fn(async () => {})
|
||||
|
||||
await editCodeToolWithDiff.fn({
|
||||
args: { diffs: [{ old_string: 'return 1', new_string: 'return 2' }] },
|
||||
helpers: {
|
||||
getScriptOptions: () => ({
|
||||
code: 'export function main() {\n\treturn 1\n}',
|
||||
lang: 'bun',
|
||||
path: 'f/example',
|
||||
args: {}
|
||||
}),
|
||||
applyCode
|
||||
},
|
||||
toolCallbacks: {
|
||||
setToolStatus: (_toolId, status) => statuses.push(status)
|
||||
},
|
||||
toolId: 'edit-1'
|
||||
})
|
||||
|
||||
expect(applyCode).toHaveBeenCalledTimes(2)
|
||||
expect(statuses.at(-1)).toMatchObject({
|
||||
codeDiff: {
|
||||
before: 'export function main() {\n\treturn 1\n}',
|
||||
after: 'export function main() {\n\treturn 2\n}',
|
||||
lang: 'typescript'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ import type { ReviewChangesOpts } from '../monaco-adapter'
|
||||
import { copilotInfo, getCurrentModel } from '$lib/aiStore'
|
||||
import { get } from 'svelte/store'
|
||||
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts'
|
||||
|
||||
// Score threshold for npm packages search filtering
|
||||
@@ -826,7 +827,12 @@ export const editCodeToolWithDiff: Tool<ScriptChatHelpers> = {
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Code changes applied`,
|
||||
result: 'Success'
|
||||
result: 'Success',
|
||||
codeDiff: {
|
||||
before: oldCode,
|
||||
after: updatedCode,
|
||||
lang: scriptLangToEditorLang(scriptOptions.lang)
|
||||
}
|
||||
})
|
||||
return `Applied changes to the script editor.`
|
||||
} catch (error) {
|
||||
@@ -878,7 +884,12 @@ export const editCodeTool: Tool<ScriptChatHelpers> = {
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Code changes applied',
|
||||
result: 'Success'
|
||||
result: 'Success',
|
||||
codeDiff: {
|
||||
before: oldCode,
|
||||
after: args.code,
|
||||
lang: scriptLangToEditorLang(scriptOptions.lang)
|
||||
}
|
||||
})
|
||||
return 'Code has been applied to the script editor.'
|
||||
} catch (error) {
|
||||
|
||||
@@ -610,6 +610,13 @@ export type WebSearchSource = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type ToolCodeDiff = {
|
||||
before: string
|
||||
after: string
|
||||
/** Monaco language id. */
|
||||
lang: string
|
||||
}
|
||||
|
||||
/** The result shape any tool returns to have it rendered as a web search card. */
|
||||
export type WebSearchResult = {
|
||||
sources: WebSearchSource[]
|
||||
@@ -725,6 +732,9 @@ export type ToolDisplayMessage = {
|
||||
* always-visible card that opens (or focuses) the item's preview in the
|
||||
* session side panel. Set only for session chats — the side panel is their surface. */
|
||||
previewCard?: { kind: PreviewCardKind; path: string }
|
||||
/** Whole text on both sides of an edit this call saved. Kept in full rather than as a
|
||||
* reference: drafts are overwritten in place, so the sides cannot be fetched back later. */
|
||||
codeDiff?: ToolCodeDiff
|
||||
planArtifactId?: string
|
||||
/** The version this card's proposal wrote, so a card scrolled far up still opens the plan
|
||||
* it proposed rather than what the document became. */
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import {
|
||||
argumentsCarryWholeFile,
|
||||
diffLineCounts,
|
||||
hasToolCodeDiff,
|
||||
toolCodeDiff,
|
||||
toolDiffLines,
|
||||
visibleToolDiffRows
|
||||
} from './toolCodeDiff'
|
||||
import { highlightedSourceLines } from './toolCodeDiffHighlight'
|
||||
import { TOOL_CODE_DIFF_LANGUAGES, toolCodeDiffLanguage } from './toolCodeDiffLanguage'
|
||||
import type { ToolDisplayMessage } from './shared'
|
||||
|
||||
function message(overrides: Partial<ToolDisplayMessage>): ToolDisplayMessage {
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: 'call-1',
|
||||
content: 'Updated script "f/example" as a draft',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('toolCodeDiff', () => {
|
||||
it('uses the saved full script sides after an edit succeeds', () => {
|
||||
const codeDiff = {
|
||||
before: 'const before = true\n',
|
||||
after: 'const after = true\n',
|
||||
lang: 'typescript'
|
||||
}
|
||||
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'edit_script',
|
||||
parameters: { old_string: 'before', new_string: 'after' },
|
||||
codeDiff
|
||||
})
|
||||
)
|
||||
).toBe(codeDiff)
|
||||
})
|
||||
|
||||
it('uses edit arguments until the full script sides are available', () => {
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'edit_script',
|
||||
parameters: { old_string: 'before', new_string: 'after' }
|
||||
})
|
||||
)
|
||||
).toEqual({ before: 'before', after: 'after', lang: 'plaintext' })
|
||||
})
|
||||
|
||||
it('renders streamed edit arguments before their JSON object is complete', () => {
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'edit_script',
|
||||
parameters: '{"old_string":"before\\nline","new_string":"after'
|
||||
})
|
||||
)
|
||||
).toEqual({ before: 'before\nline', after: 'after', lang: 'plaintext' })
|
||||
})
|
||||
|
||||
it('uses each replacement from an in-editor edit while its arguments stream', () => {
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'edit_code',
|
||||
parameters: {
|
||||
diffs: [
|
||||
{ old_string: 'first old', new_string: 'first new' },
|
||||
{ old_string: 'second old', new_string: 'second new' }
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
before: 'first old\nsecond old',
|
||||
after: 'first new\nsecond new',
|
||||
lang: 'plaintext'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses each replacement while in-editor edit arguments are incomplete', () => {
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'edit_code',
|
||||
parameters:
|
||||
'{"diffs":[{"old_string":"first old","new_string":"first new"},{"old_string":"second old","new_string":"second new'
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
before: 'first old\nsecond old',
|
||||
after: 'first new\nsecond new',
|
||||
lang: 'plaintext'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a streamed full-code editor update without replacements', () => {
|
||||
expect(
|
||||
toolCodeDiff(message({ toolName: 'edit_code', parameters: '{"code":"console.log(1)' }))
|
||||
).toEqual({ before: '', after: 'console.log(1)', lang: 'plaintext' })
|
||||
})
|
||||
|
||||
it('shows streamed full-script updates before the saved diff is available', () => {
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({ toolName: 'write_script', parameters: '{"content":"print(\\\"hello\\\")' })
|
||||
)
|
||||
).toEqual({ before: '', after: 'print("hello")', lang: 'plaintext' })
|
||||
})
|
||||
|
||||
it('only marks tools with a defined argument diff as diff-capable', () => {
|
||||
expect(hasToolCodeDiff('edit_script')).toBe(true)
|
||||
expect(hasToolCodeDiff('edit_code')).toBe(true)
|
||||
expect(hasToolCodeDiff('write_script')).toBe(true)
|
||||
expect(hasToolCodeDiff('__proto__')).toBe(false)
|
||||
expect(hasToolCodeDiff('toString')).toBe(false)
|
||||
expect(hasToolCodeDiff(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores tool names inherited from Object.prototype', () => {
|
||||
expect(toolCodeDiff(message({ toolName: 'constructor', parameters: {} }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('counts added and removed lines independently', () => {
|
||||
expect(
|
||||
diffLineCounts({
|
||||
before: 'one\ntwo\nthree\n',
|
||||
after: 'one\nnew\nthree\nfour\n',
|
||||
lang: 'plaintext'
|
||||
})
|
||||
).toEqual({
|
||||
added: 2,
|
||||
removed: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a final newline as a diffable line', () => {
|
||||
expect(toolDiffLines({ before: 'one', after: 'one\n', lang: 'plaintext' })).toEqual([
|
||||
{ kind: 'context', content: 'one', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'added', content: '', newLine: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not add a final-newline row when creating or deleting a file', () => {
|
||||
expect(toolDiffLines({ before: '', after: 'one\n', lang: 'plaintext' })).toEqual([
|
||||
{ kind: 'added', content: 'one', newLine: 1, changedRanges: [{ start: 0, length: 3 }] }
|
||||
])
|
||||
expect(toolDiffLines({ before: 'one\n', after: '', lang: 'plaintext' })).toEqual([
|
||||
{ kind: 'removed', content: 'one', oldLine: 1, changedRanges: [{ start: 0, length: 3 }] }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps both gutters correct across removed and added lines', () => {
|
||||
expect(
|
||||
toolDiffLines({
|
||||
before: 'one\ntwo\nthree\n',
|
||||
after: 'one\nnew\nthree\nfour\n',
|
||||
lang: 'plaintext'
|
||||
})
|
||||
).toEqual([
|
||||
{ kind: 'context', content: 'one', oldLine: 1, newLine: 1 },
|
||||
{
|
||||
kind: 'removed',
|
||||
content: 'two',
|
||||
oldLine: 2,
|
||||
newLine: undefined,
|
||||
changedRanges: [{ start: 0, length: 3 }]
|
||||
},
|
||||
{
|
||||
kind: 'added',
|
||||
content: 'new',
|
||||
oldLine: undefined,
|
||||
newLine: 2,
|
||||
changedRanges: [{ start: 0, length: 3 }]
|
||||
},
|
||||
{ kind: 'context', content: 'three', oldLine: 3, newLine: 3 },
|
||||
{
|
||||
kind: 'added',
|
||||
content: 'four',
|
||||
oldLine: undefined,
|
||||
newLine: 4,
|
||||
changedRanges: [{ start: 0, length: 4 }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps unchanged trailing lines out of the change counts', () => {
|
||||
const diff = { before: 'one\ntwo\nthree', after: 'one\nnew\nthree', lang: 'plaintext' }
|
||||
|
||||
expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 })
|
||||
expect(toolDiffLines(diff).filter((line) => line.content === 'three')).toEqual([
|
||||
{ kind: 'context', content: 'three', oldLine: 3, newLine: 3 }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report changes for identical files', () => {
|
||||
const diff = { before: 'one\ntwo\nthree', after: 'one\ntwo\nthree', lang: 'plaintext' }
|
||||
|
||||
expect(diffLineCounts(diff)).toEqual({ added: 0, removed: 0 })
|
||||
expect(toolDiffLines(diff)).toEqual([
|
||||
{ kind: 'context', content: 'one', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'context', content: 'two', oldLine: 2, newLine: 2 },
|
||||
{ kind: 'context', content: 'three', oldLine: 3, newLine: 3 }
|
||||
])
|
||||
})
|
||||
|
||||
it('renders a newly created file', () => {
|
||||
expect(toolDiffLines({ before: '', after: 'print("hello")', lang: 'python' })).toEqual([
|
||||
{
|
||||
kind: 'added',
|
||||
content: 'print("hello")',
|
||||
oldLine: undefined,
|
||||
newLine: 1,
|
||||
changedRanges: [{ start: 0, length: 14 }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('renders a deleted file', () => {
|
||||
expect(toolDiffLines({ before: 'print("goodbye")', after: '', lang: 'python' })).toEqual([
|
||||
{
|
||||
kind: 'removed',
|
||||
content: 'print("goodbye")',
|
||||
oldLine: 1,
|
||||
newLine: undefined,
|
||||
changedRanges: [{ start: 0, length: 16 }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('uses Monaco line alignment for reordered lines', () => {
|
||||
expect(
|
||||
toolDiffLines({
|
||||
before: 'const a = 1\nconst b = 2',
|
||||
after: 'const b = 2\nconst a = 1',
|
||||
lang: 'typescript'
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'added',
|
||||
content: 'const b = 2',
|
||||
newLine: 1,
|
||||
changedRanges: [{ start: 0, length: 11, extendsToEnd: true }]
|
||||
},
|
||||
{ kind: 'context', content: 'const a = 1', oldLine: 1, newLine: 2 },
|
||||
{
|
||||
kind: 'removed',
|
||||
content: 'const b = 2',
|
||||
oldLine: 2,
|
||||
changedRanges: [{ start: 0, length: 11 }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('avoids expensive alignment while arguments stream', () => {
|
||||
const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n')
|
||||
const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n')
|
||||
|
||||
expect(toolDiffLines({ before, after, lang: 'plaintext' }, true)).toHaveLength(400)
|
||||
expect(diffLineCounts({ before, after, lang: 'plaintext' }, true)).toEqual({
|
||||
added: 1_000,
|
||||
removed: 1_000
|
||||
})
|
||||
})
|
||||
|
||||
it('diffs a small edit in a large file', () => {
|
||||
const beforeLines = Array.from({ length: 600 }, (_, index) => `line ${index}`)
|
||||
const afterLines = beforeLines.map((line, index) => (index === 400 ? 'changed' : line))
|
||||
const diff = { before: beforeLines.join('\n'), after: afterLines.join('\n'), lang: 'plaintext' }
|
||||
const lines = toolDiffLines(diff)
|
||||
|
||||
expect(lines.filter((line) => line.kind !== 'context')).toMatchObject([
|
||||
{ kind: 'removed', content: 'line 400', oldLine: 401 },
|
||||
{ kind: 'added', content: 'changed', newLine: 401 }
|
||||
])
|
||||
expect(lines[399]).toEqual({ kind: 'context', content: 'line 399', oldLine: 400, newLine: 400 })
|
||||
expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 })
|
||||
})
|
||||
|
||||
it('reports a large rewrite as a whole-file change', () => {
|
||||
const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n')
|
||||
const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n')
|
||||
|
||||
expect(diffLineCounts({ before, after, lang: 'plaintext' })).toEqual({
|
||||
added: 1_000,
|
||||
removed: 1_000
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an empty streamed replacement paired with its own edit', () => {
|
||||
const parameters =
|
||||
'{"diffs":[{"old_string":"a","new_string":""},{"old_string":"b","new_string":"c"}]}'
|
||||
|
||||
expect(toolCodeDiff(message({ toolName: 'edit_code', parameters }))).toEqual({
|
||||
before: 'a\nb',
|
||||
after: '\nc',
|
||||
lang: 'plaintext'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes calls whose arguments are a whole file', () => {
|
||||
const call = (
|
||||
toolName: string,
|
||||
parameters: unknown,
|
||||
codeDiff?: ToolDisplayMessage['codeDiff']
|
||||
) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff }))
|
||||
|
||||
expect(call('write_script', { path: 'f/a', content: '' })).toBe(true)
|
||||
expect(call('edit_code', '{"code":"x')).toBe(true)
|
||||
expect(
|
||||
call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' })
|
||||
).toBe(true)
|
||||
expect(call('edit_code', { diffs: [{ old_string: 'a', new_string: 'b' }] })).toBe(false)
|
||||
expect(call('edit_script', { old_string: 'a', new_string: 'b' })).toBe(false)
|
||||
})
|
||||
|
||||
it('shows each row of a small edit once', () => {
|
||||
const lines = toolDiffLines({ before: 'a\nb\nc\n', after: 'a\nB\nc\n', lang: 'plaintext' })
|
||||
|
||||
expect(visibleToolDiffRows(lines, new Map())).toEqual(lines)
|
||||
expect(lines.map((line) => line.kind)).toEqual(['context', 'removed', 'added', 'context'])
|
||||
})
|
||||
|
||||
it('bounds the rows of a large rewrite', () => {
|
||||
const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n')
|
||||
const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n')
|
||||
const lines = toolDiffLines({ before, after, lang: 'plaintext' })
|
||||
const rows = visibleToolDiffRows(lines, new Map())
|
||||
|
||||
expect(rows).toHaveLength(802)
|
||||
expect(rows[400]).toEqual({ kind: 'omitted', key: 'removed:0', count: 600 })
|
||||
expect(rows[401]).toMatchObject({ kind: 'added', newLine: 1 })
|
||||
expect(rows[801]).toEqual({ kind: 'omitted', key: 'added:1000', count: 600 })
|
||||
|
||||
// One click on a run reveals 400 more rows; the overall cap still holds until `end` is clicked.
|
||||
const expanded = visibleToolDiffRows(lines, new Map([['removed:0', 1]]))
|
||||
expect(expanded).toHaveLength(1_001)
|
||||
expect(expanded[800]).toEqual({ kind: 'omitted', key: 'removed:0', count: 200 })
|
||||
expect(expanded[1_000]).toEqual({ kind: 'omitted', key: 'end', count: 801 })
|
||||
expect(
|
||||
visibleToolDiffRows(
|
||||
lines,
|
||||
new Map([
|
||||
['removed:0', 1],
|
||||
['end', 1]
|
||||
])
|
||||
)
|
||||
).toHaveLength(1_202)
|
||||
})
|
||||
|
||||
it('highlights each line within the scope of the whole source', () => {
|
||||
const typescript = toolCodeDiffLanguage('typescript')
|
||||
hljs.registerLanguage(typescript.name, typescript.register)
|
||||
const code = 'const s = `a\nb`\nconst n = 1\n'
|
||||
|
||||
const lines = highlightedSourceLines(code, typescript.name)
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0]).toBe(
|
||||
'<span class="hljs-keyword">const</span> s = <span class="hljs-string">`a</span>'
|
||||
)
|
||||
expect(lines[1]).toMatch(/^<span class="hljs-string">b`<\/span>/)
|
||||
expect(highlightedSourceLines(code, typescript.name, 2)).toEqual(lines.slice(0, 2))
|
||||
expect(highlightedSourceLines('a\nb', typescript.name)).toEqual(['a', 'b'])
|
||||
expect(highlightedSourceLines('a\nb\n', typescript.name)).toEqual(['a', 'b'])
|
||||
expect(highlightedSourceLines('', typescript.name)).toEqual([])
|
||||
|
||||
const large = Array.from({ length: 2_001 }, () => 'const a = 1 < 2').join('\n') + '\n'
|
||||
const plain = highlightedSourceLines(large, typescript.name)
|
||||
expect(plain).toHaveLength(2_001)
|
||||
expect(plain[0]).toBe('const a = 1 < 2')
|
||||
|
||||
const minified = highlightedSourceLines('x'.repeat(100_001), typescript.name)
|
||||
expect(minified).toEqual(['x'.repeat(10_000) + '…'])
|
||||
})
|
||||
|
||||
it('has a highlighter for every supported editor language', () => {
|
||||
expect(TOOL_CODE_DIFF_LANGUAGES).toEqual(
|
||||
expect.arrayContaining([
|
||||
'typescript',
|
||||
'javascript',
|
||||
'python',
|
||||
'json',
|
||||
'yaml',
|
||||
'sql',
|
||||
'shell',
|
||||
'powershell',
|
||||
'php',
|
||||
'rust',
|
||||
'graphql',
|
||||
'csharp',
|
||||
'nu',
|
||||
'java',
|
||||
'r',
|
||||
'go',
|
||||
'ruby',
|
||||
'plaintext'
|
||||
])
|
||||
)
|
||||
for (const language of TOOL_CODE_DIFF_LANGUAGES) {
|
||||
const highlighter = toolCodeDiffLanguage(language)
|
||||
hljs.registerLanguage(highlighter.name, highlighter.register)
|
||||
expect(() =>
|
||||
hljs.highlight('const changed = true', { language: highlighter.name })
|
||||
).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
// This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges.
|
||||
import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer'
|
||||
import type { ToolCodeDiff, ToolDisplayMessage } from './shared'
|
||||
|
||||
// Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not
|
||||
// exist: while the arguments stream, when the call failed, and for transcripts saved before
|
||||
// calls recorded one. Argument diffs have no language or surrounding lines.
|
||||
const ARGS_DIFF_BY_TOOL: Record<string, (params: unknown) => ToolCodeDiff | undefined> = {
|
||||
edit_script: (params) => argumentDiff(streamingEditArguments(params)),
|
||||
write_script: (params) => fullContentArgumentDiff(params, 'content'),
|
||||
edit_code: (params) => {
|
||||
if (
|
||||
params &&
|
||||
typeof params === 'object' &&
|
||||
Array.isArray((params as { diffs?: unknown }).diffs)
|
||||
) {
|
||||
const diffs = (params as { diffs: unknown[] }).diffs
|
||||
return argumentDiffs(diffs.map(streamingEditArguments))
|
||||
}
|
||||
if (typeof params === 'string') {
|
||||
return (
|
||||
argumentDiffs(streamingEditArgumentsArray(params)) ??
|
||||
fullContentArgumentDiff(params, 'code')
|
||||
)
|
||||
}
|
||||
return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code')
|
||||
}
|
||||
}
|
||||
|
||||
function fullContentArgumentDiff(params: unknown, key: string): ToolCodeDiff | undefined {
|
||||
const content =
|
||||
params && typeof params === 'object'
|
||||
? (params as Record<string, unknown>)[key]
|
||||
: typeof params === 'string'
|
||||
? partialJsonString(params, key)
|
||||
: undefined
|
||||
return typeof content === 'string' ? { before: '', after: content, lang: 'plaintext' } : undefined
|
||||
}
|
||||
|
||||
function argumentDiff(
|
||||
edit: { old_string?: string; new_string?: string } | undefined
|
||||
): ToolCodeDiff | undefined {
|
||||
return typeof edit?.old_string === 'string' || typeof edit?.new_string === 'string'
|
||||
? {
|
||||
before: typeof edit.old_string === 'string' ? edit.old_string : '',
|
||||
after: typeof edit.new_string === 'string' ? edit.new_string : '',
|
||||
lang: 'plaintext'
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
function argumentDiffs(
|
||||
edits: ({ old_string?: string; new_string?: string } | undefined)[]
|
||||
): ToolCodeDiff | undefined {
|
||||
const diffs = edits.map(argumentDiff).filter((diff): diff is ToolCodeDiff => diff !== undefined)
|
||||
if (diffs.length === 0) return undefined
|
||||
return {
|
||||
before: diffs.map((diff) => diff.before).join('\n'),
|
||||
after: diffs.map((diff) => diff.after).join('\n'),
|
||||
lang: 'plaintext'
|
||||
}
|
||||
}
|
||||
|
||||
function streamingEditArguments(
|
||||
params: unknown
|
||||
): { old_string?: string; new_string?: string } | undefined {
|
||||
if (params && typeof params === 'object')
|
||||
return params as { old_string?: string; new_string?: string }
|
||||
if (typeof params !== 'string') return undefined
|
||||
|
||||
return {
|
||||
old_string: partialJsonString(params, 'old_string'),
|
||||
new_string: partialJsonString(params, 'new_string')
|
||||
}
|
||||
}
|
||||
|
||||
function streamingEditArgumentsArray(
|
||||
partialJson: string
|
||||
): { old_string?: string; new_string?: string }[] {
|
||||
const oldStrings = partialJsonStrings(partialJson, 'old_string')
|
||||
const newStrings = partialJsonStrings(partialJson, 'new_string')
|
||||
return Array.from({ length: Math.max(oldStrings.length, newStrings.length) }, (_, index) => ({
|
||||
old_string: oldStrings[index],
|
||||
new_string: newStrings[index]
|
||||
}))
|
||||
}
|
||||
|
||||
function partialJsonString(partialJson: string, key: string): string | undefined {
|
||||
return partialJsonStrings(partialJson, key)[0]
|
||||
}
|
||||
|
||||
function partialJsonStrings(partialJson: string, key: string): string[] {
|
||||
const matches = partialJson.matchAll(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'g'))
|
||||
const result: string[] = []
|
||||
// An empty string is a real value (a deletion): skipping it would pair the next edit's
|
||||
// `new_string` with this edit's `old_string`.
|
||||
for (const match of matches) {
|
||||
try {
|
||||
result.push(JSON.parse(`"${match[1]}"`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function hasToolCodeDiff(toolName: string | undefined): boolean {
|
||||
return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName)
|
||||
}
|
||||
|
||||
export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined {
|
||||
if (message.codeDiff) return message.codeDiff
|
||||
if (!message.toolName) return undefined
|
||||
return Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName)
|
||||
? ARGS_DIFF_BY_TOOL[message.toolName](message.parameters)
|
||||
: undefined
|
||||
}
|
||||
|
||||
// Decided by tool and argument shape, never by the saved `codeDiff`: an overwrite's saved diff
|
||||
// has a before side, and clearing a script leaves both argument sides empty, yet both calls sent
|
||||
// a whole file.
|
||||
export function argumentsCarryWholeFile(message: ToolDisplayMessage): boolean {
|
||||
if (message.toolName === 'write_script') return true
|
||||
if (message.toolName !== 'edit_code') return false
|
||||
const params = message.parameters
|
||||
if (typeof params === 'string') return /"code"\s*:/.test(params)
|
||||
return Boolean(params && typeof params === 'object' && 'code' in params)
|
||||
}
|
||||
|
||||
export function diffLineCounts(
|
||||
diff: ToolCodeDiff,
|
||||
streaming = false
|
||||
): { added: number; removed: number } {
|
||||
if (streaming) {
|
||||
return { added: lines(diff.after).length, removed: lines(diff.before).length }
|
||||
}
|
||||
|
||||
return toolDiffLineCounts(toolDiffLines(diff))
|
||||
}
|
||||
|
||||
export function toolDiffLineCounts(lines: ToolDiffLine[]): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const line of lines) {
|
||||
if (line.kind === 'added') added++
|
||||
else if (line.kind === 'removed') removed++
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
export type ToolDiffLine = {
|
||||
kind: 'context' | 'added' | 'removed'
|
||||
content: string
|
||||
oldLine?: number
|
||||
newLine?: number
|
||||
changedRanges?: CharacterRange[]
|
||||
}
|
||||
|
||||
export type CharacterRange = { start: number; length: number; extendsToEnd?: boolean }
|
||||
|
||||
const STREAMING_PREVIEW_LINES = 200
|
||||
|
||||
function lines(value: string): string[] {
|
||||
const result = value.split('\n')
|
||||
if (result.at(-1) === '') result.pop()
|
||||
return result
|
||||
}
|
||||
|
||||
function hasFinalNewline(value: string): boolean {
|
||||
return value.endsWith('\n')
|
||||
}
|
||||
|
||||
export function toolDiffLines(diff: ToolCodeDiff, streaming = false): ToolDiffLine[] {
|
||||
const before = lines(diff.before)
|
||||
const after = lines(diff.after)
|
||||
if (streaming) return streamingDiffLines(before, after)
|
||||
|
||||
const result: ToolDiffLine[] = []
|
||||
const monacoBefore = monacoLines(diff.before)
|
||||
const monacoAfter = monacoLines(diff.after)
|
||||
// Runs on the UI thread. Cost follows the amount changed, not file size: only a large
|
||||
// rewrite reaches the cap, and Monaco then reports the whole file as one change.
|
||||
const changes = new DefaultLinesDiffComputer().computeDiff(monacoBefore, monacoAfter, {
|
||||
ignoreTrimWhitespace: false,
|
||||
maxComputationTimeMs: 200,
|
||||
computeMoves: false,
|
||||
extendToSubwords: false
|
||||
}).changes
|
||||
const removedRanges = new Map<number, CharacterRange[]>()
|
||||
const addedRanges = new Map<number, CharacterRange[]>()
|
||||
for (const change of changes) {
|
||||
for (const innerChange of change.innerChanges ?? []) {
|
||||
addCharacterRanges(removedRanges, monacoBefore, innerChange.originalRange)
|
||||
addCharacterRanges(addedRanges, monacoAfter, innerChange.modifiedRange)
|
||||
}
|
||||
}
|
||||
|
||||
let oldIndex = 0
|
||||
let newIndex = 0
|
||||
for (const change of changes) {
|
||||
const oldStart = change.original.startLineNumber - 1
|
||||
const newStart = change.modified.startLineNumber - 1
|
||||
appendContextLines(
|
||||
result,
|
||||
before,
|
||||
after,
|
||||
oldIndex,
|
||||
newIndex,
|
||||
Math.min(oldStart - oldIndex, newStart - newIndex)
|
||||
)
|
||||
appendChangedLines(
|
||||
result,
|
||||
'removed',
|
||||
before,
|
||||
oldStart,
|
||||
change.original.endLineNumberExclusive - 1,
|
||||
removedRanges
|
||||
)
|
||||
appendChangedLines(
|
||||
result,
|
||||
'added',
|
||||
after,
|
||||
newStart,
|
||||
change.modified.endLineNumberExclusive - 1,
|
||||
addedRanges
|
||||
)
|
||||
oldIndex = Math.min(change.original.endLineNumberExclusive - 1, before.length)
|
||||
newIndex = Math.min(change.modified.endLineNumberExclusive - 1, after.length)
|
||||
}
|
||||
const trailingContext = Math.max(0, Math.min(before.length - oldIndex, after.length - newIndex))
|
||||
appendContextLines(result, before, after, oldIndex, newIndex, trailingContext)
|
||||
oldIndex += trailingContext
|
||||
newIndex += trailingContext
|
||||
appendChangedLines(result, 'removed', before, oldIndex, before.length, removedRanges)
|
||||
appendChangedLines(result, 'added', after, newIndex, after.length, addedRanges)
|
||||
if (
|
||||
diff.before !== '' &&
|
||||
diff.after !== '' &&
|
||||
hasFinalNewline(diff.before) !== hasFinalNewline(diff.after)
|
||||
) {
|
||||
result.push(
|
||||
hasFinalNewline(diff.before)
|
||||
? { kind: 'removed', content: '', oldLine: before.length + 1 }
|
||||
: { kind: 'added', content: '', newLine: after.length + 1 }
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export type VisibleToolDiffRow =
|
||||
| ToolDiffLine
|
||||
| { kind: 'collapsed'; key: string; count: number }
|
||||
| { kind: 'omitted'; key: string; count: number }
|
||||
|
||||
const CONTEXT_LINES = 3
|
||||
// Every visible row is a DOM row with no virtualization, and only context collapses: a whole-file
|
||||
// rewrite would otherwise render every line of both sides. Two capped runs fit under the overall
|
||||
// cap, so a rewrite still shows its added side. Each expansion of an omitted row reveals one more
|
||||
// cap's worth, so no single click renders an unbounded number of rows.
|
||||
const MAX_CHANGED_RUN_ROWS = 400
|
||||
const MAX_VISIBLE_ROWS = 1_000
|
||||
|
||||
// `expansions` counts the clicks on each collapsed or omitted row, by its key.
|
||||
export function visibleToolDiffRows(
|
||||
lines: ToolDiffLine[],
|
||||
expansions: ReadonlyMap<string, number>
|
||||
): VisibleToolDiffRow[] {
|
||||
const result: VisibleToolDiffRow[] = []
|
||||
for (let index = 0; index < lines.length; ) {
|
||||
const start = index
|
||||
const kind = lines[start].kind
|
||||
while (index < lines.length && lines[index].kind === kind) index++
|
||||
const count = index - start
|
||||
|
||||
if (kind !== 'context') {
|
||||
const key = `${kind}:${start}`
|
||||
const shown = MAX_CHANGED_RUN_ROWS * (1 + (expansions.get(key) ?? 0))
|
||||
result.push(...lines.slice(start, Math.min(index, start + shown)))
|
||||
if (count > shown) result.push({ kind: 'omitted', key, count: count - shown })
|
||||
continue
|
||||
}
|
||||
|
||||
const key = `${lines[start].oldLine}:${lines[start].newLine}:${count}`
|
||||
if (count <= CONTEXT_LINES * 2 + 1 || expansions.has(key)) {
|
||||
result.push(...lines.slice(start, index))
|
||||
continue
|
||||
}
|
||||
result.push(...lines.slice(start, start + CONTEXT_LINES))
|
||||
result.push({ kind: 'collapsed', key, count: count - CONTEXT_LINES * 2 })
|
||||
result.push(...lines.slice(index - CONTEXT_LINES, index))
|
||||
}
|
||||
|
||||
const maxRows = MAX_VISIBLE_ROWS * (1 + (expansions.get('end') ?? 0))
|
||||
if (result.length <= maxRows) return result
|
||||
const hidden = result
|
||||
.slice(maxRows)
|
||||
.reduce((total, row) => total + ('count' in row ? row.count : 1), 0)
|
||||
return [...result.slice(0, maxRows), { kind: 'omitted', key: 'end', count: hidden }]
|
||||
}
|
||||
|
||||
function streamingDiffLines(before: string[], after: string[]): ToolDiffLine[] {
|
||||
return [
|
||||
...before
|
||||
.slice(0, STREAMING_PREVIEW_LINES)
|
||||
.map((content, index) => ({ kind: 'removed' as const, content, oldLine: index + 1 })),
|
||||
...after
|
||||
.slice(0, STREAMING_PREVIEW_LINES)
|
||||
.map((content, index) => ({ kind: 'added' as const, content, newLine: index + 1 }))
|
||||
]
|
||||
}
|
||||
|
||||
function monacoLines(value: string): string[] {
|
||||
const result = lines(value)
|
||||
return result.length ? result : ['']
|
||||
}
|
||||
|
||||
function appendContextLines(
|
||||
result: ToolDiffLine[],
|
||||
before: string[],
|
||||
after: string[],
|
||||
oldStart: number,
|
||||
newStart: number,
|
||||
count: number
|
||||
): void {
|
||||
for (let index = 0; index < count; index++) {
|
||||
result.push({
|
||||
kind: 'context',
|
||||
content: before[oldStart + index],
|
||||
oldLine: oldStart + index + 1,
|
||||
newLine: newStart + index + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function appendChangedLines(
|
||||
result: ToolDiffLine[],
|
||||
kind: 'added' | 'removed',
|
||||
source: string[],
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: Map<number, CharacterRange[]>
|
||||
): void {
|
||||
for (let index = start; index < Math.min(end, source.length); index++) {
|
||||
const changedRanges = ranges.get(index + 1)
|
||||
result.push({
|
||||
kind,
|
||||
content: source[index],
|
||||
...(kind === 'added' ? { newLine: index + 1 } : { oldLine: index + 1 }),
|
||||
...(changedRanges?.length ? { changedRanges } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function addCharacterRanges(
|
||||
ranges: Map<number, CharacterRange[]>,
|
||||
lines: string[],
|
||||
range: {
|
||||
startLineNumber: number
|
||||
startColumn: number
|
||||
endLineNumber: number
|
||||
endColumn: number
|
||||
}
|
||||
): void {
|
||||
for (
|
||||
let lineNumber = range.startLineNumber;
|
||||
lineNumber <= Math.min(range.endLineNumber, lines.length);
|
||||
lineNumber++
|
||||
) {
|
||||
const content = lines[lineNumber - 1]
|
||||
const start = lineNumber === range.startLineNumber ? range.startColumn - 1 : 0
|
||||
const extendsToEnd = lineNumber < range.endLineNumber
|
||||
const end = extendsToEnd ? content.length : range.endColumn - 1
|
||||
if (end > start) {
|
||||
ranges.set(lineNumber, [
|
||||
...(ranges.get(lineNumber) ?? []),
|
||||
{ start, length: end - start, ...(extendsToEnd ? { extendsToEnd } : {}) }
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import { escapeHtml } from '$lib/utils'
|
||||
|
||||
// highlight.js runs on the UI thread at roughly 15 ms per 1,000 lines (50 KB), and must see the
|
||||
// whole source for multi-line strings and comments to scope correctly. Larger sides, including
|
||||
// minified one-line sources, render as plain text with each line cut at MAX_PLAIN_LINE_CHARS.
|
||||
const MAX_HIGHLIGHTED_LINES = 2_000
|
||||
const MAX_HIGHLIGHTED_CHARS = 100_000
|
||||
const MAX_PLAIN_LINE_CHARS = 10_000
|
||||
|
||||
// Highlights the whole source before splitting it, so a line inside a multi-line string or
|
||||
// comment keeps that scope. Only the first `lineLimit` lines are highlighted: a streamed source
|
||||
// grows on every delta while the preview shows a bounded prefix.
|
||||
export function highlightedSourceLines(
|
||||
code: string,
|
||||
languageName: string,
|
||||
lineLimit = Infinity
|
||||
): string[] {
|
||||
if (code === '' || lineLimit <= 0) return []
|
||||
const sourceLines = code.split('\n')
|
||||
const truncated = sourceLines.length > lineLimit
|
||||
const shownLines = truncated ? sourceLines.slice(0, lineLimit) : sourceLines
|
||||
const source = truncated ? shownLines.join('\n') : code
|
||||
if (shownLines.length > MAX_HIGHLIGHTED_LINES || source.length > MAX_HIGHLIGHTED_CHARS) {
|
||||
const plain = shownLines.map((line) =>
|
||||
escapeHtml(
|
||||
line.length > MAX_PLAIN_LINE_CHARS ? line.slice(0, MAX_PLAIN_LINE_CHARS) + '…' : line
|
||||
)
|
||||
)
|
||||
if (!truncated && code.endsWith('\n')) plain.pop()
|
||||
return plain
|
||||
}
|
||||
const highlighted = hljs.highlight(source, { language: languageName }).value
|
||||
return splitHighlightedLines(highlighted, !truncated && code.endsWith('\n'))
|
||||
}
|
||||
|
||||
// Returns one entry per source line, closing the spans still open at each line end and
|
||||
// reopening them on the next line so every entry is balanced HTML.
|
||||
export function splitHighlightedLines(highlighted: string, hasFinalNewline: boolean): string[] {
|
||||
const result: string[] = []
|
||||
const openTags: string[] = []
|
||||
let line = ''
|
||||
|
||||
for (const token of highlighted.split(/(<[^>]+>|\n)/)) {
|
||||
if (token === '\n') {
|
||||
result.push(line + '</span>'.repeat(openTags.length))
|
||||
line = openTags.join('')
|
||||
} else {
|
||||
line += token
|
||||
if (token.startsWith('<span')) openTags.push(token)
|
||||
else if (token === '</span>') openTags.pop()
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFinalNewline) result.push(line)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages'
|
||||
import graphql from 'svelte-highlight/languages/graphql'
|
||||
import javascript from 'svelte-highlight/languages/javascript'
|
||||
import php from 'svelte-highlight/languages/php'
|
||||
import python from 'svelte-highlight/languages/python'
|
||||
import powershell from 'svelte-highlight/languages/powershell'
|
||||
import r from 'svelte-highlight/languages/r'
|
||||
import ruby from 'svelte-highlight/languages/ruby'
|
||||
import rust from 'svelte-highlight/languages/rust'
|
||||
import shell from 'svelte-highlight/languages/shell'
|
||||
import sql from 'svelte-highlight/languages/sql'
|
||||
import typescript from 'svelte-highlight/languages/typescript'
|
||||
import yaml from 'svelte-highlight/languages/yaml'
|
||||
|
||||
// Keyed by the editor language `scriptLangToEditorLang` returns, not the script language.
|
||||
// for related places search: ADD_NEW_LANG
|
||||
const LANGUAGE_BY_MONACO: Record<string, LanguageType<string>> = {
|
||||
typescript,
|
||||
javascript,
|
||||
python,
|
||||
json,
|
||||
yaml,
|
||||
sql,
|
||||
shell,
|
||||
powershell,
|
||||
php,
|
||||
rust,
|
||||
graphql,
|
||||
csharp,
|
||||
nu: python,
|
||||
java,
|
||||
r,
|
||||
go,
|
||||
ruby,
|
||||
text: plaintext,
|
||||
plaintext
|
||||
}
|
||||
|
||||
export const TOOL_CODE_DIFF_LANGUAGES = Object.keys(LANGUAGE_BY_MONACO)
|
||||
|
||||
export function toolCodeDiffLanguage(language: string): LanguageType<string> {
|
||||
return LANGUAGE_BY_MONACO[language] ?? plaintext
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
import ToolCodeDiffView from '$lib/components/copilot/chat/ToolCodeDiffView.svelte'
|
||||
import type { ToolCodeDiff } from '$lib/components/copilot/chat/shared'
|
||||
|
||||
type ComparisonCase = {
|
||||
id: string
|
||||
label: string
|
||||
diff: ToolCodeDiff
|
||||
}
|
||||
|
||||
const stableLines = Array.from(
|
||||
{ length: 14 },
|
||||
(_, index) => ` const stableValue${index + 1} = getStableValue(${index + 1})`
|
||||
)
|
||||
|
||||
const cases: ComparisonCase[] = [
|
||||
{
|
||||
id: 'typescript-replacement',
|
||||
label: 'TypeScript replacement',
|
||||
diff: {
|
||||
lang: 'typescript',
|
||||
before: `export function greeting(name: string) {
|
||||
return \`Hello, \${name}\`
|
||||
}`,
|
||||
after: `export function greeting(name: string) {
|
||||
const message = \`Hello, \${name}!\`
|
||||
return message
|
||||
}`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'long-unchanged-sections',
|
||||
label: 'Long unchanged sections',
|
||||
diff: {
|
||||
lang: 'typescript',
|
||||
before: [
|
||||
'export function buildSummary() {',
|
||||
...stableLines,
|
||||
'\treturn formatSummary("draft")',
|
||||
'}'
|
||||
].join('\n'),
|
||||
after: [
|
||||
'export function buildSummary() {',
|
||||
...stableLines,
|
||||
'\treturn formatSummary("published")',
|
||||
'}'
|
||||
].join('\n')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'json-structure',
|
||||
label: 'JSON structure',
|
||||
diff: {
|
||||
lang: 'json',
|
||||
before: `{
|
||||
"name": "daily-report",
|
||||
"schedule": "0 9 * * 1-5",
|
||||
"enabled": false
|
||||
}`,
|
||||
after: `{
|
||||
"name": "daily-report",
|
||||
"schedule": "0 9 * * 1-5",
|
||||
"timezone": "Europe/Paris",
|
||||
"enabled": true
|
||||
}`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'python-add-remove',
|
||||
label: 'Python additions and removals',
|
||||
diff: {
|
||||
lang: 'python',
|
||||
before: `def notify(users):
|
||||
for user in users:
|
||||
send_email(user, "Weekly update")
|
||||
|
||||
return len(users)`,
|
||||
after: `def notify(users):
|
||||
active_users = [user for user in users if user.active]
|
||||
for user in active_users:
|
||||
send_email(user, "Your weekly update")
|
||||
|
||||
return len(active_users)`
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
let activeCaseId = $state(cases[0].id)
|
||||
let activeCase = $derived(cases.find((item) => item.id === activeCaseId) ?? cases[0])
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Diff comparison</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-[1440px] p-6">
|
||||
<div class="mb-5 flex flex-wrap gap-2">
|
||||
{#each cases as item}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
selected={activeCaseId === item.id}
|
||||
aria-pressed={activeCaseId === item.id}
|
||||
onclick={() => (activeCaseId = item.id)}>{item.label}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-2">
|
||||
<section class="min-w-0">
|
||||
<h1 class="mb-2 text-sm font-semibold">Monaco</h1>
|
||||
<div class="h-[400px] overflow-hidden border border-slate-200 bg-white">
|
||||
{#key activeCase.id}
|
||||
<DiffEditor
|
||||
className="h-full"
|
||||
defaultLang={activeCase.diff.lang}
|
||||
defaultModifiedLang={activeCase.diff.lang}
|
||||
defaultOriginal={activeCase.diff.before}
|
||||
defaultModified={activeCase.diff.after}
|
||||
inlineDiff
|
||||
open
|
||||
readOnly
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="min-w-0">
|
||||
<h2 class="mb-2 text-sm font-semibold">Native</h2>
|
||||
<div class="h-[400px] overflow-auto border border-slate-200 bg-white">
|
||||
{#key activeCase.id}
|
||||
<ToolCodeDiffView diff={activeCase.diff} />
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
Reference in New Issue
Block a user