Skip to content

feat: allow editing messages in AI chat #6117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 23 additions & 183 deletions frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import AssistantMessage from './AssistantMessage.svelte'
import AIChatMessage from './AIChatMessage.svelte'
import { type Snippet } from 'svelte'
import {
CheckIcon,
HistoryIcon,
Loader2,
Plus,
RefreshCwIcon,
StopCircleIcon,
Undo2Icon,
X,
XIcon
} from 'lucide-svelte'
import autosize from '$lib/autosize'
import { CheckIcon, HistoryIcon, Loader2, Plus, StopCircleIcon, X, XIcon } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ContextElementBadge from './ContextElementBadge.svelte'
import ContextTextarea from './ContextTextarea.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import ChatQuickActions from './ChatQuickActions.svelte'
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ChatMode from './ChatMode.svelte'
import Markdown from 'svelte-exmarkdown'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import AIChatInput from './AIChatInput.svelte'

let {
messages,
Expand Down Expand Up @@ -63,16 +49,8 @@
suggestions?: string[]
} = $props()

let contextTextareaComponent: ContextTextarea | undefined = $state()
let instructionsTextarea: HTMLTextAreaElement | undefined = $state()

export function focusInput() {
if (aiChatManager.mode === 'script') {
contextTextareaComponent?.focus()
} else {
instructionsTextarea?.focus()
}
}
let aiChatInput: AIChatInput | undefined = $state()
let editingMessageIndex = $state<number | null>(null)

let scrollEl: HTMLDivElement | undefined = $state()
async function scrollDown() {
Expand All @@ -87,38 +65,12 @@
aiChatManager.automaticScroll && height && scrollDown()
})

function addContextToSelection(contextElement: ContextElement) {
if (
selectedContext &&
availableContext &&
!selectedContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
) &&
availableContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
)
) {
selectedContext = [...selectedContext, contextElement]
}
}

function submitSuggestion(suggestion: string) {
aiChatManager.instructions = suggestion
aiChatManager.sendRequest()
}

function isLastUserMessage(messageIndex: number): boolean {
// Find the last user message index
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
return i === messageIndex
}
}
return false
aiChatManager.sendRequest({ instructions: suggestion })
}

function restartGeneration(messageIndex: number) {
aiChatManager.restartLastGeneration(messageIndex)
export function focusInput() {
aiChatInput?.focusInput()
}
</script>

Expand Down Expand Up @@ -208,68 +160,13 @@
>
<div class="flex flex-col" bind:clientHeight={height}>
{#each messages as message, messageIndex}
<div class={twMerge(message.role === 'user' && messageIndex > 0 && 'mt-6', 'mb-2')}>
{#if message.role === 'user' && message.contextElements}
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
{#each message.contextElements as element}
<ContextElementBadge contextElement={element} />
{/each}
</div>
{/if}
<div
class={twMerge(
'text-sm py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg relative group',
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
message.role === 'tool' && 'text-tertiary'
)}
>
{#if message.role === 'assistant'}
<AssistantMessage {message} />
{:else}
{message.content}
{/if}

{#if message.role === 'user' && isLastUserMessage(messageIndex) && !aiChatManager.loading}
<div
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
size="xs2"
variant="border"
color="light"
iconOnly
title="Restart generation"
startIcon={{ icon: RefreshCwIcon }}
btnClasses="!p-1 !h-6 !w-6"
on:click={() => restartGeneration(messageIndex)}
/>
</div>
{/if}
</div>
{#if message.role === 'user' && message.snapshot}
<div
class="mx-2 text-sm text-tertiary flex flex-row items-center justify-between gap-2 mt-2"
>
Saved a flow snapshot
<Button
size="xs2"
variant="border"
color="light"
on:click={() => {
if (message.snapshot) {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot)
}
}}
title="Revert to snapshot"
startIcon={{ icon: Undo2Icon }}
>
Revert
</Button>
</div>
{/if}
</div>
<AIChatMessage
{message}
{messageIndex}
{availableContext}
bind:selectedContext
bind:editingMessageIndex
/>
{/each}
{#if aiChatManager.loading && !aiChatManager.currentReply}
<div class="mb-6 py-1 px-2">
Expand Down Expand Up @@ -323,71 +220,14 @@
</Button>
</div>
{/if}
{#if aiChatManager.mode === 'script'}
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 px-2 no-scrollbar">
<Popover>
<svelte:fragment slot="trigger">
<div
class="border rounded-md px-1 py-0.5 font-normal text-tertiary text-xs hover:bg-surface-hover"
>@</div
>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
</svelte:fragment>
</Popover>
{#each selectedContext as element}
<ContextElementBadge
contextElement={element}
deletable
on:delete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
<ContextTextarea
bind:this={contextTextareaComponent}
{availableContext}
{selectedContext}
isFirstMessage={messages.length === 0}
onAddContext={(contextElement) => addContextToSelection(contextElement)}
onSendRequest={() => {
if (!aiChatManager.loading) {
aiChatManager.sendRequest()
}
}}
onUpdateInstructions={(value) => (aiChatManager.instructions = value)}
{disabled}
/>
{:else}
<div class="relative w-full px-2 scroll-pb-2 pt-2">
<textarea
bind:this={instructionsTextarea}
bind:value={aiChatManager.instructions}
use:autosize
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !aiChatManager.loading) {
e.preventDefault()
aiChatManager.sendRequest()
}
}}
rows={3}
placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'}
class="resize-none"
{disabled}
></textarea>
</div>
{/if}
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{disabled}
isFirstMessage={messages.length === 0}
placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'}
/>
<div
class={`flex flex-row ${
aiChatManager.mode === 'script' && hasDiff ? 'justify-between' : 'justify-end'
Expand Down
Loading
Loading