feat(chat): @ picker in controls row, badges above input, polish

This commit is contained in:
Guilhem Lemouel
2026-05-20 12:57:23 +02:00
parent c2cb6e4bdc
commit 894b76b957
2 changed files with 86 additions and 74 deletions
@@ -1,5 +1,7 @@
<script lang="ts">
import AIChatMessage from './AIChatMessage.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import { type Snippet } from 'svelte'
import {
ArrowDown,
@@ -15,7 +17,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { type DisplayMessage, type ToolDisplayMessage } from './shared'
import { type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
import ProviderModelSelector from './ProviderModelSelector.svelte'
@@ -167,6 +169,17 @@
const showTypingIndicator = $derived(aiChatManager.loading)
// `@` context picker is offered in modes that accept workspace/script/flow
// references (SCRIPT, FLOW, GLOBAL → workspace items + code blocks) or in
// APP mode (datatables, frontend files, etc.). Other modes (NAVIGATOR,
// ASK, API) don't accept @-context.
const showContextPicker = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL ||
aiChatManager.mode === AIMode.APP
)
// "Waiting for user" detection — when the latest tool message is staged
// for confirmation or has an unanswered askUserQuestion, the AI loop is
// paused on the user, not on its own work. The typing-dots indicator
@@ -176,14 +189,13 @@
if (!aiChatManager.loading) return false
const last = messages[messages.length - 1]
if (!last || last.role !== 'tool') return false
const tm = last as ToolDisplayMessage
if (tm.needsConfirmation && tm.isLoading) return true
if (last.needsConfirmation && last.isLoading) return true
if (
tm.userQuestion &&
tm.isLoading &&
!tm.error &&
!tm.userQuestion.selectedChoice &&
!tm.userQuestion.canceled
last.userQuestion &&
last.isLoading &&
!last.error &&
!last.userQuestion.selectedChoice &&
!last.userQuestion.canceled
) {
return true
}
@@ -355,8 +367,8 @@
<div
class={wideLayout
? 'relative w-full max-w-3xl mx-auto px-6'
: 'relative w-full max-w-2xl mx-auto px-2'}
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
{#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
@@ -397,14 +409,49 @@
{disabled}
isFirstMessage={messages.length === 0}
/>
<div
class={`flex flex-row ${
aiChatManager.mode === 'script' && hasDiff ? 'justify-between' : 'justify-end'
} items-center`}
>
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
<div class="flex flex-row justify-between items-center gap-x-1.5">
<div class="flex flex-row items-center gap-x-1.5">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
{#if disabled}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
@@ -486,7 +533,7 @@
</div>
<style>
/* Hourglass flips every ~2.5s with brief pauses at each rest position.
/* Hourglass flips every 4s with long rests at each upright position.
`:global` because the class is applied to a child component's root
(Lucide SVG) and Svelte scoped CSS otherwise wouldn't match it. */
:global(.hourglass-flip) {
@@ -1,6 +1,5 @@
<script lang="ts">
import AppAvailableContextList from './AppAvailableContextList.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import ContextTextarea from './ContextTextarea.svelte'
import autosize from '$lib/autosize'
@@ -10,7 +9,6 @@
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
import Portal from '$lib/components/Portal.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { zIndexes } from '$lib/zIndexes'
import { ArrowUp, Square } from 'lucide-svelte'
import { Button } from '$lib/components/common'
@@ -154,7 +152,7 @@
}
}
async function addContextToSelection(contextElement: ContextElement) {
export async function addContextToSelection(contextElement: ContextElement) {
if (!selectedContext || !availableContext) return
const alreadySelected = selectedContext.find(
@@ -452,54 +450,21 @@
{/snippet}
{#snippet contextPickerRow()}
<div class="flex flex-row items-center gap-1 mt-1 overflow-scroll no-scrollbar">
<Popover>
{#snippet trigger()}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if isContextEnabledMode}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void addContextToSelection(element)
close()
}}
/>
{:else}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
contextElement={element}
deletable
onDelete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
{#if selectedContext.length > 0}
<div class="flex flex-row flex-wrap items-center gap-1 mb-1">
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
contextElement={element}
deletable
onDelete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
{/if}
{/snippet}
<div
@@ -514,6 +479,9 @@
}}
>
{#if isContextEnabledMode}
{#if showContext}
{@render contextPickerRow()}
{/if}
<div class="relative">
<ContextTextarea
bind:this={contextTextareaComponent}
@@ -538,10 +506,10 @@
</div>
{/if}
</div>
{:else if aiChatManager.mode === AIMode.APP}
{#if showContext}
{@render contextPickerRow()}
{/if}
{:else if aiChatManager.mode === AIMode.APP}
<div class={twMerge('relative w-full scroll-pb-2', className)}>
<textarea
bind:this={instructionsTextareaComponent}
@@ -580,9 +548,6 @@
</div>
{/if}
</div>
{#if showContext}
{@render contextPickerRow()}
{/if}
{#if showAppContextTooltip}
<Portal target="body">
<div