Skip to content
Open
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
134 changes: 74 additions & 60 deletions dashboard/src/components/chat/Chat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,55 +81,57 @@
/>
</div>

<div v-if="!isSidebarCollapsed" class="session-list">
<div
v-for="session in sessions"
:key="session.session_id"
class="session-item"
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
role="button"
tabindex="0"
@click="selectSession(session.session_id)"
@keydown.enter="selectSession(session.session_id)"
@keydown.space.prevent="selectSession(session.session_id)"
>
<span v-if="!isSidebarCollapsed" class="session-title">{{
sessionTitle(session)
}}</span>
<div class="session-actions" @click.stop>
<v-btn
icon="mdi-pencil-outline"
size="x-small"
variant="text"
class="session-action-btn"
:title="tm('conversation.editDisplayName')"
@click="editSidebarSessionTitle(session)"
/>
<v-btn
icon="mdi-delete-outline"
size="x-small"
variant="text"
class="session-action-btn"
:title="tm('actions.deleteChat')"
@click="deleteSidebarSession(session)"
<OverlayScrollbar v-if="!isSidebarCollapsed" class="session-list-scroll">
<div class="session-list">
<div
v-for="session in sessions"
:key="session.session_id"
class="session-item"
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
role="button"
tabindex="0"
@click="selectSession(session.session_id)"
@keydown.enter="selectSession(session.session_id)"
@keydown.space.prevent="selectSession(session.session_id)"
>
<span v-if="!isSidebarCollapsed" class="session-title">{{
sessionTitle(session)
}}</span>
<div class="session-actions" @click.stop>
<v-btn
icon="mdi-pencil-outline"
size="x-small"
variant="text"
class="session-action-btn"
:title="tm('conversation.editDisplayName')"
@click="editSidebarSessionTitle(session)"
/>
<v-btn
icon="mdi-delete-outline"
size="x-small"
variant="text"
class="session-action-btn"
:title="tm('actions.deleteChat')"
@click="deleteSidebarSession(session)"
/>
</div>
<v-progress-circular
v-if="isSessionRunning(session.session_id)"
class="session-progress"
indeterminate
size="16"
width="2"
/>
</div>
<v-progress-circular
v-if="isSessionRunning(session.session_id)"
class="session-progress"
indeterminate
size="16"
width="2"
/>
</div>

<div
v-if="!isSidebarCollapsed && !sessions.length && !loadingSessions"
class="empty-sessions"
>
{{ tm("conversation.noHistory") }}
<div
v-if="!isSidebarCollapsed && !sessions.length && !loadingSessions"
class="empty-sessions"
>
{{ tm("conversation.noHistory") }}
</div>
</div>
</div>
</OverlayScrollbar>

<div class="sidebar-footer">
<StyledMenu
Expand Down Expand Up @@ -337,14 +339,16 @@
</ProjectView>

<template v-else>
<section
ref="messagesContainer"
<OverlayScrollbar
ref="messagesScrollbar"
class="messages-panel"
@scroll="handleMessagesScroll"
>
<div v-if="loadingMessages" class="center-state">
<v-progress-circular indeterminate size="32" width="3" />
</div>
<section
class="messages-panel-inner"
>
<div v-if="loadingMessages" class="center-state">
<v-progress-circular indeterminate size="32" width="3" />
</div>

<div v-else-if="sessionProject" class="session-project-breadcrumb">
<span>{{ sessionProject.title }}</span>
Expand Down Expand Up @@ -387,6 +391,7 @@
/>
</div>
</section>
</OverlayScrollbar>

<section class="composer-shell">
<ChatInput
Expand Down Expand Up @@ -505,6 +510,7 @@ import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import axios from "axios";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import OverlayScrollbar from "@/components/shared/OverlayScrollbar.vue";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
Expand Down Expand Up @@ -607,6 +613,7 @@ const projectSessions = ref<Session[]>([]);
const loadingSessions = ref(false);
const draft = ref("");
const messagesContainer = ref<HTMLElement | null>(null);
const messagesScrollbar = ref<InstanceType<typeof OverlayScrollbar> | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const shouldStickToBottom = ref(true);
const replyTarget = ref<ChatRecord | null>(null);
Expand Down Expand Up @@ -766,6 +773,13 @@ onMounted(async () => {
} finally {
loadingSessions.value = false;
}
// Bind messagesContainer to OverlayScrollbar viewport
nextTick(() => {
if (messagesScrollbar.value?.viewport) {
messagesContainer.value = messagesScrollbar.value.viewport;
messagesContainer.value.addEventListener('scroll', handleMessagesScroll);
Comment on lines +777 to +780

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The scroll listener attached in nextTick is never explicitly removed, which can lead to subtle leaks across remounts.

Because the handler is manually attached to messagesContainer in nextTick, there should be a matching removeEventListener in onBeforeUnmount (or use a composable that handles setup/teardown). This prevents leaks or unexpected behavior if messagesContainer is reused or the component is mounted/unmounted often.

Suggested implementation:

onBeforeUnmount(() => {
  if (messagesContainer.value) {
    messagesContainer.value.removeEventListener("scroll", handleMessagesScroll);
  }
  transform: rotate(180deg);
}

The snippet you provided shows transform: rotate(180deg); inside onBeforeUnmount, which looks like a truncated or mis-placed style declaration. If this is not actually part of the unmount hook in your real file, you should instead:

  1. Locate the real onBeforeUnmount hook (or create one if it doesn’t exist).
  2. Add only the cleanup logic inside it:
    onBeforeUnmount(() => {
      if (messagesContainer.value) {
        messagesContainer.value.removeEventListener("scroll", handleMessagesScroll);
      }
    });
  3. Ensure any existing logic in onBeforeUnmount (if present) is preserved alongside this cleanup.

}
});
});
Comment on lines +776 to 783

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

onMounted 中使用单次 nextTick 来绑定滚动事件监听器存在一个严重的 Bug。

由于 messagesScrollbar 位于 v-else 分支中(当 selectedProject 为真时,渲染的是 ProjectView,此时 messagesScrollbarnull),如果组件初始化时 selectedProject 为真,onMounted 执行时 messagesScrollbar.value 将为 null,导致滚动事件监听器永远无法被绑定。当用户随后切换到普通会话(selectedProject 变为假)时,滚动监听器依然缺失,这会导致“滚动到底部”、“滚动到指定消息”等核心聊天功能失效。

解决方案
建议移除 onMounted 中的单次绑定逻辑,改为使用 Vue 3 的 watch 动态监听 messagesScrollbar 的变化。这样无论是初始化还是后续视图切换,只要 messagesScrollbar 被挂载,就能正确绑定/解绑事件监听器,同时也能避免潜在的内存泄漏。

});

// 动态绑定 messagesContainer 到 OverlayScrollbar 视口,支持视图切换
watch(messagesScrollbar, (newVal, oldVal) => {
  if (oldVal?.viewport) {
    oldVal.viewport.removeEventListener('scroll', handleMessagesScroll);
  }
  if (newVal?.viewport) {
    messagesContainer.value = newVal.viewport;
    newVal.viewport.addEventListener('scroll', handleMessagesScroll);
  } else {
    messagesContainer.value = null;
  }
});


onBeforeUnmount(() => {
Expand Down Expand Up @@ -1496,9 +1510,12 @@ function toggleTheme() {
transform: rotate(180deg);
}

.session-list {
.session-list-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
}

.session-list {
padding: 4px 12px 12px;
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -1640,7 +1657,9 @@ function toggleTheme() {
.messages-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
}

.messages-panel-inner {
padding: 24px max(24px, calc((100% - 980px) / 2)) 18px;
}

Expand Down Expand Up @@ -1773,13 +1792,8 @@ kbd {
}

@media (max-width: 760px) {
.messages-panel {
.messages-panel-inner {
padding: 18px 14px;
}

.composer-shell,
.project-composer-shell {
padding: 0;
}
}
</style>
46 changes: 2 additions & 44 deletions dashboard/src/components/chat/ChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
<div
class="input-container"
:style="{
width: '85%',
maxWidth: '900px',
width: '100%',
maxWidth: '800px',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
Expand Down Expand Up @@ -1253,46 +1253,4 @@ defineExpose({
}
}

@media (max-width: 768px) {
.input-area {
padding: 0 !important;
}

.input-container {
width: 100% !important;
max-width: 100% !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}

.input-outline-control {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
}

.input-area textarea,
.chat-textarea {
min-height: 28px !important;
max-height: 140px !important;
font-size: 16px !important;
line-height: 20px !important;
padding: 8px 14px 7px !important;
}

.attachments-preview {
margin: 8px 10px 0;
gap: 8px;
}

.attachment-card {
width: min(220px, calc(100vw - 28px));
height: 58px;
}

.image-preview {
width: 58px;
flex-basis: 58px;
}
}
</style>
26 changes: 20 additions & 6 deletions dashboard/src/components/provider/AddNewProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@
<v-dialog v-model="showDialog" max-width="1000px" >
<v-card :title="tm('dialogs.addProvider.title')">
<v-card-text style="overflow-y: auto;">
<v-tabs v-model="activeProviderTab" grow>
<v-tab value="agent_runner" class="font-weight-medium px-3">
<v-tabs v-model="activeProviderTab">
<v-tab value="agent_runner" class="font-weight-medium">
<v-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-tab value="speech_to_text" class="font-weight-medium">
<v-icon start>mdi-microphone-message</v-icon>
{{ tm('dialogs.addProvider.tabs.speechToText') }}
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-tab value="text_to_speech" class="font-weight-medium">
<v-icon start>mdi-volume-high</v-icon>
{{ tm('dialogs.addProvider.tabs.textToSpeech') }}
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-tab value="embedding" class="font-weight-medium">
<v-icon start>mdi-code-json</v-icon>
{{ tm('dialogs.addProvider.tabs.embedding') }}
</v-tab>
<v-tab value="rerank" class="font-weight-medium px-3">
<v-tab value="rerank" class="font-weight-medium">
<v-icon start>mdi-compare-vertical</v-icon>
{{ tm('dialogs.addProvider.tabs.rerank') }}
</v-tab>
Expand Down Expand Up @@ -240,4 +240,18 @@ export default {
font-weight: bold;
opacity: 0.3;
}

@media (max-width: 960px) {
:deep(.v-tab) {
min-width: 48px !important;
padding: 0 10px !important;
}
:deep(.v-tab) .v-btn__content {
font-size: 0;
}
:deep(.v-tab) .v-btn__content .v-icon {
font-size: 20px;
margin-inline-end: 0;
}
}
</style>
Loading
Loading