Merge pull request #1225 from pikasTech/fix/1224-webui

fix: simplify v0.3 workbench diagnostics layout
This commit is contained in:
Lyon
2026-06-15 00:54:52 +08:00
committed by GitHub
5 changed files with 324 additions and 48 deletions
@@ -10,16 +10,22 @@ const auth = useAuthStore();
const app = useAppStore();
const navSections = [
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench" }, { name: "Dashboard", label: "概览", path: "/dashboard" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys" }, { name: "Usage", label: "用量", path: "/usage" }, { name: "Billing", label: "充值", path: "/billing" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access" }, { name: "Users", label: "用户", path: "/admin/users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles" }] },
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate" }, { name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] }
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K" }, { name: "Usage", label: "用量", path: "/usage", icon: "U" }, { name: "Billing", label: "充值", path: "/billing", icon: "$" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R" }] },
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G" }, { name: "Performance", label: "性能", path: "/performance", icon: "M" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S" }, { name: "Settings", label: "设置", path: "/settings", icon: "T" }, { name: "Help", label: "帮助", path: "/help", icon: "?" }] }
];
const shellless = computed(() => route.name === "Login" || route.name === "Register" || route.name === "NotFound");
function go(path: string): void {
void router.push(path);
async function go(path: string): Promise<void> {
if (route.path === path) return;
app.setLoading(true);
try {
await router.push(path);
} finally {
window.setTimeout(() => app.setLoading(false), 120);
}
}
</script>
@@ -27,7 +33,7 @@ function go(path: string): void {
<main v-if="shellless" class="auth-main">
<slot />
</main>
<main v-else class="platform-shell">
<main v-else class="platform-shell" :class="{ 'is-sidebar-collapsed': app.sidebarCollapsed }">
<aside class="platform-sidebar" :data-collapsed="app.sidebarCollapsed">
<div class="brand-lockup">
<span class="brand-mark">HW</span>
@@ -39,9 +45,9 @@ function go(path: string): void {
<nav class="nav-groups" aria-label="主导航">
<section v-for="section in navSections" :key="section.title" class="nav-section">
<h2>{{ section.title }}</h2>
<button v-for="item in section.items" :key="item.name" class="nav-item" :data-active="route.name === item.name" type="button" @click="go(item.path)">
<span class="nav-dot" />
<span>{{ item.label }}</span>
<button v-for="item in section.items" :key="item.name" class="nav-item" :data-active="route.name === item.name" type="button" :title="item.label" :aria-label="item.label" @click="go(item.path)">
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</button>
</section>
</nav>
@@ -50,6 +56,7 @@ function go(path: string): void {
<header class="platform-topbar">
<button class="icon-button" type="button" aria-label="折叠侧边栏" @click="app.sidebarCollapsed = !app.sidebarCollapsed"></button>
<div class="topbar-status">
<span v-if="app.loading" class="topbar-progress" aria-live="polite"><span aria-hidden="true" /></span>
<span>同源 API</span>
<strong>{{ auth.user?.displayName || auth.user?.username || auth.user?.name || "HWLAB 用户" }}</strong>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { ChatMessage } from "@/types";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
@@ -11,6 +12,12 @@ import { traceIdentityText } from "./message-rendering";
const workbench = useWorkbenchStore();
const clipboard = useClipboard();
const detailMessageId = ref<string | null>(null);
const detailMessage = computed(() => workbench.messages.find((message) => message.id === detailMessageId.value) ?? null);
function openDetails(message: ChatMessage): void {
detailMessageId.value = message.id;
}
</script>
<template>
@@ -18,22 +25,36 @@ const clipboard = useClipboard();
<article v-for="message in workbench.messages" :key="message.id" class="message-card" :data-role="message.role" :data-status="message.status">
<header>
<strong>{{ message.title }}</strong>
<StatusBadge :status="message.status" />
<div class="message-header-actions">
<StatusBadge :status="message.status" />
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="消息详情" title="消息详情" @click="openDetails(message)">i</button>
</div>
</header>
<MessageMarkdown class="message-text" :source="message.text" />
<TraceTimeline :trace="message.runnerTrace" />
<CodeAgentStatusSummary :message="message" />
<MessageActions
:message="message"
@cancel="workbench.cancelAgentMessage"
@retry="workbench.retryAgentMessage"
@replay-trace="workbench.replayAgentTrace"
@copy-trace="(target: ChatMessage) => clipboard.copy(traceIdentityText(target))"
/>
</article>
<article v-if="workbench.messages.length === 0" class="message-card empty-message">
<header><strong>等待会话</strong><StatusBadge status="source" label="SOURCE" /></header>
<MessageMarkdown class="message-text" source="请先显式新建或选择 session。Code Agent 不会自动滚动新 session 掩盖失败。" />
</article>
<div v-if="detailMessage" class="workbench-dialog-backdrop" role="presentation" @click.self="detailMessageId = null">
<section id="message-detail-dialog" class="workbench-dialog wide" role="dialog" aria-modal="true" aria-labelledby="message-detail-title">
<header>
<div class="dialog-title-stack">
<h2 id="message-detail-title">消息详情</h2>
<p>{{ detailMessage.traceId || detailMessage.runnerTrace?.traceId || 'trace 未观测' }}</p>
</div>
<button type="button" class="dialog-close" aria-label="关闭" @click="detailMessageId = null">x</button>
</header>
<TraceTimeline :trace="detailMessage.runnerTrace" />
<CodeAgentStatusSummary :message="detailMessage" />
<MessageActions
:message="detailMessage"
@cancel="workbench.cancelAgentMessage"
@retry="workbench.retryAgentMessage"
@replay-trace="workbench.replayAgentTrace"
@copy-trace="(target: ChatMessage) => clipboard.copy(traceIdentityText(target))"
/>
</section>
</div>
</section>
</template>
+38 -1
View File
@@ -21,13 +21,14 @@ 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 switchingConversationId = ref<string | null>(null);
const chatPending = ref(false);
const error = ref<string | null>(null);
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
const activeAbort = ref<AbortController | null>(null);
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const activeConversationId = computed(() => firstNonEmptyString(workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId));
const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId));
const selectedSessionId = computed(() => firstNonEmptyString(workspace.value?.selectedAgentSessionId, workspace.value?.workspace?.selectedAgentSessionId, activeConversation.value?.sessionId));
const selectedThreadId = computed(() => firstNonEmptyString(workspace.value?.workspace?.threadId, activeConversation.value?.threadId));
const activeConversation = computed(() => conversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
@@ -75,7 +76,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function createSession(): Promise<void> {
const current = await ensureWorkspace();
if (!current) return;
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: activeProjectId.value, create: true, updatedByClient: "cloud-web-vue" });
loading.value = false;
if (response.ok) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
@@ -87,12 +90,24 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function selectConversation(conversation: ConversationRecord): Promise<void> {
const current = await ensureWorkspace();
if (!current) return;
const previousWorkspace = workspace.value;
const previousMessages = messages.value;
const tabProjectId = conversation.projectId ?? activeProjectId.value;
switchingConversationId.value = conversation.conversationId;
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
messages.value = messagesFromConversation(conversation);
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
loading.value = false;
switchingConversationId.value = null;
if (response.ok) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
currentRequest.value = null;
} else {
workspace.value = previousWorkspace;
messages.value = previousMessages;
error.value = response.error ?? "session switch failed";
}
}
@@ -314,6 +329,28 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
});
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
return {
...current,
projectId,
selectedConversationId: conversation.conversationId,
selectedAgentSessionId: conversation.sessionId ?? current.selectedAgentSessionId,
selectedConversation: conversation,
workspace: {
...(current.workspace ?? {}),
projectId,
selectedConversationId: conversation.conversationId,
selectedAgentSessionId: conversation.sessionId ?? current.workspace?.selectedAgentSessionId,
threadId: conversation.threadId ?? current.workspace?.threadId,
messages: conversation.messages ?? []
}
};
}
function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] {
return (conversation.messages ?? []).map((message) => ({ ...message, id: message.id ?? nextProtocolId("msg"), title: message.title ?? (message.role === "user" ? "用户" : "Code Agent"), createdAt: message.createdAt ?? new Date().toISOString(), status: message.status ?? "source" }));
}
function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
const selected = workspace?.selectedConversation?.messages;
const embedded = workspace?.workspace?.messages;
+210 -15
View File
@@ -1,10 +1,16 @@
.platform-shell {
display: grid;
height: 100dvh;
min-height: 100vh;
grid-template-columns: 248px minmax(0, 1fr);
overflow: hidden;
background: #eef3f8;
}
.platform-shell.is-sidebar-collapsed {
grid-template-columns: 76px minmax(0, 1fr);
}
.platform-sidebar {
position: sticky;
top: 0;
@@ -18,13 +24,17 @@
}
.platform-sidebar[data-collapsed="true"] {
width: 72px;
width: auto;
overflow: hidden;
align-items: center;
padding-right: 8px;
padding-left: 8px;
}
.platform-main {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 58px minmax(0, 1fr);
}
@@ -39,10 +49,17 @@
}
.platform-content {
display: grid;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 22px;
}
.platform-content > * {
min-height: 0;
}
.brand-lockup {
display: flex;
min-width: 0;
@@ -91,6 +108,18 @@
gap: 16px;
}
.route-stack {
overflow: auto;
}
.workbench-route {
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
overflow: hidden;
}
.nav-section h2 {
margin: 0 0 4px;
color: #94a3b8;
@@ -113,6 +142,36 @@
text-align: left;
}
.nav-icon {
display: inline-flex;
width: 24px;
height: 24px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 7px;
background: #e2e8f0;
color: #334155;
font-size: 12px;
font-weight: 850;
}
.nav-item[data-active="true"] .nav-icon {
background: #bae6fd;
color: #075985;
}
.platform-sidebar[data-collapsed="true"] .brand-lockup div,
.platform-sidebar[data-collapsed="true"] .nav-section h2,
.platform-sidebar[data-collapsed="true"] .nav-label {
display: none;
}
.platform-sidebar[data-collapsed="true"] .nav-item {
justify-content: center;
padding: 9px;
}
.nav-item[data-active="true"] {
background: #e0f2fe;
color: #0c4a6e;
@@ -139,6 +198,27 @@
gap: 10px;
}
.topbar-progress {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
}
.topbar-progress span {
width: 14px;
height: 14px;
border: 2px solid #bae6fd;
border-top-color: #0e7490;
border-radius: 999px;
animation: workbench-spin 0.8s linear infinite;
}
@keyframes workbench-spin {
to { transform: rotate(360deg); }
}
.auth-main,
.login-shell {
display: grid;
@@ -190,22 +270,81 @@
color: #b91c1c;
}
.workbench-status-row {
.workbench-diagnostics-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
color: #475569;
font-size: 13px;
justify-content: flex-end;
min-height: 34px;
}
.workbench-live-strip {
display: flex;
flex-wrap: wrap;
align-items: stretch;
.workbench-diagnostics-toggle {
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 8px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
color: #334155;
padding: 4px 8px;
font-weight: 800;
}
.workbench-diagnostics-toggle > span:first-child,
.message-detail-button {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #e0f2fe;
color: #0c4a6e;
font-size: 12px;
font-weight: 850;
}
.workbench-diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.workbench-diagnostics-summary {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.workbench-diagnostics-summary div {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 8px;
}
.workbench-diagnostics-summary dt,
.workbench-diagnostics-summary dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.workbench-diagnostics-summary dt {
color: #64748b;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.workbench-diagnostics-summary dd {
color: #0f172a;
font-size: 12px;
font-weight: 750;
}
.probe-card,
.live-build-summary {
min-width: min(320px, 100%);
@@ -373,8 +512,9 @@
.workbench-grid {
display: grid;
min-height: calc(100vh - 180px);
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr) minmax(280px, 360px);
min-height: 0;
height: 100%;
grid-template-columns: auto minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
}
@@ -393,6 +533,8 @@
.workbench-center {
display: grid;
height: 100%;
min-height: 0;
min-width: 0;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
@@ -402,6 +544,8 @@
.hwpod-panel {
position: relative;
display: grid;
height: 100%;
min-height: 0;
min-width: 0;
align-content: start;
gap: 12px;
@@ -409,6 +553,14 @@
overflow: hidden;
}
.session-rail {
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
}
.hwpod-panel {
grid-template-rows: auto auto minmax(0, auto) auto minmax(0, 1fr);
}
.panel-header {
display: flex;
align-items: center;
@@ -424,6 +576,7 @@
.session-list,
.conversation-panel {
display: grid;
min-height: 0;
gap: 10px;
overflow: auto;
}
@@ -488,6 +641,19 @@
gap: 10px;
}
.message-header-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.message-detail-button {
border: 0;
cursor: pointer;
padding: 0;
}
.message-text,
.message-markdown {
margin: 0;
@@ -865,9 +1031,18 @@
.hwpod-node-status,
.hwpod-event-panel {
display: grid;
min-height: 0;
gap: 10px;
}
.hwpod-node-status {
overflow: auto;
}
.hwpod-event-panel {
grid-template-rows: auto minmax(0, 1fr);
}
.hwpod-node-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -971,7 +1146,8 @@
}
.hwpod-event-scroll {
max-height: 210px;
min-height: 0;
max-height: none;
overflow: auto;
border: 1px solid #d8e1eb;
border-radius: 8px;
@@ -1686,7 +1862,7 @@
.platform-sidebar .brand-lockup div,
.nav-section h2,
.nav-item span:not(.nav-dot) {
.nav-label {
display: none;
}
@@ -1701,6 +1877,22 @@
grid-template-columns: 1fr;
}
.workbench-route {
height: auto;
overflow: auto;
}
.workbench-grid,
.session-rail,
.workbench-center,
.hwpod-panel {
height: auto;
}
.hwpod-event-scroll {
max-height: 260px;
}
.field-grid,
.form-grid.two-col,
.compact-form-grid {
@@ -1711,6 +1903,8 @@
@media (max-width: 720px) {
.platform-shell {
grid-template-columns: 1fr;
height: auto;
overflow: auto;
}
.platform-sidebar {
@@ -1724,6 +1918,7 @@
.platform-content {
padding: 14px;
overflow: visible;
}
.platform-topbar {
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { onMounted } from "vue";
import PageHeader from "@/components/common/PageHeader.vue";
import { onMounted, ref } from "vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import CommandComposer from "@/components/workbench/CommandComposer.vue";
import ConversationPanel from "@/components/workbench/ConversationPanel.vue";
@@ -12,22 +11,18 @@ import { useAutoRefresh } from "@/composables/useAutoRefresh";
import { useWorkbenchStore } from "@/stores/workbench";
const workbench = useWorkbenchStore();
const diagnosticsOpen = ref(false);
onMounted(() => void workbench.hydrate());
useAutoRefresh(() => workbench.refreshLive(), 30_000);
</script>
<template>
<section id="workspace" class="workbench-route">
<PageHeader eyebrow="Workbench" title="Code Workbench" description="Code Agent 是平台控制台中的一个应用页面;session、trace、HWPOD evidence 仍走 Cloud Web 同源 API。" />
<div class="workbench-status-row">
<StatusBadge :status="workbench.chatPending ? 'running' : 'source'" :label="workbench.chatPending ? '处理中' : 'SOURCE'" />
<span>Provider: {{ workbench.providerProfile }}</span>
<span>Composer: {{ workbench.composer.submitMode }}{{ workbench.composer.targetTraceId ? ` ${workbench.composer.targetTraceId}` : '' }}</span>
<span>Timeout: inactivity {{ Math.round(workbench.codeAgentTimeoutMs / 1000) }}s</span>
</div>
<div class="workbench-live-strip" aria-label="Workbench live status">
<WorkbenchProbePanel :live="workbench.live" />
<WorkbenchBuildSummary :live="workbench.live" />
<div class="workbench-diagnostics-bar">
<button id="workbench-diagnostics-toggle" class="workbench-diagnostics-toggle" type="button" aria-haspopup="dialog" @click="diagnosticsOpen = true">
<span aria-hidden="true">i</span>
<StatusBadge :status="workbench.chatPending ? 'running' : 'source'" :label="workbench.chatPending ? '处理中' : '诊断'" />
</button>
</div>
<div class="workbench-grid">
<SessionRail />
@@ -37,5 +32,26 @@ useAutoRefresh(() => workbench.refreshLive(), 30_000);
</main>
<HwpodNodeOpsPanel />
</div>
<div v-if="diagnosticsOpen" class="workbench-dialog-backdrop" role="presentation" @click.self="diagnosticsOpen = false">
<section id="workbench-diagnostics-dialog" class="workbench-dialog wide" role="dialog" aria-modal="true" aria-labelledby="workbench-diagnostics-title">
<header>
<div class="dialog-title-stack">
<h2 id="workbench-diagnostics-title">Workbench 诊断</h2>
<p>Code Agent sessiontraceHWPOD evidence 仍走 Cloud Web 同源 API</p>
</div>
<button type="button" class="dialog-close" aria-label="关闭" @click="diagnosticsOpen = false">x</button>
</header>
<div class="workbench-diagnostics-grid">
<dl class="workbench-diagnostics-summary">
<div><dt>Provider</dt><dd>{{ workbench.providerProfile }}</dd></div>
<div><dt>Composer</dt><dd>{{ workbench.composer.submitMode }}{{ workbench.composer.targetTraceId ? ` ${workbench.composer.targetTraceId}` : '' }}</dd></div>
<div><dt>Timeout</dt><dd>inactivity {{ Math.round(workbench.codeAgentTimeoutMs / 1000) }}s</dd></div>
<div><dt>Session</dt><dd>{{ workbench.selectedSessionId || '未选择' }}</dd></div>
</dl>
<WorkbenchProbePanel :live="workbench.live" />
<WorkbenchBuildSummary :live="workbench.live" />
</div>
</section>
</div>
</section>
</template>