This commit is contained in:
Timothy Jaeryang Baek 2026-11-03 16:47:22 +09:00
parent 563d621854
commit 09030d5dd2
5 changed files with 326 additions and 193 deletions

View file

@ -2649,7 +2649,10 @@
const initNewChat = async () => {
loading = true;
if (!embedded && ($showTabs === 'comments' || $showTabs === 'side' || typeof $showTabs === 'object'))
if (
!embedded &&
($showTabs === 'comments' || $showTabs === 'side' || typeof $showTabs === 'object')
)
showTabs.set(false);
const signal = startChatLoad();
modelDefaults = null;
@ -3874,6 +3877,10 @@
};
let terminalTabs: ChatTabs;
let fullWidthTabs = false;
let mainChatActive = false;
let tabTitleEditor: import('svelte').Snippet | undefined;
let tabActions: import('svelte').Snippet | undefined;
const getChatId = async (_history: typeof history) => {
let _chatId = $chatId;
@ -3954,7 +3961,9 @@
messageInput?.openComments();
return { status: 'rejected' };
}
const sideCommand = String(userPrompt).trim().match(/^\/(?:side|btw)(?:\s+([\s\S]*))?$/i);
const sideCommand = String(userPrompt)
.trim()
.match(/^\/(?:side|btw)(?:\s+([\s\S]*))?$/i);
if (sideCommand && !sideChatAllowed) {
toast.error($i18n.t('You do not have permission to use side chat.'));
return { status: 'rejected' };
@ -5290,8 +5299,18 @@
></div>
{/if}
<div class="w-full h-full flex">
<div class="h-full flex relative max-w-full min-w-0 flex-1 flex-col">
<div
class="w-full h-full min-h-0 {fullWidthTabs
? 'grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)_auto]'
: 'flex'}"
>
<div
class="h-full flex relative max-w-full min-w-0 min-h-0 flex-1 flex-col col-start-1 row-start-2"
class:hidden={fullWidthTabs && !mainChatActive}
id={fullWidthTabs ? 'chat-panel-main-chat-view' : undefined}
role={fullWidthTabs ? 'tabpanel' : undefined}
aria-labelledby={fullWidthTabs ? 'chat-panel-main-chat' : undefined}
>
{#if loading}
<div class="flex items-center justify-center h-full w-full">
<Spinner className="size-5" />
@ -5327,6 +5346,9 @@
</div>
{:else}
<Navbar
tabbed={fullWidthTabs}
bind:tabTitleEditor
bind:tabActions
{liveParticipants}
bind:this={navbarElement}
readOnly={isShared}
@ -5424,7 +5446,7 @@
}}
bind:selectedModels
{atSelectedModel}
className={embedded ? 'h-full flex pt-4' : 'h-full flex pt-18'}
className={embedded || fullWidthTabs ? 'h-full flex pt-4' : 'h-full flex pt-18'}
{sendMessage}
{showMessage}
{submitMessage}
@ -5436,7 +5458,7 @@
{onToolCallResolved}
allowDelete={!(generating || taskIds?.length)}
forkHandler={$temporaryChatEnabled ? null : handleForkChat}
topPadding={!embedded}
topPadding={!embedded && !fullWidthTabs}
bottomPadding={files.length > 0}
{onSelect}
{onInsertToNote}
@ -5698,6 +5720,11 @@
{#if !embedded}
<ChatTabs
bind:fullWidth={fullWidthTabs}
bind:mainChatActive
title={$chatTitle || $i18n.t('New Chat')}
tabTitleEditor={loading ? undefined : tabTitleEditor}
tabActions={loading ? undefined : tabActions}
bind:terminalContext
bind:history
bind:files

View file

@ -40,6 +40,8 @@
activateTab,
removeTab,
moveTab,
mainChatTabId,
setFullWidthTabs,
fileTabId,
type PanelTab
} from './ChatTabs/tabs';
@ -95,6 +97,25 @@
export let canSideChat = false;
export let codeInterpreterEnabled = false;
export let fullWidth = false;
export let mainChatActive = false;
export let title = '';
export let tabTitleEditor: import('svelte').Snippet | undefined = undefined;
export let tabActions: import('svelte').Snippet | undefined = undefined;
let expanded = false;
$: fullWidth = expanded && largeScreen && !!$showTabs;
$: mainChatActive = fullWidth && tabs.activeId === mainChatTabId;
$: if (fullWidth !== tabs.tabs.some((tab) => tab.id === mainChatTabId)) {
tabs = setFullWidthTabs(tabs, fullWidth, title);
if (fullWidth) void tick().then(() => document.getElementById(domId(mainChatTabId))?.focus());
}
const toggleLayout = async () => {
if (panelTarget) releasePanel(panelTarget);
expanded = !expanded;
localStorage.setItem('chatTabsLayout', expanded ? 'full' : 'side');
await tick();
if (!fullWidth) document.getElementById('chat-tabs-layout-toggle')?.focus();
};
let largeScreen = false;
let dragged = false;
@ -169,6 +190,7 @@
};
const tabIcon = (kind: PanelTab['kind']) =>
({
chat: 'chat-lines',
files: 'folder',
file: 'page',
terminal: 'terminal',
@ -183,14 +205,18 @@
const domId = (id: string) => `chat-panel-${encodeURIComponent(id)}`;
$: activeTabId = tabs.activeId;
$: activeTab = tabs.tabs.find((tab) => tab.id === tabs.activeId);
$: terminalContext = fileContexts[tabs.activeId] ?? activeTab?.context ?? null;
$: contextTab =
activeTab?.kind === 'chat'
? tabs.tabs.find((tab) => tab.id === tabs.history.at(-1))
: activeTab;
$: terminalContext = (contextTab && fileContexts[contextTab.id]) ?? contextTab?.context ?? null;
// Resolve before reactive Files-tab creation, which can run later in the same update.
$: gitConnection =
mounted &&
$config?.features?.enable_tool_servers &&
(tabs.tabs.find((tab) => tab.id === tabs.activeId)?.connection ??
(contextTab?.connection ??
resolveTerminalConnection(
tabs.tabs.find((tab) => tab.id === tabs.activeId)?.terminalId ?? $selectedTerminalId,
contextTab?.terminalId ?? $selectedTerminalId,
$terminalServers ?? [],
directTerminals,
localStorage.token ?? ''
@ -254,7 +280,7 @@
terminalId: selectedConnection.selector,
connection: { ...selectedConnection },
untitled: true,
context: activeTab?.terminalId === selectedConnection.selector ? terminalContext : undefined
context: contextTab?.terminalId === selectedConnection.selector ? terminalContext : undefined
});
};
const openFeature = (kind: 'artifacts' | 'preview' | 'voice' | 'comments' | 'side') => {
@ -277,7 +303,9 @@
const newTerminal = () => {
if (!terminalEnabled || !selectedConnection) return;
let terminalNumber = 1;
while (tabs.tabs.some((tab) => tab.kind === 'terminal' && tab.terminalNumber === terminalNumber)) {
while (
tabs.tabs.some((tab) => tab.kind === 'terminal' && tab.terminalNumber === terminalNumber)
) {
terminalNumber++;
}
openPanel({
@ -343,6 +371,7 @@
});
};
const closeTab = async (id: string) => {
if (id === mainChatTabId) return;
if (closing.has(id)) return;
closing.add(id);
try {
@ -397,7 +426,7 @@
// Restore the DOM before Svelte reconciles its keyed tabs.
from.insertBefore(item, nextSibling?.parentNode === from ? nextSibling : null);
if (newDraggableIndex != null)
tabs = moveTab(tabs, item.dataset.panelTab!, newDraggableIndex);
tabs = moveTab(tabs, item.dataset.panelTab!, newDraggableIndex + (fullWidth ? 1 : 0));
dragged = false;
}
});
@ -492,7 +521,11 @@
$: showPreviewTab = !!$embed && $embed.chatId === chatId;
$: availableViews = [
{ kind: 'files', label: $i18n.t('Files'), available: showFilesTab },
{ kind: 'side', label: $i18n.t('Side chat'), available: canSideChat && !!chatId && hasMessages },
{
kind: 'side',
label: $i18n.t('Side chat'),
available: canSideChat && !!chatId && hasMessages
},
{ kind: 'artifacts', label: $i18n.t('Artifacts'), available: showArtifactsTab },
{ kind: 'preview', label: $i18n.t('Preview'), available: showPreviewTab },
{ kind: 'voice', label: $i18n.t('Voice'), available: $showCallOverlay },
@ -529,7 +562,7 @@
const hadTabs = tabs.tabs.length > 0;
for (const tab of tabs.tabs) {
// An open side chat owns its model and context; changing the parent must not discard it.
if (tab.kind === 'side') continue;
if (tab.kind === 'side' || tab.kind === 'chat') continue;
const available = tab.terminalId
? !!$selectedTerminalId && terminalAccessible(tab.terminalId)
: availableViews.some((view) => view.kind === tab.kind && view.available);
@ -591,7 +624,7 @@
$: if (chatId !== tabsChatId) {
if (tabsChatId || !chatId) {
tabs = emptyTabs();
tabs = setFullWidthTabs(emptyTabs(), fullWidth, title);
sideChatNumber = 0;
hasOpenedTabs = false;
dirtyFiles = {};
@ -602,10 +635,12 @@
}
$: if ($showTabs === true && !tabs.tabs.length) {
const options = [
...availableViews.filter((view) => view.available).map((view) => () => {
if (view.kind === 'files') openFiles();
else openFeature(view.kind as 'artifacts' | 'preview' | 'voice' | 'comments' | 'side');
}),
...availableViews
.filter((view) => view.available)
.map((view) => () => {
if (view.kind === 'files') openFiles();
else openFeature(view.kind as 'artifacts' | 'preview' | 'voice' | 'comments' | 'side');
}),
...(terminalFilesAvailable ? [newFile] : []),
...(terminalEnabled ? [newTerminal] : []),
...(browserEnabled ? [openBrowser] : []),
@ -623,7 +658,8 @@
($user.permissions?.chat?.side_chat ?? false))
) &&
$sideChatRequest?.parentId === chatId
) sideChatRequest.set(null);
)
sideChatRequest.set(null);
$: if ($showTabs === 'files' && showFilesTab) openFiles();
$: if ($showTabs === 'side' && chatId && hasMessages) {
if (canSideChat) openFeature('side');
@ -715,6 +751,8 @@
const mediaQuery = window.matchMedia('(min-width: 768px)');
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
expanded = localStorage.getItem('chatTabsLayout') === 'full';
if (expanded && largeScreen && !$showTabs) showTabs.set(true);
let isDestroyed = false;
@ -788,11 +826,13 @@
<div
class:hidden={!$showTabs || !panelTarget}
use:placePanel={$showTabs ? panelTarget : null}
class="h-full w-full min-h-0 flex flex-col"
class={fullWidth ? 'contents' : 'h-full w-full min-h-0 flex flex-col'}
style:--chat-tabs-dark-background={'var(--color-gray-900)'}
id="controls-container"
>
<div class="flex items-center px-2 pt-1 pb-1 gap-1 shrink-0 select-none">
<div
class="flex items-center px-2 pt-1 pb-1 gap-1 shrink-0 select-none col-start-1 row-start-1 min-w-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex items-center gap-0.5 flex-1 min-w-0 py-0.5">
<div
role="tablist"
@ -809,7 +849,8 @@
event.shiftKey ||
Math.abs(event.deltaY) <= Math.abs(event.deltaX) ||
strip.scrollWidth <= strip.clientWidth
) return;
)
return;
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? strip.clientWidth : 1;
event.preventDefault();
strip.scrollLeft += event.deltaY * unit;
@ -817,22 +858,26 @@
>
{#each tabs.tabs as tab (tab.id)}
<div
data-panel-tab={tab.id}
class="flex items-center gap-1.5 px-2.5 h-7 rounded-lg text-sm font-normal whitespace-nowrap shrink-0 transition {tabs.activeId ===
role="presentation"
data-panel-tab={tab.kind === 'chat' ? undefined : tab.id}
on:click={() => selectTab(tab.id)}
class="panel-tab flex cursor-pointer items-center gap-1.5 ps-2.5 pe-2 h-7 rounded-lg text-sm font-normal whitespace-nowrap shrink-0 transition {tabs.activeId ===
tab.id
? 'bg-gray-100/40 dark:bg-gray-800/25 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'}"
>
<button
role="tab"
aria-keyshortcuts="Alt+Shift+ArrowLeft Alt+Shift+ArrowRight"
aria-keyshortcuts={tab.kind === 'chat'
? undefined
: 'Alt+Shift+ArrowLeft Alt+Shift+ArrowRight'}
id={domId(tab.id)}
aria-controls={`${domId(tab.id)}-view`}
aria-selected={tabs.activeId === tab.id}
tabindex={tabs.activeId === tab.id ? 0 : -1}
title={tab.path ?? tab.label}
title={tab.kind === 'chat' ? title : (tab.path ?? tab.label)}
aria-label={tab.kind === 'chat' ? title : undefined}
class="flex h-full items-center gap-1.5 min-w-0"
on:click={() => selectTab(tab.id)}
>
{#if tab.kind === 'file'}
<Icon name={fileIconName(tab.label)} size={14} strokeWidth={1.4} />
@ -841,7 +886,11 @@
{:else}
<Icon name={tabIcon(tab.kind)} size={14} />
{/if}
<span class="max-w-30 overflow-hidden text-ellipsis">{tab.label}</span>
{#if tab.kind !== 'chat' || !tabTitleEditor}
<span class="max-w-30 overflow-hidden text-ellipsis"
>{tab.kind === 'chat' ? title : tab.label}</span
>
{/if}
{#if tab.kind === 'comments' && commentCount}<span class="text-gray-400"
>{commentCount}</span
>{/if}
@ -850,14 +899,18 @@
class="ms-1 size-1.5 shrink-0 rounded-full bg-gray-400"
aria-label={$i18n.t('Unsaved changes')}
></span>{/if}
<button
type="button"
class="flex items-center justify-center w-4 h-4 rounded text-gray-400 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-white"
aria-label={tab.kind === 'voice'
? $i18n.t('End call')
: `${$i18n.t('Close')} ${tab.label}`}
on:click={() => closeTab(tab.id)}><Icon name="xmark" size={12} /></button
>
{#if tab.kind === 'chat'}
{@render tabTitleEditor?.()}
{@render tabActions?.()}
{:else}<button
type="button"
class="flex items-center justify-center w-4 h-4 rounded text-gray-400 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-white"
aria-label={tab.kind === 'voice'
? $i18n.t('End call')
: `${$i18n.t('Close')} ${tab.label}`}
on:click|stopPropagation={() => closeTab(tab.id)}
><Icon name="xmark" size={12} /></button
>{/if}
</div>
{/each}
</div>
@ -906,6 +959,19 @@
</Dropdown>
{/if}
</div>
{#if largeScreen}
<button
id="chat-tabs-layout-toggle"
type="button"
class="flex items-center justify-center w-7 h-7 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 shrink-0"
on:click={toggleLayout}
aria-label={fullWidth ? $i18n.t('Use side tabs') : $i18n.t('Use full-width tabs')}
title={fullWidth ? $i18n.t('Use side tabs') : $i18n.t('Use full-width tabs')}
aria-pressed={fullWidth}
>
<Icon name={fullWidth ? 'split-horizontal' : 'expand'} size={14} />
</button>
{/if}
<button
type="button"
class="flex items-center justify-center w-7 h-7 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 shrink-0"
@ -913,8 +979,11 @@
aria-label={$i18n.t('Hide tabs')}><Icon name="xmark" size={12} /></button
>
</div>
<div class="relative flex-1 min-h-0">
{#each tabs.tabs as tab (tab.id)}
<div
class="relative flex-1 min-h-0 min-w-0 col-start-1 row-start-2"
class:hidden={mainChatActive}
>
{#each tabs.tabs.filter((tab) => tab.kind !== 'chat') as tab (tab.id)}
<div
id={`${domId(tab.id)}-view`}
role="tabpanel"
@ -1051,19 +1120,21 @@
{$i18n.t('Open a tab using the + menu.')}
</div>{/if}
</div>
{#if gitConnection && tabs.tabs.length}
<GitBar
bind:status={gitStatus}
bind:diffMode={gitDiffMode}
bind:hideWhitespace={gitHideWhitespace}
connection={gitConnection}
{chatId}
directory={terminalContext?.currentDirectory ?? '.'}
active={!!$showTabs}
onOpenFile={(path) => openFile(path, gitConnection.selector)}
onOpenDirectory={(path) => openFiles(gitConnection.selector, path)}
/>
{/if}
<div class="col-start-1 row-start-3 min-w-0" class:hidden={mainChatActive}>
{#if gitConnection && tabs.tabs.length}
<GitBar
bind:status={gitStatus}
bind:diffMode={gitDiffMode}
bind:hideWhitespace={gitHideWhitespace}
connection={gitConnection}
{chatId}
directory={terminalContext?.currentDirectory ?? '.'}
active={!!$showTabs}
onOpenFile={(path) => openFile(path, gitConnection.selector)}
onOpenDirectory={(path) => openFiles(gitConnection.selector, path)}
/>
{/if}
</div>
</div>
{#if !largeScreen}
@ -1096,7 +1167,7 @@
</MobileSwipePanel>
{:else}
<ResizableSidePanel
open={!!$showTabs}
open={!!$showTabs && !fullWidth}
bind:width={controlsWidth}
minWidth={280}
minSiblingWidth={360}
@ -1109,9 +1180,17 @@
class="h-full w-full z-40 pointer-events-auto"
></div>
</ResizableSidePanel>
{#if fullWidth}
<!-- The tab strip and pane share the parent chat's grid without remounting either view. -->
<div use:preservePanel bind:this={panelTarget} class="contents"></div>
{/if}
{/if}
<style>
.panel-tab:not(:has(:global(button:not([role='tab'])))) {
padding-inline: 0.625rem;
}
.panel-tab-strip {
scrollbar-width: none;
}

View file

@ -2,6 +2,7 @@ import type { TerminalContext } from '$lib/types';
import type { TerminalConnection } from '$lib/apis/terminal';
export type PanelTabKind =
| 'chat'
| 'files'
| 'file'
| 'terminal'
@ -31,6 +32,17 @@ export type PanelTabs = { tabs: PanelTab[]; activeId: string; history: string[]
export const emptyTabs = (): PanelTabs => ({ tabs: [], activeId: '', history: [] });
export const mainChatTabId = 'main-chat';
export function setFullWidthTabs(state: PanelTabs, fullWidth: boolean, label = 'Chat'): PanelTabs {
if (!fullWidth) return removeTab(state, mainChatTabId);
if (state.tabs.some((tab) => tab.id === mainChatTabId)) return state;
return activateTab(
{ ...state, tabs: [{ id: mainChatTabId, kind: 'chat', label }, ...state.tabs] },
mainChatTabId
);
}
export function activateTab(state: PanelTabs, id: string): PanelTabs {
if (!state.tabs.some((tab) => tab.id === id) || state.activeId === id) return state;
return {
@ -70,6 +82,7 @@ export function removeTab(state: PanelTabs, id: string): PanelTabs {
export function moveTab(state: PanelTabs, id: string, to: number): PanelTabs {
const from = state.tabs.findIndex((tab) => tab.id === id);
if (id === mainChatTabId || state.tabs[to]?.id === mainChatTabId) return state;
if (from < 0 || to < 0 || to >= state.tabs.length || from === to) return state;
const tabs = [...state.tabs];
tabs.splice(to, 0, tabs.splice(from, 1)[0]);

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { getContext, tick, type Snippet } from 'svelte';
import { toast } from 'svelte-sonner';
import {
@ -56,9 +56,14 @@
export let scrollTop = 0;
export let scrollToTop: (() => void) | null = null;
export let chat;
export let history;
export let chat: any;
export let history: any;
export let title = '';
export let tabbed = false;
export let tabTitleEditor: Snippet | undefined = undefined;
export let tabActions: Snippet | undefined = undefined;
$: tabTitleEditor = confirmEdit ? renderTitleEditor : undefined;
$: tabActions = renderChatActions;
export let onSaveTempChat: () => {};
export let archiveChatHandler: (id: string) => void;
export let deleteChatHandler: (id: string) => void;
@ -143,6 +148,146 @@
};
</script>
{#snippet renderTitleEditor()}
<input
bind:this={chatTitleInput}
bind:value={chatTitle}
class="min-w-0 flex-1 bg-transparent md:py-1 text-[0.9375rem] font-normal text-gray-700 outline-hidden dark:text-gray-300"
aria-label={$i18n.t('Title')}
placeholder={generating ? $i18n.t('Generating...') : ''}
disabled={generating}
on:keydown={(e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
editChatTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
confirmEdit = false;
}
}}
on:blur={editChatTitle}
/>
<Tooltip content={$i18n.t('Generate')}>
<button
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="generate-title-button"
aria-label={$i18n.t('Generate')}
disabled={generating}
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
generateTitleHandler();
}}
>
<Sparkles strokeWidth="1.5" />
</button>
</Tooltip>
{/snippet}
{#snippet renderChatActions()}
{#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true}
{#if !chat?.id && !tabbed}
<Tooltip content={$i18n.t(`Temporary Chat`)}>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="temporary-chat-button"
on:click={async () => {
if (($settings?.temporaryChatByDefault ?? false) && $temporaryChatEnabled) {
// for proper initNewChat handling
await temporaryChatEnabled.set(null);
} else {
await temporaryChatEnabled.set(!$temporaryChatEnabled);
}
if ($page.url.pathname !== '/' && !$page.url.pathname.startsWith('/folders/')) {
await goto('/');
}
// add 'temporary-chat=true' to the URL
if ($temporaryChatEnabled) {
window.history.replaceState(window.history.state, '', '?temporary-chat=true');
} else {
window.history.replaceState(window.history.state, '', location.pathname);
}
}}
aria-label={$i18n.t(`Temporary Chat`)}
>
{#if $temporaryChatEnabled}
<ChatBubbleDottedChecked className="size-4" strokeWidth="1.5" />
{:else}
<ChatBubbleDotted className="size-4" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{:else if chat?.id && $temporaryChatEnabled}
<Tooltip content={$i18n.t(`Save Chat`)}>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="save-temporary-chat-button"
on:click={async () => {
onSaveTempChat();
}}
aria-label={$i18n.t(`Save Chat`)}
>
<ChatCheck className="size-4.5" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{/if}
{#if $mobile && !$temporaryChatEnabled && chat && chat.id}
<Tooltip content={$i18n.t('New Chat')}>
<button
class="flex size-6 {$showSidebar
? 'md:hidden'
: ''} cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
on:click={() => {
initNewChat();
}}
aria-label={$i18n.t('New Chat')}
>
<ChatPlus className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{#if liveParticipants.length > 0}
<ChatParticipants participants={liveParticipants} mode="presence" avatarClass="size-5" />
{/if}
{#if shareEnabled && chat?.id && (Object.keys(history?.messages ?? {}).length > 0 || scrollToTop || ($artifactContents ?? []).length > 0 || (!readOnly && !$temporaryChatEnabled) || $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true))}
<Menu
{chat}
{shareEnabled}
{readOnly}
{scrollToTop}
shareHandler={() => {
showShareChatModal = !showShareChatModal;
}}
{renameHandler}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
}}
deleteChatHandler={() => {
deleteChatHandler(chat.id);
}}
{moveChatHandler}
>
<button
class="flex shrink-0 cursor-pointer items-center justify-center {tabbed
? 'size-4 rounded text-gray-400 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-white'
: 'size-6 rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200'}"
id="chat-context-menu-button"
aria-label={$i18n.t('Chat actions')}
>
<EllipsisHorizontal className={tabbed ? 'size-3' : 'size-4.5'} strokeWidth="1.5" />
</button>
</Menu>
{/if}
{/snippet}
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<button
@ -155,6 +300,7 @@
/>
<nav
class:hidden={tabbed}
class="sticky top-0 z-30 w-full {$mobile
? 'pt-1.5'
: ''} pb-1 -mb-12 flex flex-col items-center drag-region"
@ -195,42 +341,8 @@
>
{#if chat?.id}
<div class="flex max-w-full min-w-0 items-center gap-2 mr-2">
{#if confirmEdit}
<input
bind:this={chatTitleInput}
bind:value={chatTitle}
class="min-w-0 flex-1 bg-transparent md:py-1 text-[0.9375rem] font-normal text-gray-700 outline-hidden dark:text-gray-300"
aria-label={$i18n.t('Title')}
placeholder={generating ? $i18n.t('Generating...') : ''}
disabled={generating}
on:keydown={(e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
editChatTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
confirmEdit = false;
}
}}
on:blur={editChatTitle}
/>
<Tooltip content={$i18n.t('Generate')}>
<button
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="generate-title-button"
aria-label={$i18n.t('Generate')}
disabled={generating}
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
generateTitleHandler();
}}
>
<Sparkles strokeWidth="1.5" />
</button>
</Tooltip>
{#if confirmEdit && !tabbed}
{@render renderTitleEditor()}
{:else}
<h1
class="min-w-0 truncate md:py-1 text-left text-[0.9375rem] font-normal text-gray-700 dark:text-gray-300"
@ -267,107 +379,7 @@
<div class="lg:mr-1 flex flex-none items-center gap-2 self-center">
<!-- <div class="md:hidden flex self-center w-[0.0625rem] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
{#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true}
{#if !chat?.id}
<Tooltip content={$i18n.t(`Temporary Chat`)}>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="temporary-chat-button"
on:click={async () => {
if (($settings?.temporaryChatByDefault ?? false) && $temporaryChatEnabled) {
// for proper initNewChat handling
await temporaryChatEnabled.set(null);
} else {
await temporaryChatEnabled.set(!$temporaryChatEnabled);
}
if ($page.url.pathname !== '/' && !$page.url.pathname.startsWith('/folders/')) {
await goto('/');
}
// add 'temporary-chat=true' to the URL
if ($temporaryChatEnabled) {
window.history.replaceState(window.history.state, '', '?temporary-chat=true');
} else {
window.history.replaceState(window.history.state, '', location.pathname);
}
}}
aria-label={$i18n.t(`Temporary Chat`)}
>
{#if $temporaryChatEnabled}
<ChatBubbleDottedChecked className="size-4" strokeWidth="1.5" />
{:else}
<ChatBubbleDotted className="size-4" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{:else if $temporaryChatEnabled}
<Tooltip content={$i18n.t(`Save Chat`)}>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="save-temporary-chat-button"
on:click={async () => {
onSaveTempChat();
}}
aria-label={$i18n.t(`Save Chat`)}
>
<ChatCheck className="size-4.5" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{/if}
{#if $mobile && !$temporaryChatEnabled && chat && chat.id}
<Tooltip content={$i18n.t('New Chat')}>
<button
class="flex size-6 {$showSidebar
? 'md:hidden'
: ''} cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
on:click={() => {
initNewChat();
}}
aria-label={$i18n.t('New Chat')}
>
<ChatPlus className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{#if liveParticipants.length > 0}
<ChatParticipants
participants={liveParticipants}
mode="presence"
avatarClass="size-5"
/>
{/if}
{#if shareEnabled && chat?.id && (Object.keys(history?.messages ?? {}).length > 0 || scrollToTop || ($artifactContents ?? []).length > 0 || (!readOnly && !$temporaryChatEnabled) || $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true))}
<Menu
{chat}
{shareEnabled}
{readOnly}
{scrollToTop}
shareHandler={() => {
showShareChatModal = !showShareChatModal;
}}
{renameHandler}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
}}
deleteChatHandler={() => {
deleteChatHandler(chat.id);
}}
{moveChatHandler}
>
<button
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="chat-context-menu-button"
aria-label={$i18n.t('Chat actions')}
>
<EllipsisHorizontal className="size-4.5" strokeWidth="1.5" />
</button>
</Menu>
{/if}
{#if !tabbed}{@render renderChatActions()}{/if}
{#if history.currentId || $selectedTerminalId}
<Tooltip content={$i18n.t('Tabs')}>

View file

@ -4180,6 +4180,7 @@
"Use default": "",
"Use device vibration feedback on supported Android devices.": "",
"Use discovered scopes": "",
"Use full-width tabs": "",
"Use global setting": "",
"Use groups to organize your users and assign permissions.": "",
"Use in prompt": "",
@ -4187,6 +4188,7 @@
"Use segmented retrieval for focused and relevant content extraction.": "",
"Use segmented retrieval for focused and relevant context.": "",
"Use segmented retrieval for focused context.": "",
"Use side tabs": "",
"Use these in model system prompts as {{example}}.": "",
"Use Web Search?": "",
"Used in native function calling mode. Workspace settings and user permissions still apply.": "Used in native function calling mode. Workspace settings and user permissions still apply.",