Merge pull request #1316 from pikasTech/fix/issue-1313-loading-state
修复 Workbench session 列表加载态
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user