agent-Specialization/static/src/components/sidebar/ConversationSidebar.vue
JOJO 03cb39924a feat(host): support concurrent workspace conversations
Implement host-mode multi-workspace concurrency while preserving one active task per workspace. Host terminals are scoped by workspace id, host workspace switching no longer globally refreshes or blocks on tasks, and task APIs now expose workspace/conversation metadata for frontend coordination.

Update conversation navigation so creating/loading conversations during an active workspace task is view-only and does not mutate backend terminal context. This prevents running task output from being pushed into the currently viewed conversation after switching or creating a new conversation.

Add running task awareness to the frontend sidebar, composer, and polling flow. Running tasks can be shown across workspaces, current-workspace active conversations render inline loaders, completed unviewed tasks persist as check indicators across refresh, and completed tasks clear once viewed.

Restore running conversations correctly after switching workspace/conversation or refreshing during an active task. REST polling is rebound only for the matching conversation/task, stale poll responses are ignored, empty assistant placeholders are restored before first content, and completion clears loader/stop states without requiring page refresh.

Adjust host workspace UX so switching workspaces loads the active conversation if one is running, otherwise opens the new-conversation state. Other conversations in a busy workspace are view-only with disabled input instead of blocked navigation.

Validation: python3 -m py_compile server/auth.py server/context.py server/conversation.py server/status.py server/tasks.py; python3 -m unittest test.test_server_refactor_smoke; npm run build --silent 2>&1 | tail -n 12.
2026-05-29 17:54:11 +08:00

417 lines
15 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" 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>
<div
v-if="loading && !displayConversations.length && !searchActive"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading" 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'
}"
@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="更多操作"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id)"
class="conversation-actions-menu"
:class="{ open: openActionMenuId === conv.id }"
>
<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>
<div v-if="!searchActive && 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 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, onBeforeUnmount, onMounted, ref } 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;
}>(),
{
showCollapseButton: true,
collapseButtonVariant: 'toggle',
displayMode: 'chat',
displayModeDisabled: false,
runningTasks: () => [],
currentWorkspaceId: ''
}
);
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;
}>();
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,
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 toggleActionMenu = (conversationId: string) => {
openActionMenuId.value = openActionMenuId.value === conversationId ? null : conversationId;
};
const closeActionMenu = () => {
openActionMenuId.value = null;
};
onMounted(() => {
document.addEventListener('click', closeActionMenu);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
});
const displayConversations = computed(() =>
searchActive.value ? searchResults.value : conversations.value
);
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
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 || ''));
</script>