mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
feat(ai-chat): show per-file diffs for raw app edits (#11521)
* feat(ai-chat): show per-file diffs for raw app edits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep reference runnable writes on the generic tool card Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: extend diff row highlights across horizontal overflow 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
4f30982ce3
commit
ec0883a627
@@ -24,6 +24,7 @@
|
||||
const diffRows = $derived(diffLines ?? toolDiffLines(diff, streaming))
|
||||
const visibleRows = $derived(visibleToolDiffRows(diffRows, expansions))
|
||||
const language = $derived(toolCodeDiffLanguage(diff.lang))
|
||||
const beforeLanguage = $derived(toolCodeDiffLanguage(diff.beforeLang ?? diff.lang))
|
||||
const lastLines = $derived.by(() => {
|
||||
let old = 0
|
||||
let current = 0
|
||||
@@ -38,10 +39,12 @@
|
||||
`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)
|
||||
for (const { name, register } of [language, beforeLanguage]) {
|
||||
if (!hljs.getLanguage(name)) hljs.registerLanguage(name, register)
|
||||
}
|
||||
|
||||
return {
|
||||
original: highlightedSourceLines(diff.before, language.name, lastLines.old),
|
||||
original: highlightedSourceLines(diff.before, beforeLanguage.name, lastLines.old),
|
||||
modified: highlightedSourceLines(diff.after, language.name, lastLines.current)
|
||||
}
|
||||
})
|
||||
@@ -82,50 +85,54 @@
|
||||
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'
|
||||
: ''}"
|
||||
<!-- Rows take this wrapper's width, so the longest line sets it: sized to the scroller
|
||||
instead, a row's background stops at the visible edge when a line overflows. -->
|
||||
<div class="w-max min-w-full">
|
||||
{#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)}
|
||||
>
|
||||
{#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}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ToolCodeDiffView from './ToolCodeDiffView.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import {
|
||||
argumentsCarryWholeFile,
|
||||
diffIsWholeFile,
|
||||
diffLineCounts,
|
||||
toolCodeDiff,
|
||||
toolDiffLineCounts,
|
||||
@@ -25,16 +25,17 @@
|
||||
|
||||
// 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.
|
||||
// when it settles so the new diff does not disappear under the user. A call whose diff is a
|
||||
// whole file (`write_script`, full-code `edit_code`, raw app writes and deletes) 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) &&
|
||||
!diffIsWholeFile(message) &&
|
||||
(message.isLoading || message.isQueued || message.isStreamingArguments))
|
||||
)
|
||||
)
|
||||
@@ -43,9 +44,7 @@
|
||||
toggled = { id: toggleKey, open: true }
|
||||
}
|
||||
})
|
||||
const expanded = $derived(
|
||||
hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)
|
||||
)
|
||||
const expanded = $derived(hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault))
|
||||
const lines = $derived(diff && expanded ? toolDiffLines(diff, isStreaming) : undefined)
|
||||
const counts = $derived(
|
||||
diff && expanded
|
||||
|
||||
@@ -141,7 +141,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))
|
||||
const isDiffCard = $derived(hasToolCodeDiff(message))
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -4255,6 +4255,86 @@ describe('global AI tools', () => {
|
||||
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the diff of the one raw app file a call changed', async () => {
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
'u/admin/diffed_app',
|
||||
{
|
||||
summary: 'Diffed',
|
||||
files: { '/index.tsx': 'untouched', '/styles.css': 'a { color: red; }\n' },
|
||||
runnables: {
|
||||
go: {
|
||||
name: 'Go',
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'python3', content: 'x = 1' }
|
||||
},
|
||||
greet: {
|
||||
name: 'Greet',
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export const main = () => 1' }
|
||||
}
|
||||
},
|
||||
data: { tables: [] }
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
const statuses: any[] = []
|
||||
const callbacks = {
|
||||
...toolCallbacks,
|
||||
setToolStatus: (_toolId: string, status: any) => statuses.push(status)
|
||||
}
|
||||
|
||||
await callGlobalTool(
|
||||
'patch_app_file',
|
||||
{
|
||||
path: 'u/admin/diffed_app',
|
||||
file_path: '/styles.css',
|
||||
old_string: 'red',
|
||||
new_string: 'blue',
|
||||
replace_all: false
|
||||
},
|
||||
callbacks
|
||||
)
|
||||
await callGlobalTool(
|
||||
'delete_app_runnable',
|
||||
{ path: 'u/admin/diffed_app', key: 'go' },
|
||||
callbacks
|
||||
)
|
||||
await callGlobalTool(
|
||||
'write_app_runnable',
|
||||
{
|
||||
path: 'u/admin/diffed_app',
|
||||
key: 'ref',
|
||||
runnable: { name: 'Ref', type: 'flow', path: 'u/admin/hello_flow' }
|
||||
},
|
||||
callbacks
|
||||
)
|
||||
await callGlobalTool(
|
||||
'write_app_runnable',
|
||||
{
|
||||
path: 'u/admin/diffed_app',
|
||||
key: 'greet',
|
||||
runnable: {
|
||||
name: 'Greet',
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'python3', content: 'def main(): return 1' }
|
||||
}
|
||||
},
|
||||
callbacks
|
||||
)
|
||||
|
||||
expect(statuses.filter((status) => status.codeDiff).map((status) => status.codeDiff)).toEqual([
|
||||
{ before: 'a { color: red; }\n', after: 'a { color: blue; }\n', lang: 'css' },
|
||||
{ before: 'x = 1', after: '', lang: 'python' },
|
||||
{
|
||||
before: 'export const main = () => 1',
|
||||
after: 'def main(): return 1',
|
||||
lang: 'python',
|
||||
beforeLang: 'typescript'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
|
||||
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
|
||||
path: 'f/apps/report',
|
||||
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
type ToolDisplayAction
|
||||
} from '../shared'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import { appFileEditorLang } from '../toolCodeDiff'
|
||||
import { searchDocsTool, readDocsPageTool } from '../docs/core'
|
||||
import { createDbSchemaTool } from '../script/core'
|
||||
import type { ContextElement } from '../context'
|
||||
@@ -5300,10 +5301,12 @@ function maybeAttachPreviewCard(
|
||||
function finishAppDraftWrite(
|
||||
result: DraftPersistResult,
|
||||
ctx: WriteDraftCtx,
|
||||
onSaved: () => { content: string; message: string; warning?: string }
|
||||
onSaved: () => { content: string; message: string; warning?: string },
|
||||
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 { content, message, warning } = onSaved()
|
||||
@@ -6842,12 +6845,18 @@ async function writeAppFile(
|
||||
})
|
||||
|
||||
const { value } = await loadAppDraftValue(args.path, workspace)
|
||||
const before = value.files[target.filePath] ?? ''
|
||||
value.files = { ...value.files, [target.filePath]: args.content }
|
||||
const result = await saveAppDraft(workspace, args.path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Updated ${target.filePath} in app "${args.path}"`,
|
||||
message: `Updated draft app "${args.path}" with frontend file "${target.filePath}".`
|
||||
}))
|
||||
return finishAppDraftWrite(
|
||||
result,
|
||||
ctx,
|
||||
() => ({
|
||||
content: `Updated ${target.filePath} in app "${args.path}"`,
|
||||
message: `Updated draft app "${args.path}" with frontend file "${target.filePath}".`
|
||||
}),
|
||||
{ before, after: args.content, lang: appFileEditorLang(target.filePath) }
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteAppFile(
|
||||
@@ -6871,13 +6880,18 @@ async function deleteAppFile(
|
||||
if (!(target.filePath in value.files)) {
|
||||
throw new Error(`Frontend file "${target.filePath}" not found in app "${args.path}".`)
|
||||
}
|
||||
const { [target.filePath]: _removed, ...remaining } = value.files
|
||||
const { [target.filePath]: removed, ...remaining } = value.files
|
||||
value.files = remaining
|
||||
const result = await saveAppDraft(workspace, args.path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Removed ${target.filePath} from app "${args.path}"`,
|
||||
message: `Removed "${target.filePath}" from draft app "${args.path}".`
|
||||
}))
|
||||
return finishAppDraftWrite(
|
||||
result,
|
||||
ctx,
|
||||
() => ({
|
||||
content: `Removed ${target.filePath} from app "${args.path}"`,
|
||||
message: `Removed "${target.filePath}" from draft app "${args.path}".`
|
||||
}),
|
||||
{ before: removed, after: '', lang: appFileEditorLang(target.filePath) }
|
||||
)
|
||||
}
|
||||
|
||||
async function patchAppFile(
|
||||
@@ -6948,10 +6962,15 @@ async function patchAppFile(
|
||||
}
|
||||
|
||||
const result = await saveAppDraft(workspace, path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Patched ${target.filePath} in app "${path}"`,
|
||||
message: `Patched "${target.filePath}" in draft app "${path}".`
|
||||
}))
|
||||
return finishAppDraftWrite(
|
||||
result,
|
||||
ctx,
|
||||
() => ({
|
||||
content: `Patched ${target.filePath} in app "${path}"`,
|
||||
message: `Patched "${target.filePath}" in draft app "${path}".`
|
||||
}),
|
||||
{ before: currentContent, after: updated, lang: appFileEditorLang(target.filePath) }
|
||||
)
|
||||
}
|
||||
|
||||
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
|
||||
@@ -6982,15 +7001,48 @@ async function writeAppRunnable(
|
||||
await recomputeAppPolicy(value)
|
||||
const undeployed = await undeployedRunnableTargets(workspace, { [key]: persisted })
|
||||
const result = await saveAppDraft(workspace, path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Updated runnable "${key}" in app "${path}"`,
|
||||
message: `Updated draft app "${path}" with runnable "${key}".`,
|
||||
warning: undeployed.length
|
||||
? `This runnable points at an item that is NOT deployed (${undeployed[0]}), so it fails at runtime — ` +
|
||||
`a path runnable runs the deployed item, never a draft. Offer to deploy just that item with ` +
|
||||
`deploy_workspace_item; the app itself does not need deploying, since the preview runs its draft.`
|
||||
: undefined
|
||||
}))
|
||||
return finishAppDraftWrite(
|
||||
result,
|
||||
ctx,
|
||||
() => ({
|
||||
content: `Updated runnable "${key}" in app "${path}"`,
|
||||
message: `Updated draft app "${path}" with runnable "${key}".`,
|
||||
warning: undeployed.length
|
||||
? `This runnable points at an item that is NOT deployed (${undeployed[0]}), so it fails at runtime — ` +
|
||||
`a path runnable runs the deployed item, never a draft. Offer to deploy just that item with ` +
|
||||
`deploy_workspace_item; the app itself does not need deploying, since the preview runs its draft.`
|
||||
: undefined
|
||||
}),
|
||||
inlineRunnableDiff(key, existing, persisted)
|
||||
)
|
||||
}
|
||||
|
||||
function inlineRunnableCode(runnable: PersistedRunnable | undefined): string | undefined {
|
||||
return runnable?.type === 'inline' || runnable?.type === 'runnableByName'
|
||||
? (runnable.inlineScript?.content ?? '')
|
||||
: undefined
|
||||
}
|
||||
|
||||
// A runnable that references a workspace or hub item has no code of its own to diff.
|
||||
function inlineRunnableDiff(
|
||||
key: string,
|
||||
before: PersistedRunnable | undefined,
|
||||
after: PersistedRunnable | undefined
|
||||
): ToolCodeDiff | undefined {
|
||||
const beforeCode = inlineRunnableCode(before)
|
||||
const afterCode = inlineRunnableCode(after)
|
||||
if (beforeCode === undefined && afterCode === undefined) return undefined
|
||||
if (after !== undefined && afterCode === undefined) return undefined
|
||||
const lang = (runnable: PersistedRunnable | undefined) =>
|
||||
appFileEditorLang(`backend/${key}/main.${getInlineScriptExtension(runnable)}`)
|
||||
const afterLang = lang(after ?? before)
|
||||
const beforeLang = beforeCode === undefined ? afterLang : lang(before)
|
||||
return {
|
||||
before: beforeCode ?? '',
|
||||
after: afterCode ?? '',
|
||||
lang: afterLang,
|
||||
...(beforeLang !== afterLang ? { beforeLang } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7090,14 +7142,19 @@ async function deleteAppRunnable(
|
||||
if (!(key in value.runnables)) {
|
||||
throw new Error(`Backend runnable "${key}" not found in app "${path}".`)
|
||||
}
|
||||
const { [key]: _removed, ...remaining } = value.runnables
|
||||
const { [key]: removed, ...remaining } = value.runnables
|
||||
value.runnables = remaining
|
||||
await recomputeAppPolicy(value)
|
||||
const result = await saveAppDraft(workspace, path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Removed runnable "${key}" from app "${path}"`,
|
||||
message: `Removed runnable "${key}" from draft app "${path}".`
|
||||
}))
|
||||
return finishAppDraftWrite(
|
||||
result,
|
||||
ctx,
|
||||
() => ({
|
||||
content: `Removed runnable "${key}" from app "${path}"`,
|
||||
message: `Removed runnable "${key}" from draft app "${path}".`
|
||||
}),
|
||||
inlineRunnableDiff(key, removed as PersistedRunnable, undefined)
|
||||
)
|
||||
}
|
||||
|
||||
const triggerLabels: Record<TriggerKind, string> = {
|
||||
|
||||
@@ -622,6 +622,9 @@ export type ToolCodeDiff = {
|
||||
after: string
|
||||
/** Monaco language id. */
|
||||
lang: string
|
||||
/** Language of the before side, when the edit changed it (an inline runnable rewritten from
|
||||
* TypeScript to Python). */
|
||||
beforeLang?: string
|
||||
}
|
||||
|
||||
export type ToolDisplayMessage = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import {
|
||||
argumentsCarryWholeFile,
|
||||
diffIsWholeFile,
|
||||
diffLineCounts,
|
||||
hasToolCodeDiff,
|
||||
toolCodeDiff,
|
||||
@@ -113,12 +113,31 @@ describe('toolCodeDiff', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
const call = (toolName: string | undefined, parameters?: unknown) =>
|
||||
hasToolCodeDiff(message({ toolName, parameters }))
|
||||
|
||||
expect(call('edit_script')).toBe(true)
|
||||
expect(call('edit_code')).toBe(true)
|
||||
expect(call('write_script')).toBe(true)
|
||||
expect(call('__proto__')).toBe(false)
|
||||
expect(call('toString')).toBe(false)
|
||||
expect(call(undefined)).toBe(false)
|
||||
expect(
|
||||
hasToolCodeDiff(
|
||||
message({ toolName: 'delete_app_file', codeDiff: { before: 'a', after: '', lang: 'css' } })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a reference runnable write on the generic card', () => {
|
||||
const call = (parameters: unknown) =>
|
||||
hasToolCodeDiff(message({ toolName: 'write_app_runnable', parameters }))
|
||||
|
||||
expect(call('{"path":"f/a","key":"go","runnable":{"name":"Go","type":"in')).toBe(true)
|
||||
expect(call('{"path":"f/a","key":"go","runnable":{"name":"Go"')).toBe(true)
|
||||
expect(call('{"path":"f/a","key":"go","runnable":{"name":"Go","type":"flow"')).toBe(false)
|
||||
expect(call({ path: 'f/a', key: 'go', runnable: { type: 'script', path: 'f/s' } })).toBe(false)
|
||||
expect(call({ path: 'f/a', key: 'go', runnable: { type: 'inline' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores tool names inherited from Object.prototype', () => {
|
||||
@@ -302,14 +321,38 @@ describe('toolCodeDiff', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('highlights streamed raw app edits by file', () => {
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'patch_app_file',
|
||||
parameters: '{"path":"f/a","file_path":"/src/App.tsx","old_string":"a","new_string":"b'
|
||||
})
|
||||
)
|
||||
).toEqual({ before: 'a', after: 'b', lang: 'typescript' })
|
||||
expect(
|
||||
toolCodeDiff(
|
||||
message({
|
||||
toolName: 'write_app_runnable',
|
||||
parameters:
|
||||
'{"path":"f/a","key":"go","runnable":{"name":"Go","type":"inline","inlineScript":{"language":"python3","content":"def main'
|
||||
})
|
||||
)
|
||||
).toEqual({ before: '', after: 'def main', lang: 'python' })
|
||||
})
|
||||
|
||||
it('recognizes calls whose arguments are a whole file', () => {
|
||||
const call = (
|
||||
toolName: string,
|
||||
parameters: unknown,
|
||||
codeDiff?: ToolDisplayMessage['codeDiff']
|
||||
) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff }))
|
||||
) => diffIsWholeFile(message({ toolName, parameters, codeDiff }))
|
||||
|
||||
expect(call('write_script', { path: 'f/a', content: '' })).toBe(true)
|
||||
expect(call('delete_app_file', { path: 'f/a', file_path: '/a.css' })).toBe(true)
|
||||
expect(call('patch_app_file', { file_path: '/a.css', old_string: 'a', new_string: 'b' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(call('edit_code', '{"code":"x')).toBe(true)
|
||||
expect(
|
||||
call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' })
|
||||
|
||||
@@ -24,17 +24,63 @@ const ARGS_DIFF_BY_TOOL: Record<string, (params: unknown) => ToolCodeDiff | unde
|
||||
)
|
||||
}
|
||||
return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code')
|
||||
},
|
||||
patch_app_file: (params) => {
|
||||
const diff = argumentDiff(streamingEditArguments(params))
|
||||
return diff && { ...diff, lang: appFileEditorLang(argumentString(params, 'file_path')) }
|
||||
},
|
||||
write_app_file: (params) => {
|
||||
const diff = fullContentArgumentDiff(params, 'content')
|
||||
return diff && { ...diff, lang: appFileEditorLang(argumentString(params, 'file_path')) }
|
||||
},
|
||||
write_app_runnable: (params) => {
|
||||
const inlineScript =
|
||||
params && typeof params === 'object'
|
||||
? (params as { runnable?: { inlineScript?: unknown } }).runnable?.inlineScript
|
||||
: params
|
||||
const diff = fullContentArgumentDiff(inlineScript, 'content')
|
||||
const language = argumentString(inlineScript, 'language')
|
||||
return (
|
||||
diff && { ...diff, lang: appFileEditorLang(language === 'python3' ? 'main.py' : 'main.ts') }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const LANGUAGE_BY_APP_FILE_EXTENSION: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
mts: 'typescript',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
mjs: 'javascript',
|
||||
py: 'python',
|
||||
css: 'css',
|
||||
html: 'xml',
|
||||
svelte: 'xml',
|
||||
vue: 'xml',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml'
|
||||
}
|
||||
|
||||
// A raw app's backend runnables are named `backend/<key>/main.ts|py`, so their extension
|
||||
// picks the language like any frontend file's.
|
||||
export function appFileEditorLang(filePath: string | undefined): string {
|
||||
const extension = filePath?.split('/').pop()?.split('.').slice(1).pop()?.toLowerCase()
|
||||
return (extension && LANGUAGE_BY_APP_FILE_EXTENSION[extension]) ?? 'plaintext'
|
||||
}
|
||||
|
||||
function argumentString(params: unknown, key: string): string | undefined {
|
||||
if (params && typeof params === 'object') {
|
||||
const value = (params as Record<string, unknown>)[key]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
return typeof params === 'string' ? partialJsonString(params, key) : undefined
|
||||
}
|
||||
|
||||
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
|
||||
const content = argumentString(params, key)
|
||||
return content !== undefined ? { before: '', after: content, lang: 'plaintext' } : undefined
|
||||
}
|
||||
|
||||
function argumentDiff(
|
||||
@@ -104,8 +150,22 @@ function partialJsonStrings(partialJson: string, key: string): string[] {
|
||||
return result
|
||||
}
|
||||
|
||||
export function hasToolCodeDiff(toolName: string | undefined): boolean {
|
||||
return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName)
|
||||
export function hasToolCodeDiff(message: ToolDisplayMessage): boolean {
|
||||
if (message.codeDiff) return true
|
||||
if (!message.toolName || !Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName)) return false
|
||||
// A runnable that references a workspace or hub item has no code to diff, so it keeps the
|
||||
// generic card, whose details show the target and static inputs. Until `type` has streamed
|
||||
// past a prefix of "inline", the call is assumed inline so an inline runnable does not
|
||||
// switch cards mid-stream.
|
||||
if (message.toolName === 'write_app_runnable') {
|
||||
const params = message.parameters
|
||||
const type =
|
||||
params && typeof params === 'object'
|
||||
? (params as { runnable?: { type?: unknown } }).runnable?.type
|
||||
: argumentString(params, 'type')
|
||||
return type === undefined || (typeof type === 'string' && 'inline'.startsWith(type))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined {
|
||||
@@ -116,11 +176,19 @@ export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefi
|
||||
: undefined
|
||||
}
|
||||
|
||||
const WHOLE_FILE_TOOLS = new Set([
|
||||
'write_script',
|
||||
'write_app_file',
|
||||
'write_app_runnable',
|
||||
'delete_app_file',
|
||||
'delete_app_runnable'
|
||||
])
|
||||
|
||||
// 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
|
||||
// a whole file. A delete's diff is the whole removed file, saved while the call still runs.
|
||||
export function diffIsWholeFile(message: ToolDisplayMessage): boolean {
|
||||
if (message.toolName && WHOLE_FILE_TOOLS.has(message.toolName)) return true
|
||||
if (message.toolName !== 'edit_code') return false
|
||||
const params = message.parameters
|
||||
if (typeof params === 'string') return /"code"\s*:/.test(params)
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages'
|
||||
import {
|
||||
csharp,
|
||||
css,
|
||||
go,
|
||||
java,
|
||||
json,
|
||||
plaintext,
|
||||
xml,
|
||||
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'
|
||||
@@ -32,6 +41,8 @@ const LANGUAGE_BY_MONACO: Record<string, LanguageType<string>> = {
|
||||
r,
|
||||
go,
|
||||
ruby,
|
||||
css,
|
||||
xml,
|
||||
text: plaintext,
|
||||
plaintext
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user