Merge pull request #1316 from pikasTech/fix/issue-1313-loading-state

修复 Workbench session 列表加载态
This commit is contained in:
Lyon
2026-06-16 08:02:51 +08:00
committed by GitHub
6 changed files with 92 additions and 16 deletions
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts";
import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
test("R2 composer ignores stale workspace activeTraceId without verified active message", () => {
const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" });
@@ -45,6 +45,12 @@ test("R2 workspace snapshots cannot override a newer explicit session selection"
}), true);
});
test("R2 session list shows loading until conversations are ready", () => {
assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: false }), true);
assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: true }), false);
assert.equal(shouldShowSessionListLoading({ loading: false, conversationsReady: false }), false);
});
test("R2 drafts keep recent unique values and normalize corrupt storage", () => {
const next = recordRecentDraft([{ text: "旧输入", ts: "2026-01-01T00:00:00.000Z" }], "新输入", "2026-01-02T00:00:00.000Z");
assert.deepEqual(next.map((item) => item.text), ["新输入", "旧输入"]);
@@ -1,6 +1,52 @@
<script setup lang="ts">
withDefaults(defineProps<{ label?: string; compact?: boolean }>(), {
label: "加载中",
compact: false
});
</script>
<template>
<div class="loading-state" role="status" aria-live="polite">
<span class="loading-dot" />
<span>加载中</span>
<div class="loading-state" :data-compact="compact ? 'true' : 'false'" role="status" aria-live="polite">
<span class="loading-spinner" aria-hidden="true" />
<span>{{ label }}</span>
</div>
</template>
<style scoped>
.loading-state {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.loading-state[data-compact="false"] {
min-height: 96px;
width: 100%;
}
.loading-state[data-compact="true"] {
min-height: 0;
}
.loading-spinner {
width: 16px;
height: 16px;
flex: 0 0 auto;
border: 2px solid #bae6fd;
border-top-color: #0e7490;
border-radius: 999px;
animation: loading-state-spin 0.8s linear infinite;
}
@keyframes loading-state-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.loading-spinner { animation: none; }
}
</style>
@@ -2,6 +2,7 @@
import { computed, ref } from "vue";
import { useWorkbenchStore } from "@/stores/workbench";
import { useClipboard } from "@/composables/useClipboard";
import LoadingState from "@/components/common/LoadingState.vue";
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId, workbenchSessionPath } from "@/utils";
const workbench = useWorkbenchStore();
@@ -9,6 +10,7 @@ const { copied, copy } = useClipboard();
const width = ref(readWidth());
const dragging = ref(false);
const activeTab = computed(() => workbench.sessionTabs.find((tab) => tab.active) ?? null);
const showSessionListLoading = computed(() => workbench.sessionListLoading);
function copyActive(): void {
const tab = activeTab.value;
@@ -82,21 +84,24 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
<button id="session-create" class="btn btn-primary btn-sm" type="button" @click="workbench.createSession">新建</button>
</div>
<div class="session-model-channel" id="session-model-channel">模型通道{{ workbench.providerProfile }}</div>
<div class="session-list" id="session-tabs">
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" :data-running="tab.running" type="button" :title="tab.label || tab.conversationId" @click="workbench.selectConversation(tab)">
<span class="session-tab-title">{{ tab.label || tab.conversationId }}</span>
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
</button>
<p v-if="workbench.sessionTabs.length === 0" class="muted-box">没有 session发送前必须显式新建或选择 session</p>
<div class="session-list" id="session-tabs" :data-loading="showSessionListLoading">
<LoadingState v-if="showSessionListLoading" class="session-list-loading" label="加载中" />
<template v-else>
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" :data-running="tab.running" type="button" :title="tab.label || tab.conversationId" @click="workbench.selectConversation(tab)">
<span class="session-tab-title">{{ tab.label || tab.conversationId }}</span>
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
</button>
<p v-if="workbench.sessionTabs.length === 0" class="muted-box">没有 session发送前必须显式新建或选择 session</p>
</template>
</div>
<div class="session-active-metadata" id="session-status">
<span>{{ workbench.loading ? '加载中' : activeTab ? `当前 ${activeTab.sessionId || activeTab.conversationId}` : '等待 workspace' }}</span>
<small v-if="activeTab?.lastTraceId">trace {{ activeTab.lastTraceId }}</small>
<span>{{ showSessionListLoading ? '加载中' : activeTab ? `当前 ${activeTab.sessionId || activeTab.conversationId}` : '等待 workspace' }}</span>
<small v-if="!showSessionListLoading && activeTab?.lastTraceId">trace {{ activeTab.lastTraceId }}</small>
<small v-if="copied">已复制</small>
</div>
<div class="session-rail-actions">
<button class="btn btn-secondary btn-sm" type="button" :disabled="!activeTab" @click="copyActive">复制</button>
<button id="session-delete" class="btn btn-secondary btn-sm" type="button" :disabled="!activeTab" @click="workbench.deleteCurrentSession">删除当前</button>
<button class="btn btn-secondary btn-sm" type="button" :disabled="showSessionListLoading || !activeTab" @click="copyActive">复制</button>
<button id="session-delete" class="btn btn-secondary btn-sm" type="button" :disabled="showSessionListLoading || !activeTab" @click="workbench.deleteCurrentSession">删除当前</button>
</div>
<div
class="session-resize-handle"
@@ -77,6 +77,10 @@ export function shouldApplyWorkspaceSnapshot(input: { requestEpoch: number; curr
return selectedConversationIdFromWorkspace(input.workspace) === currentConversationId;
}
export function shouldShowSessionListLoading(input: { loading: boolean; conversationsReady: boolean }): boolean {
return input.loading && !input.conversationsReady;
}
export function mergeSelectedConversation(conversations: ConversationRecord[], workspace: WorkspaceRecord | null): ConversationRecord[] {
const selectedConversationId = selectedConversationIdFromWorkspace(workspace);
if (!selectedConversationId) return conversations;
+7 -2
View File
@@ -4,7 +4,7 @@ import { api } from "@/api";
import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
@@ -21,6 +21,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const gatewayShellTimeoutMs = ref(readNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", DEFAULT_GATEWAY_TIMEOUT_MS));
const live = ref<LiveSurface | null>(null);
const loading = ref(false);
const conversationsReady = ref(false);
const switchingConversationId = ref<string | null>(null);
const chatPending = ref(false);
const error = ref<string | null>(null);
@@ -35,6 +36,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const selectedThreadId = computed(() => firstNonEmptyString(workspace.value?.workspace?.threadId, activeConversation.value?.threadId));
const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value));
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value }));
const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value));
const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value }));
@@ -45,10 +47,12 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function hydrate(): Promise<void> {
const requestEpoch = workspaceSelectionEpoch.value;
conversationsReady.value = conversations.value.length > 0;
loading.value = true;
error.value = null;
const [workspaceResult, conversationsResult] = await Promise.all([api.workbench.workspace(projectId.value), api.workbench.conversations(projectId.value)]);
loading.value = false;
conversationsReady.value = true;
if (!workspaceResult.ok) {
error.value = workspaceResult.error ?? "workspace unavailable";
return;
@@ -158,6 +162,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function refreshConversations(): Promise<void> {
const response = await api.workbench.conversations(activeProjectId.value);
conversationsReady.value = true;
if (response.ok) conversations.value = response.data?.conversations ?? [];
}
@@ -468,7 +473,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return true;
}
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
});
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
@@ -594,6 +594,16 @@
overflow: auto;
}
.session-list[data-loading="true"] {
align-content: center;
justify-items: center;
overflow: hidden;
}
.session-list-loading {
min-height: 120px;
}
.conversation-panel {
display: flex;
min-height: 0;