This commit is contained in:
Timothy Jaeryang Baek
2026-08-19 16:35:49 -07:00
parent dd7158b45f
commit 33dff414e8
9 changed files with 301 additions and 414 deletions
+5 -24
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { fade } from 'svelte/transition';
@@ -147,8 +146,6 @@
$: messageInputDropzoneId = embedded ? 'note-chat-input-dropzone' : 'chat-pane';
const eventTarget = new EventTarget();
let controlPane: Pane | undefined;
let controlPaneComponent: ChatControls | undefined;
let messageInput: MessageInput | undefined;
let messagesRef: Messages | undefined;
@@ -1556,20 +1553,7 @@
stopAudio();
});
const showControlsSubscribe = showControls.subscribe(async (value) => {
await tick();
if (controlPane && !$mobile) {
try {
if (value) {
controlPaneComponent?.openPane();
} else {
controlPane.collapse();
}
} catch (e) {
// ignore
}
}
const showControlsSubscribe = showControls.subscribe((value) => {
if (!value) {
showCallOverlay.set(false);
showArtifacts.set(false);
@@ -4249,8 +4233,8 @@
/>
{/if}
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
<div class="w-full h-full flex">
<div class="h-full flex relative max-w-full min-w-0 flex-1 flex-col">
<FilesOverlay show={dragged} />
{#if embedded}
<div
@@ -4595,16 +4579,14 @@
</div>
{/if}
</div>
</Pane>
</div>
{#if !embedded}
<ChatControls
bind:this={controlPaneComponent}
bind:history
bind:chatFiles
bind:params
bind:files
bind:pane={controlPane}
chatId={$chatId}
chatUser={chatOwner}
modelId={selectedModelIds?.at(0) ?? null}
@@ -4620,10 +4602,9 @@
{showMessage}
{eventTarget}
{codeInterpreterEnabled}
containerId={chatContainerId}
/>
{/if}
</PaneGroup>
</div>
</div>
{:else if loading}
<div class=" flex items-center justify-center h-full w-full">
+130 -202
View File
@@ -5,10 +5,9 @@
<script lang="ts">
import { SvelteFlowProvider } from '@xyflow/svelte';
import { slide } from 'svelte/transition';
import { Pane, PaneResizer } from 'paneforge';
import { v4 as uuidv4 } from 'uuid';
import { onDestroy, onMount, tick, getContext } from 'svelte';
import { onMount, tick, getContext } from 'svelte';
import {
config,
terminalServers,
@@ -29,6 +28,7 @@
import Controls from './Controls/Controls.svelte';
import CallOverlay from './MessageInput/CallOverlay.svelte';
import Drawer from '../common/Drawer.svelte';
import ResizableSidePanel from '../common/ResizableSidePanel.svelte';
import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
import FileNav from './FileNav.svelte';
@@ -56,13 +56,10 @@
export let codeInterpreterEnabled = false;
export let pane: Pane | null = null;
export let containerId = 'chat-container';
let largeScreen = false;
let dragged = false;
let minSize = 0;
let paneReady = false;
let mounted = false;
let controlsWidth = 350;
// Tab state for Controls+Files panel
let activeTab = savedTab;
@@ -92,11 +89,10 @@
chatContextAvailable(selectedSystemTerminal) &&
!(chatContextNeedsSavedChat(selectedSystemTerminal) && !isSavedChatId(chatId));
$: terminalFilesAvailable = !!(
($selectedTerminalId &&
(selectedSystemTerminalAvailable ||
(!selectedSystemTerminal &&
($user?.role === 'admin' ||
($user?.permissions?.features?.direct_tool_servers ?? true)))))
$selectedTerminalId &&
(selectedSystemTerminalAvailable ||
(!selectedSystemTerminal &&
($user?.role === 'admin' || ($user?.permissions?.features?.direct_tool_servers ?? true))))
);
$: showFilesTab =
terminalFilesAvailable ||
@@ -179,18 +175,6 @@
}
};
export const openPane = () => {
if (parseInt(localStorage?.chatControlsSize)) {
const container = document.getElementById(containerId);
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
pane.resize(size);
} else {
pane.resize(minSize);
}
};
const handleMediaQuery = async (e) => {
if (e.matches) {
largeScreen = true;
@@ -206,7 +190,6 @@
await tick();
showCallOverlay.set(true);
}
pane = null;
}
};
@@ -222,45 +205,16 @@
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
let resizeObserver: ResizeObserver | null = null;
let isDestroyed = false;
// Wait for Svelte to render the Pane after largeScreen changed
const init = async () => {
await tick();
if (isDestroyed) return;
// If controls were persisted as open, set the pane to the saved size
if ($showControls && pane) {
openPane();
}
setTimeout(() => {
paneReady = true;
mounted = true;
}, 0);
const container = document.getElementById(containerId) as HTMLElement;
if (!container) return;
minSize = Math.floor((350 / container.clientWidth) * 100);
resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width;
minSize = Math.floor((350 / width) * 100);
if ($showControls) {
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
pane.resize(minSize);
} else {
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
if (size < minSize && pane) pane.resize(minSize);
}
}
}
});
resizeObserver.observe(container);
};
init();
@@ -269,8 +223,7 @@
return () => {
isDestroyed = true;
paneReady = false;
resizeObserver?.disconnect();
mounted = false;
if (!largeScreen) {
showControls.set(false);
}
@@ -289,7 +242,7 @@
if ($showCallOverlay) showCallOverlay.set(false);
};
$: if (paneReady && !chatId) closeHandler();
$: if (mounted && !chatId) closeHandler();
// Helper: is a "special" full-screen panel active?
$: specialPanel = $showCallOverlay || $showArtifacts || $showEmbeds;
@@ -409,154 +362,129 @@
</Drawer>
{/if}
{:else}
{#if $showControls}
<PaneResizer
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer"
>
<div
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</PaneResizer>
{/if}
<Pane
bind:pane
defaultSize={0}
onResize={(size) => {
if ($showControls && pane.isExpanded()) {
if (size < minSize) pane.resize(minSize);
if (size < minSize) {
localStorage.chatControlsSize = 0;
} else {
const container = document.getElementById(containerId);
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
}
}
}}
onCollapse={() => {
if (paneReady) showControls.set(false);
}}
collapsible={true}
class="z-10 bg-white dark:bg-gray-900"
<ResizableSidePanel
open={$showControls}
bind:width={controlsWidth}
minWidth={350}
maxWidth={640}
storageKey="chatControlsSize"
className="h-full z-10 bg-white dark:bg-gray-900"
>
{#if $showControls}
<div class="flex max-h-full min-h-full">
<div
class="w-full {specialPanel && !$showCallOverlay
? ' '
: 'bg-white dark:bg-gray-900'} z-40 pointer-events-auto {activeTab === 'files'
? ''
: 'overflow-y-auto'} scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
<div class="w-full h-full flex justify-center">
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close={() => showControls.set(false)}
/>
</div>
{:else if $showEmbeds}
<Embeds overlay={dragged} />
{:else if $showArtifacts}
<Artifacts {history} overlay={dragged} />
{:else}
<!-- Controls + Files tabs -->
<div class="flex flex-col h-full min-h-0">
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2 pb-2 shrink-0">
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#if showControlsTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'controls'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'files'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'files')}
>
{$i18n.t('Files')}
</button>
{/if}
{#if showOverviewTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'overview'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'overview')}
>
{$i18n.t('Overview')}
</button>
{/if}
</div>
<button
class="p-1 rounded-lg text-gray-500 dark:text-gray-400"
on:click={() => showControls.set(false)}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
<div class="flex h-full max-h-full min-h-full">
<div
class="w-full {specialPanel && !$showCallOverlay
? ' '
: 'bg-white dark:bg-gray-900'} z-40 pointer-events-auto {activeTab === 'files'
? ''
: 'overflow-y-auto'} scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
<div class="w-full h-full flex justify-center">
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close={() => showControls.set(false)}
/>
</div>
{:else if $showEmbeds}
<Embeds overlay={dragged} />
{:else if $showArtifacts}
<Artifacts {history} overlay={dragged} />
{:else}
<!-- Controls + Files tabs -->
<div class="flex flex-col h-full min-h-0">
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2 pb-2 shrink-0">
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#if showControlsTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'controls'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div
class="flex-1 min-h-0 {activeTab === 'overview'
? 'h-full'
: activeTab === 'controls'
? 'overflow-y-auto px-3 pt-1'
: ''}"
>
{#if activeTab === 'overview'}
<Overview
{history}
{chatUser}
onNodeClick={(e) => {
const node = e.node;
if (node?.data?.message?.favorite) {
history.messages[node.data.message.id].favorite = true;
} else {
history.messages[node.data.message.id].favorite = null;
}
showMessage(node.data.message, true);
}}
/>
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} overlay={dragged} {chatId} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
{$i18n.t('Controls')}
</button>
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'files'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'files')}
>
{$i18n.t('Files')}
</button>
{/if}
{#if showOverviewTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'overview'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'overview')}
>
{$i18n.t('Overview')}
</button>
{/if}
</div>
<button
class="p-1 rounded-lg text-gray-500 dark:text-gray-400"
on:click={() => showControls.set(false)}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div>
<div
class="flex-1 min-h-0 {activeTab === 'overview'
? 'h-full'
: activeTab === 'controls'
? 'overflow-y-auto px-3 pt-1'
: ''}"
>
{#if activeTab === 'overview'}
<Overview
{history}
{chatUser}
onNodeClick={(e) => {
const node = e.node;
if (node?.data?.message?.favorite) {
history.messages[node.data.message.id].favorite = true;
} else {
history.messages[node.data.message.id].favorite = null;
}
showMessage(node.data.message, true);
}}
/>
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} overlay={dragged} {chatId} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
{/if}
</div>
</div>
{/if}
</div>
{/if}
</Pane>
</div>
</ResizableSidePanel>
{/if}