agent-Specialization/static/src/components/sidebar/ConversationSidebar.vue
JOJO 5244768494 feat(sidebar): 侧边栏按工作区/项目分组显示对话
- 新增个人化设置 group_sidebar_by_workspace / sidebar_pinned_workspaces /
  sidebar_workspace_order,支持分组视图、置顶、排序
- 后端 conversation API 支持 workspace_id 参数,用于获取/创建/加载/删除
  指定工作区的对话,避免切换视图时 session 漂移
- server/context.py 新增 update_session 参数,临时解析目标 terminal 时不污染
  当前会话
- 前端 ConversationSidebar 实现分组折叠、运行中指示器、新建/更多操作菜单、
  动画同步
- config/terminal.py 默认 TERMINAL_SANDBOX_MODE 由 docker 改为 host
2026-07-11 01:51:01 +08:00

1040 lines
40 KiB
Vue

<template>
<aside class="conversation-sidebar" :class="{ collapsed, 'monitor-mode': monitorModeActive }">
<div class="conversation-sidebar-inner">
<div class="conversation-primary-actions">
<button
type="button"
class="sidebar-nav-row conversation-menu-btn"
:title="collapsed ? '展开对话记录' : '收起对话记录'"
@click="$emit('toggle')"
>
<span
class="sidebar-nav-icon chat-icon"
data-tutorial="conversation-menu"
aria-hidden="true"
>
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5 6.5c0-1.38 1.12-2.5 2.5-2.5h13c1.38 0 2.5 1.12 2.5 2.5v8.5c0 1.38-1.12 2.5-2.5 2.5h-5.6l-3.4 3.2.6-3.2H7.5c-1.38 0-2.5-1.12-2.5-2.5V6.5z"
stroke="currentColor"
stroke-width="1.45"
stroke-linejoin="round"
/>
<path
d="M9 9.5h10"
stroke="currentColor"
stroke-width="1.45"
stroke-linecap="round"
/>
<path d="M9 13h6" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" />
</svg>
</span>
<span class="sidebar-nav-label">对话记录</span>
</button>
<button type="button" class="sidebar-nav-row" title="新建对话" @click="$emit('create')">
<span
class="sidebar-nav-icon pencil-icon"
data-tutorial="quick-new-conversation"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
/>
<path d="m15 5 4 4" />
</svg>
</span>
<span class="sidebar-nav-label">新建对话</span>
</button>
<button
type="button"
class="sidebar-nav-row workspace-control-btn"
:class="{ active: workspaceCollapsed }"
:title="workspaceCollapsed ? '显示工作区' : '隐藏工作区'"
@click="$emit('toggle-workspace')"
>
<span class="sidebar-nav-icon" data-tutorial="workspace-toggle" aria-hidden="true">
<span class="icon icon-md" :style="iconStyle('layers')"></span>
</span>
<span class="sidebar-nav-label">工作区</span>
</button>
<button
type="button"
class="sidebar-nav-row monitor-mode-btn"
:class="{ active: displayMode === 'monitor', blocked: displayModeDisabled }"
:aria-disabled="displayModeDisabled"
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
@click="$emit('toggle-display-mode')"
>
<span class="sidebar-nav-icon" data-tutorial="monitor-toggle" aria-hidden="true">
<span class="icon icon-md" :style="iconStyle('monitor')"></span>
</span>
<span class="sidebar-nav-label">虚拟显示器</span>
</button>
</div>
<div class="conversation-search">
<label class="search-input-wrap">
<span class="sidebar-nav-icon search-inline-icon" aria-hidden="true">
<span class="icon icon-sm" :style="iconStyle('search')"></span>
</span>
<input
class="search-input"
:value="searchQuery"
placeholder="搜索对话"
@input="$emit('search', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="
$emit('search-submit', ($event.target as HTMLInputElement).value)
"
/>
</label>
</div>
<div class="conversation-list">
<div v-if="runningTaskItems.length && !searchActive && !isGroupByWorkspaceActive" class="running-task-section">
<div class="running-task-section-title">运行中的任务</div>
<button
v-for="task in runningTaskItems"
:key="task.task_id"
type="button"
class="running-task-item"
:class="{ active: task.conversation_id === currentConversationId }"
@click="$emit('select-running-task', task)"
>
<span class="running-task-workspace">{{ task.workspace_label || task.workspace_id }}</span>
<span class="running-task-main">
<span class="running-task-title">{{
task.conversation_title || task.message || '运行中的对话'
}}</span>
<span
v-if="isTaskActive(task)"
class="conversation-running-loader running-task-state"
aria-label="运行中"
></span>
<span v-else class="conversation-complete-check running-task-state" aria-label="已完成"></span>
</span>
</button>
</div>
<template v-if="isGroupByWorkspaceActive && !searchActive">
<div class="workspace-groups">
<div
v-for="ws in sortedWorkspaces"
:key="String(ws.workspace_id || ws.label)"
class="workspace-group"
>
<div
class="workspace-group-header"
:class="{ active: String(ws.workspace_id || '') === currentWorkspaceId }"
@mouseenter="hoverWorkspaceId = String(ws.workspace_id || '')"
@mouseleave="hoverWorkspaceId = null"
>
<button
type="button"
class="workspace-group-toggle"
@click="toggleWorkspaceExpanded(String(ws.workspace_id || ''))"
>
<span
class="icon icon-sm workspace-folder-icon"
:style="iconStyle(isWorkspaceExpanded(String(ws.workspace_id || '')) ? 'folderOpen' : 'folderClosed')"
aria-hidden="true"
></span>
<span class="workspace-group-label">{{ ws.label || ws.workspace_id }}</span>
<span
v-if="String(ws.workspace_id || '') === currentWorkspaceId"
class="workspace-current-dot"
aria-label="当前工作区"
></span>
<span
v-if="
isWorkspaceRunning(String(ws.workspace_id || '')) &&
!isWorkspaceExpanded(String(ws.workspace_id || '')) &&
hoverWorkspaceId !== String(ws.workspace_id || '') &&
openWorkspaceMenuId !== String(ws.workspace_id || '')
"
class="workspace-running-loader"
aria-label="运行中"
></span>
</button>
<div
class="workspace-group-actions"
:class="{ visible: hoverWorkspaceId === String(ws.workspace_id || '') || openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<button
type="button"
class="workspace-new-btn"
title="新建对话"
aria-label="新建对话"
@click="handleCreateWorkspaceConversation(String(ws.workspace_id || ''))"
>
<span class="icon icon-sm" :style="iconStyle('pencil')" aria-hidden="true"></span>
</button>
<button
type="button"
class="workspace-more-btn"
title="更多操作"
aria-label="更多操作"
:aria-expanded="openWorkspaceMenuId === String(ws.workspace_id || '')"
@click.stop="toggleWorkspaceMenu(String(ws.workspace_id || ''))"
>
<span aria-hidden="true"></span>
</button>
<div
class="workspace-actions-menu"
:class="{ open: openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<button type="button" @click="handlePinWorkspace(String(ws.workspace_id || ''))">
<span>{{ pinnedWorkspaceIds.has(String(ws.workspace_id || '')) ? '取消置顶' : '置顶' }}</span>
</button>
<button
v-if="props.versioningHostMode"
type="button"
@click="handleRevealWorkspace(String(ws.workspace_id || ''))"
>
<span>在文件夹中打开</span>
</button>
<button type="button" @click="startRenameWorkspace(ws)">
<span>重命名</span>
</button>
</div>
</div>
</div>
<div
class="workspace-group-children"
:class="{ expanded: isWorkspaceExpanded(String(ws.workspace_id || '')) }"
>
<div class="workspace-group-children-inner">
<div
v-if="getWorkspaceGroup(String(ws.workspace_id || ''))?.loading && !getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
class="workspace-conversations-loading"
>
正在加载...
</div>
<div
v-else-if="!getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
class="workspace-no-conversations"
>
暂无对话
</div>
<template v-else>
<div
class="workspace-conversations-viewport"
:style="{
'--workspace-visible-count': getWorkspaceGroup(String(ws.workspace_id || ''))?.visibleLimit || 5
}"
>
<transition-group
name="conversation-delete"
tag="div"
class="workspace-conversations-list"
:class="`animation-mode-${conversationListAnimationMode}`"
>
<div
v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))"
:key="conv.id"
class="conversation-item workspace-conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource'
}"
@click="handleWorkspaceConversationClick(conv.id, String(ws.workspace_id || ''))"
>
<div class="conversation-title">{{ conv.title }}</div>
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="shouldShowActionMenu(conv.id)"
class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
</button>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', { id: conv.id, workspaceId: String(ws.workspace_id || '') });
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</transition-group>
</div>
<div
v-if="getWorkspaceHasMore(String(ws.workspace_id || ''))"
class="load-more workspace-load-more"
>
<button
class="load-more-btn"
type="button"
:disabled="getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore"
@click="loadMoreWorkspaceConversations(String(ws.workspace_id || ''))"
>
{{ getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore ? '载入中...' : '加载更多' }}
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading && !isDeletingConversation" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
<transition-group
v-else
name="conversation-delete"
tag="div"
class="conversation-list-items"
:class="`animation-mode-${conversationListAnimationMode}`"
>
<div
v-for="conv in displayConversations"
:key="conv.id"
class="conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource',
deleting: pendingDeletingConversationIds.includes(conv.id)
}"
@click="
openActionMenuId = null;
$emit('select', conv.id);
"
>
<div class="conversation-title">{{ conv.title }}</div>
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id) && openActionMenuId === conv.id"
class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
</button>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</transition-group>
</template>
<div v-if="!searchActive && !isGroupByWorkspaceActive && hasMore" class="load-more">
<button
class="load-more-btn"
type="button"
:disabled="loadingMore"
@click="$emit('load-more')"
>
{{ loadingMore ? '载入中...' : '加载更多' }}
</button>
</div>
<div v-else-if="searchActive" class="load-more search-more">
<div v-if="searchInProgress" class="search-progress">
<span class="search-spinner" aria-hidden="true">
<span class="search-spinner-orbit">
<span class="icon icon-sm search-spinner-icon" :style="iconStyle('search')"></span>
</span>
</span>
<span class="search-progress-text">搜索中...</span>
</div>
<button
v-else-if="searchMoreAvailable"
class="load-more-btn"
type="button"
@click="$emit('search-more')"
>
搜索更多
</button>
<div v-else-if="displayConversations.length" class="search-done">已全部搜索</div>
</div>
</div>
<div
v-if="renameWorkspaceId"
class="workspace-rename-overlay"
@click.self="cancelRenameWorkspace"
>
<div class="workspace-rename-card">
<div class="workspace-rename-title">
重命名{{ workspaceKind === 'workspace' ? '工作区' : '项目' }}
</div>
<input
ref="renameInput"
v-model="renameWorkspaceLabel"
type="text"
class="workspace-rename-input"
placeholder="请输入名称"
@keydown.enter.prevent="submitRenameWorkspace"
@keydown.esc.prevent="cancelRenameWorkspace"
/>
<div class="workspace-rename-actions">
<button type="button" class="workspace-rename-cancel" @click="cancelRenameWorkspace">
取消
</button>
<button type="button" class="workspace-rename-confirm" @click="submitRenameWorkspace">
确认
</button>
</div>
</div>
</div>
<div class="conversation-personal-entry" :class="{ active: personalPageVisible }">
<button
type="button"
class="sidebar-nav-row personal-page-btn"
data-tutorial="open-personal-space"
title="个人空间"
@click="$emit('personal')"
>
<span class="sidebar-nav-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
></path>
</svg>
</span>
<span class="sidebar-nav-label personal-label">个人空间</span>
</button>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
defineOptions({ name: 'ConversationSidebar' });
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui';
import { useConversationStore } from '@/stores/conversation';
import { usePersonalizationStore } from '@/stores/personalization';
const props = withDefaults(
defineProps<{
formatTime?: (input: unknown) => string;
iconStyle?: (key: string) => Record<string, string>;
showCollapseButton?: boolean;
collapseButtonVariant?: 'toggle' | 'close';
displayMode?: 'chat' | 'monitor';
displayModeDisabled?: boolean;
runningTasks?: any[];
currentWorkspaceId?: string;
workspaces?: any[];
workspaceKind?: 'workspace' | 'project';
hostWorkspaceEnabled?: boolean;
groupByWorkspace?: boolean;
versioningHostMode?: boolean;
}>(),
{
showCollapseButton: true,
collapseButtonVariant: 'toggle',
displayMode: 'chat',
displayModeDisabled: false,
runningTasks: () => [],
currentWorkspaceId: '',
workspaces: () => [],
workspaceKind: 'workspace',
hostWorkspaceEnabled: false,
groupByWorkspace: false,
versioningHostMode: false
}
);
const emit = defineEmits<{
(event: 'toggle'): void;
(event: 'create'): void;
(event: 'search', value: string): void;
(event: 'search-submit', value: string): void;
(event: 'select', id: string): void;
(event: 'select-running-task', task: any): void;
(event: 'load-more'): void;
(event: 'search-more'): void;
(event: 'personal'): void;
(event: 'delete', id: string): void;
(event: 'duplicate', id: string): void;
(event: 'toggle-workspace'): void;
(event: 'toggle-display-mode'): void;
(event: 'select-workspace-conversation', payload: { conversationId: string; workspaceId: string }): void;
(event: 'create-workspace-conversation', workspaceId: string): void;
(event: 'reveal-workspace', workspaceId: string): void;
(event: 'rename-workspace', payload: { workspaceId: string; label: string }): void;
(event: 'pin-workspace', workspaceId: string): void;
}>();
const uiStore = useUiStore();
const conversationStore = useConversationStore();
const personalizationStore = usePersonalizationStore();
const {
sidebarCollapsed: collapsed,
workspaceCollapsed,
chatDisplayMode: uiChatDisplayMode
} = storeToRefs(uiStore);
const {
searchQuery,
searchResults,
searchActive,
searchInProgress,
searchMoreAvailable,
conversations,
conversationInsertAnimations,
conversationListAnimationMode,
pendingDeletingConversationIds,
conversationsLoading,
hasMoreConversations: hasMore,
loadingMoreConversations: loadingMore,
currentConversationId
} = storeToRefs(conversationStore);
const { visible: personalPageVisible } = storeToRefs(personalizationStore);
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
const displayMode = computed(() => props.displayMode);
const monitorModeActive = computed(
() => displayMode.value === 'monitor' || uiChatDisplayMode.value === 'monitor'
);
const displayModeDisabled = computed(() => props.displayModeDisabled);
const openActionMenuId = ref<string | null>(null);
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
const hoverWorkspaceId = ref<string | null>(null);
const openWorkspaceMenuId = ref<string | null>(null);
const renameWorkspaceId = ref<string | null>(null);
const renameWorkspaceLabel = ref('');
const renameInput = ref<HTMLInputElement | null>(null);
watch(renameWorkspaceId, async (id) => {
if (id) {
await nextTick();
renameInput.value?.focus();
renameInput.value?.select();
}
});
const updateActionMenuPosition = (conversationId: string) => {
if (typeof document === 'undefined') return;
const trigger = document.querySelector(`[data-action-trigger="${conversationId}"]`) as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
actionMenuPosition.value = {
top: rect.bottom + 6,
right: (typeof window !== 'undefined' ? window.innerWidth : 0) - rect.right
};
};
const toggleActionMenu = (conversationId: string) => {
if (openActionMenuId.value === conversationId) {
openActionMenuId.value = null;
} else {
updateActionMenuPosition(conversationId);
openActionMenuId.value = conversationId;
}
};
watch(openActionMenuId, async (id) => {
if (id) {
await nextTick();
updateActionMenuPosition(id);
}
});
const shouldShowActionMenu = (conversationId: string) => {
return (
openActionMenuId.value === conversationId &&
!isConversationActive(conversationId) &&
!isConversationCompleted(conversationId)
);
};
const closeActionMenu = () => {
openActionMenuId.value = null;
};
const pinnedWorkspaceIds = ref<Set<string>>(new Set());
const workspaceOrder = ref<string[]>([]);
const sidebarSortLoaded = ref(false);
const syncPinnedFromStore = () => {
pinnedWorkspaceIds.value = new Set(
Array.isArray(personalizationStore.form.sidebar_pinned_workspaces)
? personalizationStore.form.sidebar_pinned_workspaces
: []
);
};
const syncOrderFromStore = () => {
workspaceOrder.value = Array.isArray(personalizationStore.form.sidebar_workspace_order)
? [...personalizationStore.form.sidebar_workspace_order]
: [];
};
const persistPinnedWorkspaces = () => {
if (!sidebarSortLoaded.value) return;
const next = Array.from(pinnedWorkspaceIds.value);
const current = personalizationStore.form.sidebar_pinned_workspaces || [];
if (JSON.stringify(next) === JSON.stringify(current)) return;
console.log('[sidebar-debug] persistPinnedWorkspaces', {
next: JSON.parse(JSON.stringify(next)),
current: JSON.parse(JSON.stringify(current))
});
personalizationStore.updateField({
key: 'sidebar_pinned_workspaces',
value: next
});
};
const persistWorkspaceOrder = () => {
if (!sidebarSortLoaded.value) return;
const next = Array.from(workspaceOrder.value);
const current = personalizationStore.form.sidebar_workspace_order || [];
if (JSON.stringify(next) === JSON.stringify(current)) return;
console.log('[sidebar-debug] persistWorkspaceOrder', {
next: JSON.parse(JSON.stringify(next)),
current: JSON.parse(JSON.stringify(current))
});
personalizationStore.updateField({
key: 'sidebar_workspace_order',
value: next
});
};
const bumpCurrentWorkspaceToTop = () => {
if (!sidebarSortLoaded.value) return;
const currentId = String(props.currentWorkspaceId || '');
console.log('[sidebar-debug] bumpCurrentWorkspaceToTop', {
currentId,
orderBefore: JSON.parse(JSON.stringify(workspaceOrder.value))
});
if (!currentId || pinnedWorkspaceIds.value.has(currentId)) return;
if (workspaceOrder.value[0] === currentId) return;
const order = workspaceOrder.value.filter((item) => item !== currentId);
order.unshift(currentId);
workspaceOrder.value = order;
persistWorkspaceOrder();
console.log('[sidebar-debug] bumpCurrentWorkspaceToTop after', {
orderAfter: JSON.parse(JSON.stringify(workspaceOrder.value))
});
};
const syncWorkspaceOrderWithWorkspaces = (workspaces: any[]) => {
if (!sidebarSortLoaded.value || !Array.isArray(workspaces)) return;
const ids = workspaces
.map((ws) => String(ws?.workspace_id || ''))
.filter(Boolean);
const existing = new Set(workspaceOrder.value);
const pinnedIds = new Set(
ids.filter((id) => pinnedWorkspaceIds.value.has(id))
);
const newIds = ids.filter((id) => !existing.has(id) && !pinnedIds.has(id));
const removedIds = new Set(
workspaceOrder.value.filter((id) => !ids.includes(id))
);
console.log('[sidebar-debug] syncWorkspaceOrderWithWorkspaces', {
ids,
newIds,
removedIds: Array.from(removedIds),
orderBefore: JSON.parse(JSON.stringify(workspaceOrder.value))
});
if (newIds.length || removedIds.size) {
workspaceOrder.value = workspaceOrder.value
.filter((id) => !removedIds.has(id))
.concat(newIds);
persistWorkspaceOrder();
}
};
const loadSidebarSortFromStore = () => {
if (!personalizationStore.loaded || sidebarSortLoaded.value) return;
console.log('[sidebar-debug] loadSidebarSortFromStore start', {
pinned: JSON.parse(JSON.stringify(personalizationStore.form.sidebar_pinned_workspaces)),
order: JSON.parse(JSON.stringify(personalizationStore.form.sidebar_workspace_order))
});
syncPinnedFromStore();
syncOrderFromStore();
sidebarSortLoaded.value = true;
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
syncWorkspaceOrderWithWorkspaces(props.workspaces);
}
bumpCurrentWorkspaceToTop();
console.log('[sidebar-debug] loadSidebarSortFromStore end', {
pinned: Array.from(pinnedWorkspaceIds.value),
order: JSON.parse(JSON.stringify(workspaceOrder.value))
});
};
watch(
() => personalizationStore.loaded,
(loaded) => {
if (loaded) loadSidebarSortFromStore();
},
{ immediate: true }
);
watch(
() => personalizationStore.form.sidebar_pinned_workspaces,
() => {
if (!sidebarSortLoaded.value) return;
syncPinnedFromStore();
},
{ deep: true }
);
watch(
() => personalizationStore.form.sidebar_workspace_order,
() => {
if (!sidebarSortLoaded.value) return;
syncOrderFromStore();
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
syncWorkspaceOrderWithWorkspaces(props.workspaces);
}
bumpCurrentWorkspaceToTop();
},
{ deep: true }
);
watch(
() => props.workspaces,
(list) => {
if (Array.isArray(list) && list.length > 0) {
syncWorkspaceOrderWithWorkspaces(list);
}
},
{ immediate: true, deep: true }
);
watch(
() => props.currentWorkspaceId,
() => bumpCurrentWorkspaceToTop(),
{ immediate: true }
);
const isGroupByWorkspaceActive = computed(
() => props.groupByWorkspace && props.hostWorkspaceEnabled && Array.isArray(props.workspaces) && props.workspaces.length > 0
);
const sortedWorkspaces = computed(() => {
const list = Array.isArray(props.workspaces) ? [...props.workspaces] : [];
return list.sort((a: any, b: any) => {
const aId = String(a?.workspace_id || '');
const bId = String(b?.workspace_id || '');
const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aIndex = workspaceOrder.value.indexOf(aId);
const bIndex = workspaceOrder.value.indexOf(bId);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return String(a?.label || aId).localeCompare(String(b?.label || bId));
});
});
const displayConversations = computed(() =>
searchActive.value ? searchResults.value : conversations.value
);
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
const isDeletingConversation = computed(() => conversationListAnimationMode.value === 'delete');
const runningTaskItems = computed(() =>
Array.isArray(props.runningTasks)
? props.runningTasks.filter(
(task) =>
task &&
task.task_id &&
task.conversation_id &&
task.conversation_id !== currentConversationId.value &&
String(task.workspace_id || '') !== String(props.currentWorkspaceId || '')
)
: []
);
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']);
const isTaskActive = (task: any) => activeStatuses.has(String(task?.status || ''));
const isTaskCompleted = (task: any) => terminalStatuses.has(String(task?.status || ''));
const activeConversationIds = computed(
() =>
new Set(
(Array.isArray(props.runningTasks) ? props.runningTasks : [])
.filter((task) => isTaskActive(task))
.map((task) => String(task?.conversation_id || ''))
.filter(Boolean)
)
);
const completedConversationIds = computed(
() =>
new Set(
(Array.isArray(props.runningTasks) ? props.runningTasks : [])
.filter((task) => isTaskCompleted(task))
.map((task) => String(task?.conversation_id || ''))
.filter(Boolean)
)
);
const isConversationActive = (conversationId: string) =>
activeConversationIds.value.has(String(conversationId || ''));
const isConversationCompleted = (conversationId: string) =>
completedConversationIds.value.has(String(conversationId || ''));
const isWorkspaceRunning = (workspaceId: string) =>
(Array.isArray(props.runningTasks) ? props.runningTasks : []).some(
(task) => isTaskActive(task) && String(task?.workspace_id || '') === workspaceId
);
const getWorkspaceGroup = (workspaceId: string) => {
return conversationStore.workspaceGroups.find((g) => g.workspaceId === workspaceId);
};
const getWorkspaceVisibleAndBufferConversations = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) return [];
const end = group.visibleLimit + group.bufferLimit;
return group.conversations.slice(0, end);
};
const getWorkspaceHasMore = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) return false;
return group.hasMore || group.conversations.length > group.visibleLimit;
};
const isWorkspaceExpanded = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
return group ? group.expanded : true;
};
const toggleWorkspaceExpanded = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) {
conversationStore.ensureWorkspaceGroup(workspaceId);
void conversationStore.loadWorkspaceConversations(workspaceId);
return;
}
conversationStore.setWorkspaceGroupExpanded(workspaceId, !group.expanded);
if (!group.expanded && group.conversations.length === 0 && !group.loading) {
void conversationStore.loadWorkspaceConversations(workspaceId);
}
};
const ensureWorkspaceGroup = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) {
conversationStore.ensureWorkspaceGroup(workspaceId);
void conversationStore.loadWorkspaceConversations(workspaceId);
} else if (group.conversations.length === 0 && !group.loading && !group.loadingMore) {
void conversationStore.loadWorkspaceConversations(workspaceId);
}
};
watch(
[isGroupByWorkspaceActive, sortedWorkspaces],
([active, list]) => {
if (active && list.length > 0) {
list.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
}
},
{ immediate: true }
);
const handleWorkspaceConversationClick = (conversationId: string, workspaceId: string) => {
console.log('[sidebar-debug] handleWorkspaceConversationClick', { conversationId, workspaceId });
openWorkspaceMenuId.value = null;
emit('select-workspace-conversation', { conversationId, workspaceId });
};
const handleCreateWorkspaceConversation = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('create-workspace-conversation', workspaceId);
};
const toggleWorkspaceMenu = (workspaceId: string) => {
openWorkspaceMenuId.value = openWorkspaceMenuId.value === workspaceId ? null : workspaceId;
};
const closeWorkspaceMenu = () => {
openWorkspaceMenuId.value = null;
};
const handlePinWorkspace = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
const order = [...workspaceOrder.value];
const orderIndex = order.indexOf(workspaceId);
if (pinnedWorkspaceIds.value.has(workspaceId)) {
pinnedWorkspaceIds.value.delete(workspaceId);
if (orderIndex !== -1) order.splice(orderIndex, 1);
const currentId = String(props.currentWorkspaceId || '');
const currentIndex = order.indexOf(currentId);
const insertIndex = currentIndex !== -1 ? currentIndex + 1 : 0;
order.splice(insertIndex, 0, workspaceId);
} else {
pinnedWorkspaceIds.value.add(workspaceId);
if (orderIndex !== -1) order.splice(orderIndex, 1);
}
workspaceOrder.value = order;
persistPinnedWorkspaces();
persistWorkspaceOrder();
emit('pin-workspace', workspaceId);
};
const handleRevealWorkspace = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('reveal-workspace', workspaceId);
};
const startRenameWorkspace = (workspace: any) => {
openWorkspaceMenuId.value = null;
renameWorkspaceId.value = String(workspace?.workspace_id || '');
renameWorkspaceLabel.value = String(workspace?.label || workspace?.workspace_id || '');
};
const submitRenameWorkspace = () => {
const workspaceId = renameWorkspaceId.value;
const label = renameWorkspaceLabel.value.trim();
if (workspaceId && label) {
emit('rename-workspace', { workspaceId, label });
}
renameWorkspaceId.value = null;
renameWorkspaceLabel.value = '';
};
const cancelRenameWorkspace = () => {
renameWorkspaceId.value = null;
renameWorkspaceLabel.value = '';
};
const loadMoreWorkspaceConversations = (workspaceId: string) => {
void conversationStore.loadMoreWorkspaceConversations(workspaceId);
};
onMounted(() => {
document.addEventListener('click', closeActionMenu);
document.addEventListener('click', closeWorkspaceMenu);
if (isGroupByWorkspaceActive.value) {
sortedWorkspaces.value.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
document.removeEventListener('click', closeWorkspaceMenu);
});
</script>