feat(frontend): add inline session title rename in sidebar

Allow users to rename chat sessions from the sidebar menu with inline editing, API persistence, and i18n feedback.
This commit is contained in:
wizardchen
2026-07-09 00:06:53 +08:00
committed by lyingbug
parent 6ac1703198
commit d287c70358
9 changed files with 132 additions and 4 deletions
+5 -1
View File
@@ -26,6 +26,10 @@ export async function generateSessionsTitle(session_id: string, data: any) {
return post(`/api/v1/sessions/${session_id}/generate_title`, data);
}
export async function updateSession(session_id: string, data: { title: string; description?: string }) {
return put(`/api/v1/sessions/${session_id}`, data);
}
export async function knowledgeChat(data: { session_id: string; query: string; }) {
return postChat(`/api/v1/knowledge-chat/${data.session_id}`, { query: data.query, channel: "web" });
}
@@ -75,4 +79,4 @@ export async function stopSession(session_id: string, message_id: string) {
export async function clearSessionMessages(session_id: string) {
return del(`/api/v1/sessions/${session_id}/messages`);
}
}
+70 -2
View File
@@ -17,7 +17,22 @@
@click.stop
@change="emit('toggle-select')"
/>
<span class="submenu_title" :class="batchMode ? 'submenu_title--batch' : ''" :title="item.title">
<form
v-if="titleEditing"
class="session-title-edit"
@submit.prevent="submitTitleEdit"
@click.stop
>
<input
ref="titleInputRef"
v-model="titleDraft"
class="session-title-edit__input"
:maxlength="SESSION_TITLE_MAX_LENGTH"
@keydown.esc.prevent="cancelTitleEdit"
@blur="submitTitleEdit"
/>
</form>
<span v-else class="submenu_title" :class="batchMode ? 'submenu_title--batch' : ''" :title="item.title">
<t-icon v-if="item.is_pinned" name="pin" class="submenu_pin_icon" />
<span class="submenu_title-text">{{ item.title }}</span>
</span>
@@ -64,6 +79,7 @@
<script setup lang="ts">
import { onBeforeUnmount, nextTick, ref } from 'vue'
import { normalizeSessionTitleDraft, SESSION_TITLE_MAX_LENGTH } from './sessionTitleEdit'
interface SessionMenuOption {
content: string
@@ -72,7 +88,7 @@ interface SessionMenuOption {
prefixIcon?: any
}
defineProps<{
const props = defineProps<{
item: { id: string; path: string; title: string; is_pinned?: boolean }
batchMode: boolean
activePath: string
@@ -86,6 +102,7 @@ const emit = defineEmits<{
(e: 'navigate'): void
(e: 'toggle-select'): void
(e: 'menu-click', data: { value: string }): void
(e: 'rename-submit', data: { title: string }): void
(e: 'hover-in'): void
(e: 'hover-out'): void
}>()
@@ -95,7 +112,10 @@ const MENU_GAP = 4
const VIEWPORT_MARGIN = 8
const menuOpen = ref(false)
const titleEditing = ref(false)
const titleDraft = ref('')
const triggerRef = ref<HTMLButtonElement | null>(null)
const titleInputRef = ref<HTMLInputElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
const updateMenuPosition = (): void => {
@@ -138,7 +158,36 @@ const toggleMenu = (): void => {
})
}
const startTitleEdit = (): void => {
closeMenu()
titleDraft.value = props.item.title || ''
titleEditing.value = true
nextTick(() => {
titleInputRef.value?.focus()
titleInputRef.value?.select()
})
}
const cancelTitleEdit = (): void => {
titleEditing.value = false
titleDraft.value = ''
}
const submitTitleEdit = (): void => {
if (!titleEditing.value) return
const nextTitle = normalizeSessionTitleDraft(titleDraft.value)
const currentTitle = normalizeSessionTitleDraft(props.item.title || '')
titleEditing.value = false
titleDraft.value = ''
if (!nextTitle || nextTitle === currentTitle) return
emit('rename-submit', { title: nextTitle })
}
const handleMenuClick = (option: SessionMenuOption): void => {
if (option.value === 'rename') {
startTitleEdit()
return
}
closeMenu()
emit('menu-click', { value: option.value })
}
@@ -158,6 +207,25 @@ onBeforeUnmount(() => {
flex: 0 0 auto;
}
.session-title-edit {
flex: 1 1 auto;
min-width: 0;
}
.session-title-edit__input {
width: 100%;
height: 26px;
padding: 0 8px;
border: 1px solid var(--td-brand-color);
border-radius: 5px;
color: var(--td-text-color-primary);
background: var(--td-bg-color-container);
font-size: 14px;
line-height: 24px;
outline: none;
box-shadow: 0 0 0 2px var(--td-brand-color-light);
}
.menu-more-wrap {
display: inline-flex;
align-items: center;
+23 -1
View File
@@ -153,6 +153,7 @@
@navigate="gotopage(subitem.path)"
@toggle-select="toggleBatchSelect(subitem.id)"
@menu-click="handleSessionMenuClick($event, subitem)"
@rename-submit="renameSessionTitle(subitem, $event.title)"
@hover-in="mouseenteBotDownr(subitem.id)" @hover-out="mouseleaveBotDown" />
</div>
</div>
@@ -201,7 +202,7 @@
import { storeToRefs } from 'pinia';
import { onMounted, onUnmounted, watch, computed, ref, h, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getSessionsList, delSession, batchDelSessions, deleteAllSessions, clearSessionMessages, pinSession, unpinSession } from "@/api/chat/index";
import { getSessionsList, delSession, batchDelSessions, deleteAllSessions, clearSessionMessages, pinSession, unpinSession, updateSession } from "@/api/chat/index";
import { useChatResourcesStore } from '@/stores/chatResources';
import { listAllIMChannels } from '@/api/agent/index';
import SessionSidebarRow from './SessionSidebarRow.vue';
@@ -616,6 +617,7 @@ const buildSessionMenuOptions = (item: any) => {
});
}
options.push(
{ content: t('menu.renameSession'), value: 'rename', prefixIcon: () => h(TIcon, { name: 'edit-1', size: '16px' }) },
{ content: t('menu.clearMessages'), value: 'clearMessages', prefixIcon: () => h(TIcon, { name: 'clear', size: '16px' }) },
{ content: t('menu.batchManage'), value: 'batchManage', prefixIcon: () => h(TIcon, { name: 'queue', size: '16px' }) },
{ content: t('upload.deleteRecord'), value: 'delete', theme: 'error', prefixIcon: () => h(TIcon, { name: 'delete', size: '16px' }) },
@@ -638,6 +640,26 @@ const updateSessionInBuckets = (
syncMenuStoreFromBuckets();
};
const renameSessionTitle = async (item: any, title: string) => {
try {
const res: any = await updateSession(item.id, {
title,
description: item.description || '',
});
if (res && res.success) {
updateSessionInBuckets(item.id, {
title: res.data?.title || title,
isNoTitle: false,
});
MessagePlugin.success(t('menu.renameSessionSuccess'));
} else {
MessagePlugin.error(t('menu.renameSessionFailed'));
}
} catch {
MessagePlugin.error(t('menu.renameSessionFailed'));
}
};
const togglePin = (item: any, pin: boolean) => {
if (pinningIds.value.has(item.id)) return;
pinningIds.value.add(item.id);
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeSessionTitleDraft } from './sessionTitleEdit.ts'
test('normalizeSessionTitleDraft trims whitespace and collapses inner runs', () => {
assert.equal(normalizeSessionTitleDraft(' Quarterly summary\t review '), 'Quarterly summary review')
})
test('normalizeSessionTitleDraft rejects blank titles after trimming', () => {
assert.equal(normalizeSessionTitleDraft(' \n\t '), '')
})
test('normalizeSessionTitleDraft caps long titles at 80 characters', () => {
const input = 'x'.repeat(90)
assert.equal(normalizeSessionTitleDraft(input), 'x'.repeat(80))
})
@@ -0,0 +1,5 @@
export const SESSION_TITLE_MAX_LENGTH = 80
export function normalizeSessionTitleDraft(value: string): string {
return value.trim().replace(/\s+/g, ' ').slice(0, SESSION_TITLE_MAX_LENGTH)
}
+3
View File
@@ -15,6 +15,9 @@ export default {
clearMessages: 'Clear Messages',
clearMessagesSuccess: 'Messages cleared',
clearMessagesFailed: 'Failed to clear messages, please try again later',
renameSession: 'Rename',
renameSessionSuccess: 'Title updated',
renameSessionFailed: 'Failed to update title, please try again later',
batchManage: 'Batch Manage',
newSession: 'New Chat',
pin: 'Pin',
+3
View File
@@ -15,6 +15,9 @@ export default {
clearMessages: "메시지 지우기",
clearMessagesSuccess: "메시지가 지워졌습니다",
clearMessagesFailed: "메시지 지우기 실패, 나중에 다시 시도해 주세요",
renameSession: "제목 수정",
renameSessionSuccess: "제목이 업데이트되었습니다",
renameSessionFailed: "제목 수정 실패, 나중에 다시 시도해 주세요",
batchManage: "일괄 관리",
newSession: "새 세션",
pin: "고정",
+3
View File
@@ -12,6 +12,9 @@ export default {
clearMessages: 'Очистить сообщения',
clearMessagesSuccess: 'Сообщения очищены',
clearMessagesFailed: 'Не удалось очистить сообщения, попробуйте позже',
renameSession: 'Переименовать',
renameSessionSuccess: 'Название обновлено',
renameSessionFailed: 'Не удалось обновить название, попробуйте позже',
batchManage: 'Пакетное управление',
newSession: 'Новый диалог',
pin: 'Закрепить',
+3
View File
@@ -15,6 +15,9 @@ export default {
clearMessages: "清空消息",
clearMessagesSuccess: "消息已清空",
clearMessagesFailed: "清空消息失败,请稍后再试",
renameSession: "修改标题",
renameSessionSuccess: "标题已更新",
renameSessionFailed: "修改标题失败,请稍后再试",
batchManage: "批量管理",
newSession: "新会话",
pin: "置顶",