feat: move AI chat history to sidebar and drop in-panel header

- Add an "AI chat" section to the sidebar (mobile drawer, desktop, and
  operator views) showing past chats from HistoryManager with a + button
  in the header that creates a new chat and routes to /design-sandbox.
  Section title fades out when the sidebar is collapsed.
- Remove the in-panel chat header from AIChatDisplay (Chat title,
  HideButton slot, History popover, + button, headerRight slot) along
  with the now-unused props (pastChats, loadPastChat, deletePastChat,
  saveAndClear, headerLeft, headerRight). The chat panel now starts
  directly with the messages, and history/new-chat live in the sidebar.
- AIChat.svelte updated to drop the headerLeft snippet, the HideButton
  import, and the now-unused props on AIChatDisplay.
- design-sandbox page mocks aiChatManager.historyManager.getPastChats()
  with realistic Windmill-style chat titles so the sidebar history is
  populated for the design preview without writing to IndexedDB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-04-29 11:08:23 +02:00
co-authored by Claude Opus 4.7
parent 0da682bd7c
commit 2ab9d35c85
4 changed files with 65 additions and 114 deletions
@@ -5,7 +5,6 @@
import { dbSchemas, userStore, workspaceStore } from '$lib/stores'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { base } from '$lib/base'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
@@ -53,8 +52,6 @@
aiChatManager.sendRequest(options)
}
const historyManager = aiChatManager.historyManager
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
$effect(() => {
@@ -80,20 +77,8 @@
}}
/>
{#snippet headerLeft()}
<HideButton
hidden={false}
direction="right"
panelName="AI"
shortcut="L"
size="md"
on:click={() => aiChatManager.toggleOpen()}
/>
{/snippet}
<AIChatDisplay
bind:this={aiChatDisplay}
pastChats={historyManager.getPastChats()}
bind:selectedContext={
() => aiChatManager.contextManager.getSelectedContext(),
(sc) => aiChatManager.contextManager.setSelectedContext(sc)
@@ -113,16 +98,8 @@
}
]
: aiChatManager.displayMessages}
saveAndClear={aiChatManager.saveAndClear}
deletePastChat={(id) => {
historyManager.deletePastChat(id)
}}
loadPastChat={(id) => {
aiChatManager.loadPastChat(id)
}}
cancel={aiChatManager.cancel}
askAi={aiChatManager.askAi}
{headerLeft}
hasDiff={aiChatManager.scriptEditorOptions &&
!!aiChatManager.scriptEditorOptions.lastDeployedCode &&
aiChatManager.scriptEditorOptions.lastDeployedCode !== aiChatManager.scriptEditorOptions.code}
@@ -1,22 +1,18 @@
<script lang="ts">
import AIChatMessage from './AIChatMessage.svelte'
import { type Snippet } from 'svelte'
import {
ArrowUp,
CheckIcon,
Code2,
FileCode,
HistoryIcon,
Loader2,
MousePointer2,
Plus,
Square,
TextSelect,
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 ChatQuickActions from './ChatQuickActions.svelte'
@@ -31,35 +27,23 @@
let {
messages,
pastChats,
hasDiff,
diffMode = false, // todo: remove default
selectedContext = $bindable([]), // todo: remove default
availableContext = [], // todo: remove default
loadPastChat,
deletePastChat,
saveAndClear,
cancel,
askAi = () => {}, // todo: remove default,
headerLeft,
headerRight,
disabled = false,
disabledMessage = '',
suggestions = []
}: {
messages: DisplayMessage[]
pastChats: { id: string; title: string }[]
hasDiff?: boolean
diffMode: boolean
selectedContext: ContextElement[]
availableContext: ContextElement[]
loadPastChat: (id: string) => void
deletePastChat: (id: string) => void
saveAndClear: () => void
cancel: () => void
askAi?: (instructions: string, options?: { withCode?: boolean; withDiff?: boolean }) => void
headerLeft?: Snippet
headerRight?: Snippet
disabled?: boolean
disabledMessage?: string
suggestions?: string[]
@@ -113,80 +97,6 @@
</script>
<div class="flex flex-col h-full">
<div
class="flex flex-row items-center justify-between gap-2 p-2 border-b border-gray-200 dark:border-gray-600"
>
<div class="flex flex-row items-center gap-2">
{@render headerLeft?.()}
<p class="text-sm font-semibold">Chat</p>
</div>
<div class="flex flex-row items-center gap-2">
<Popover>
{#snippet trigger()}
<Button
on:click={() => {}}
title="History"
size="md"
btnClasses="!p-1"
startIcon={{ icon: HistoryIcon }}
iconOnly
variant="border"
color="light"
propagateEvent
/>
{/snippet}
{#snippet content({ close })}
<div class="p-1 overflow-y-auto max-h-[300px]">
{#if pastChats.length === 0}
<div class="text-center text-primary text-xs">No history</div>
{:else}
<div class="flex flex-col">
{#each pastChats as chat}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1"
onclick={() => {
loadPastChat(chat.id)
close()
}}
>
<div
class="text-xs font-medium w-48 text-ellipsis overflow-hidden whitespace-nowrap flex-1"
title={chat.title}
>
{chat.title}
</div>
<Button
iconOnly
size="xs2"
btnClasses="!p-1"
variant="default"
startIcon={{ icon: X }}
on:click={() => {
deletePastChat(chat.id)
}}
/>
</button>
{/each}
</div>
{/if}
</div>
{/snippet}
</Popover>
<Button
title="New chat"
on:click={() => {
saveAndClear()
}}
size="md"
btnClasses="!p-1"
startIcon={{ icon: Plus }}
iconOnly
variant="border"
color="light"
/>
{@render headerRight?.()}
</div>
</div>
{#if messages.length === 0}
<span class="text-2xs text-gray-500 dark:text-gray-400 text-center px-2 my-2"
>You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the
@@ -49,7 +49,7 @@
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { ArrowLeft, Search, WandSparkles } from 'lucide-svelte'
import { ArrowLeft, Plus, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { deepEqual } from 'fast-equals'
import { twMerge } from 'tailwind-merge'
@@ -463,6 +463,49 @@
globalDbManagerDrawer.val = useDbManagerUriState()
</script>
{#snippet chatHistorySection({ isCollapsed: collapsed }: { isCollapsed: boolean })}
<div class="pt-3 flex flex-col">
<div class="flex flex-row items-center justify-between px-2 mb-1">
<div
class="text-secondary text-[0.5rem] uppercase transition-opacity"
class:opacity-0={collapsed}
>
AI chat
</div>
<button
type="button"
title="New chat"
class="p-0.5 rounded text-secondary hover:bg-surface-hover hover:text-primary transition-colors"
onclick={() => {
aiChatManager.saveAndClear()
goto('/design-sandbox')
}}
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
{#if !collapsed}
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
{#each aiChatManager.historyManager.getPastChats() as chat (chat.id)}
<button
type="button"
class="text-left text-2xs font-normal px-2 py-1.5 rounded hover:bg-surface-hover transition-colors truncate"
title={chat.title}
onclick={() => {
aiChatManager.loadPastChat(chat.id)
goto('/design-sandbox')
}}
>
{chat.title}
</button>
{:else}
<div class="text-2xs font-normal text-secondary px-2 py-1.5 italic">No past chats</div>
{/each}
</div>
{/if}
</div>
{/snippet}
<svelte:window bind:innerWidth />
<UserSettings bind:this={userSettings} showMcpMode={true} />
@@ -576,6 +619,7 @@
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{@render chatHistorySection({ isCollapsed: false })}
</div>
<SidebarContent
@@ -655,6 +699,7 @@
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{@render chatHistorySection({ isCollapsed })}
</div>
<SidebarContent
@@ -768,6 +813,7 @@
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{@render chatHistorySection({ isCollapsed })}
</div>
<SidebarContent
@@ -85,6 +85,19 @@
}
]
// Realistic-sounding past-chat titles for the sidebar history. Just titles + ids
// because the sidebar snippet only reads those two fields.
const fakePastChats = [
{ id: 'mock_1', title: 'Daily user re-engagement flow' },
{ id: 'mock_2', title: 'Sync Stripe customers to Postgres' },
{ id: 'mock_3', title: 'Weekly usage report email' },
{ id: 'mock_4', title: 'Migrate S3 bucket to GCS' },
{ id: 'mock_5', title: 'Slack alert on failed deploys' },
{ id: 'mock_6', title: 'Parse CSV uploads into Postgres' },
{ id: 'mock_7', title: 'Generate signed download URLs' },
{ id: 'mock_8', title: 'Onboarding webhook handler' }
]
function applyMocks() {
// Pretend Windmill AI is enabled with an Anthropic model so the chat input is active.
copilotInfo.set({
@@ -96,6 +109,11 @@
})
aiChatManager.mode = AIMode.FLOW
aiChatManager.displayMessages = fakeMessages
// Patch getPastChats so the sidebar's "AI chat" history shows the mock titles
// without writing anything to IndexedDB (keeps the real workspace history clean).
// Cast to any because savedChats / pastChats are private fields on HistoryManager.
;(aiChatManager.historyManager as any).getPastChats = () => fakePastChats
}
// The parent (logged) layout always renders its AiChatLayout (right-side chat).