fix: collapse workbench session rail metadata
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01040104 响应布局; PJ2026-01040106 Trace阅读.
|
||||
// Responsibility: Session rail list, low-noise session details, local resize, and collapse UI state.
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
@@ -7,15 +10,20 @@ import { workbenchSessionPath } from "@/utils";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const { copied, copy } = useClipboard();
|
||||
const COLLAPSED_STORAGE_KEY = "hwlab.workbench.sessionRailCollapsed.v1";
|
||||
const width = ref(readWidth());
|
||||
const collapsed = ref(readCollapsed());
|
||||
const dragging = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const activeTab = computed(() => workbench.sessionTabs.find((tab) => tab.active) ?? null);
|
||||
const activeSessionUrl = computed(() => activeTab.value ? sessionUrl(activeTab.value) : "");
|
||||
const railStyle = computed(() => ({ width: collapsed.value ? "48px" : `${width.value}px` }));
|
||||
const showSessionListLoading = computed(() => workbench.sessionListLoading);
|
||||
|
||||
function copyActive(): void {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
void copy(`url=${sessionUrl(tab)}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`);
|
||||
void copy(`url=${sessionUrl(tab)}\nsessionId=${tab.sessionId ?? ''}\nthreadId=${tab.threadId ?? ''}\ntraceId=${tab.lastTraceId ?? ''}\nstatus=${tab.status ?? ''}\nupdatedAt=${tab.updatedAt ?? tab.startedAt ?? ''}`);
|
||||
}
|
||||
|
||||
function sessionUrl(tab: { sessionId?: string | null }): string {
|
||||
@@ -24,6 +32,7 @@ function sessionUrl(tab: { sessionId?: string | null }): string {
|
||||
}
|
||||
|
||||
function startResize(event: PointerEvent): void {
|
||||
if (collapsed.value) return;
|
||||
dragging.value = true;
|
||||
const startX = event.clientX;
|
||||
const startWidth = width.value;
|
||||
@@ -38,6 +47,7 @@ function startResize(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function keyResize(event: KeyboardEvent): void {
|
||||
if (collapsed.value) return;
|
||||
const step = event.shiftKey ? 32 : 8;
|
||||
if (event.key === "ArrowLeft") { event.preventDefault(); setWidth(width.value - step); }
|
||||
if (event.key === "ArrowRight") { event.preventDefault(); setWidth(width.value + step); }
|
||||
@@ -59,6 +69,19 @@ function readWidth(): number {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapsed(): void {
|
||||
collapsed.value = !collapsed.value;
|
||||
try { localStorage.setItem(COLLAPSED_STORAGE_KEY, collapsed.value ? "true" : "false"); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSED_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionUpdatedTime(value: string | null | undefined): string {
|
||||
if (!value) return "未记录";
|
||||
const date = new Date(value);
|
||||
@@ -73,16 +96,19 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="session-rail" id="session-sidebar" :class="{ 'is-resizing': dragging }" :style="{ width: `${width}px` }">
|
||||
<aside class="session-rail" id="session-sidebar" :class="{ 'is-resizing': dragging, 'is-collapsed': collapsed }" :data-collapsed="collapsed" :style="railStyle">
|
||||
<div class="panel-header compact">
|
||||
<div>
|
||||
<div v-if="!collapsed">
|
||||
<p class="page-eyebrow">Sessions</p>
|
||||
<h2>显式会话</h2>
|
||||
</div>
|
||||
<button id="session-create" class="btn btn-primary btn-sm" type="button" @click="workbench.createSession">新建</button>
|
||||
<div class="session-rail-header-actions">
|
||||
<button v-if="!collapsed" id="session-detail-toggle" class="message-detail-button session-detail-button" type="button" aria-label="Session 详情" title="Session 详情" :disabled="showSessionListLoading || !activeTab" @click="detailOpen = true">!</button>
|
||||
<button v-if="!collapsed" id="session-create" class="btn btn-primary btn-sm" type="button" @click="workbench.createSession">新建</button>
|
||||
<button id="session-collapse-toggle" class="btn btn-secondary btn-sm session-collapse-toggle" type="button" :aria-label="collapsed ? '展开 Sessions' : '折叠 Sessions'" :title="collapsed ? '展开 Sessions' : '折叠 Sessions'" :aria-expanded="!collapsed" @click="toggleCollapsed">{{ collapsed ? '›' : '‹' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-model-channel" id="session-model-channel">模型通道:{{ workbench.providerProfile }}</div>
|
||||
<div class="session-list" id="session-tabs" :data-loading="showSessionListLoading">
|
||||
<div v-if="!collapsed" 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.sessionId || tab.key" class="session-tab" :data-active="tab.active" :data-running="tab.running" :data-status="tab.status || undefined" :data-session-id="tab.sessionId || undefined" type="button" :title="tab.label || tab.sessionId || tab.key" @click="workbench.selectSession(tab)">
|
||||
@@ -99,16 +125,35 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="session-active-metadata" id="session-status">
|
||||
<span>{{ showSessionListLoading ? '加载中' : activeTab ? `当前 ${activeTab.sessionId}` : '等待 session' }}</span>
|
||||
<small v-if="!showSessionListLoading && activeTab?.lastTraceId">trace {{ activeTab.lastTraceId }}</small>
|
||||
<small v-if="copied">已复制</small>
|
||||
</div>
|
||||
<div class="session-rail-actions">
|
||||
<div v-if="!collapsed" class="session-rail-actions">
|
||||
<button id="session-copy" 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 v-if="detailOpen && activeTab" class="workbench-dialog-backdrop" role="presentation" @click.self="detailOpen = false">
|
||||
<section id="session-detail-dialog" class="workbench-dialog" role="dialog" aria-modal="true" aria-labelledby="session-detail-title">
|
||||
<header>
|
||||
<div class="dialog-title-stack">
|
||||
<h2 id="session-detail-title">Session 详情</h2>
|
||||
<p>{{ activeTab.sessionId || '未选择' }}</p>
|
||||
</div>
|
||||
<button type="button" class="dialog-close" aria-label="关闭" @click="detailOpen = false">x</button>
|
||||
</header>
|
||||
<dl class="session-detail-grid">
|
||||
<div><dt>sessionId</dt><dd>{{ activeTab.sessionId || '-' }}</dd></div>
|
||||
<div><dt>threadId</dt><dd>{{ activeTab.threadId || '-' }}</dd></div>
|
||||
<div><dt>lastTraceId</dt><dd>{{ activeTab.lastTraceId || '-' }}</dd></div>
|
||||
<div><dt>URL</dt><dd>{{ activeSessionUrl || '-' }}</dd></div>
|
||||
<div><dt>status</dt><dd>{{ activeTab.status || 'unknown' }}</dd></div>
|
||||
<div><dt>updatedAt</dt><dd>{{ activeTab.updatedAt || activeTab.startedAt || '未记录' }}</dd></div>
|
||||
</dl>
|
||||
<div class="session-detail-actions">
|
||||
<button id="session-detail-copy" class="btn btn-secondary btn-sm" type="button" @click="copyActive">复制</button>
|
||||
<span v-if="copied">已复制</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div
|
||||
v-if="!collapsed"
|
||||
class="session-resize-handle"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
|
||||
@@ -566,7 +566,44 @@
|
||||
}
|
||||
|
||||
.session-rail {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.session-rail-header-actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-detail-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.session-collapse-toggle {
|
||||
min-width: 30px;
|
||||
padding: 0 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.session-rail.is-collapsed {
|
||||
width: 48px !important;
|
||||
grid-template-rows: auto;
|
||||
justify-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.session-rail.is-collapsed .panel-header.compact {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.session-rail.is-collapsed .session-rail-header-actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hwpod-panel {
|
||||
@@ -1476,26 +1513,42 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.session-model-channel,
|
||||
.session-active-metadata {
|
||||
.session-detail-grid {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-detail-grid div {
|
||||
min-width: 0;
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.session-detail-grid dt {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.session-detail-grid dd {
|
||||
margin: 3px 0 0;
|
||||
color: #0f172a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.session-active-metadata span {
|
||||
color: #0f172a;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.session-active-metadata small {
|
||||
.session-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@@ -2591,17 +2644,26 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-rail.is-collapsed {
|
||||
width: 100% !important;
|
||||
max-height: 44px;
|
||||
grid-template-rows: auto;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.session-rail .panel-header.compact {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.session-rail .panel-header.compact .page-eyebrow,
|
||||
.session-rail .session-model-channel,
|
||||
.session-rail .session-active-metadata,
|
||||
.session-rail .session-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-rail.is-collapsed .panel-header.compact {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.session-rail .session-rail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
export const selectors = {
|
||||
workspace: "#workspace",
|
||||
sessionCreate: "#session-create",
|
||||
sessionDetailToggle: "#session-detail-toggle",
|
||||
sessionDetailDialog: "#session-detail-dialog",
|
||||
sessionDetailCopy: "#session-detail-copy",
|
||||
sessionCollapseToggle: "#session-collapse-toggle",
|
||||
sessionCopy: "#session-copy",
|
||||
sessionDelete: "#session-delete",
|
||||
sessionTabs: "#session-tabs",
|
||||
sessionStatus: "#session-status",
|
||||
commandInput: "#command-input",
|
||||
commandSend: "#command-send",
|
||||
conversationList: "#conversation-list",
|
||||
|
||||
@@ -44,7 +44,8 @@ test.describe("legacy cnv deep link canonical authority", () => {
|
||||
|
||||
test("cnv route reflects canonical URL as soon as canonical content is visible", async ({ page }) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/cnv_c5e1330558c94d8e");
|
||||
await expect(page.locator(selectors.sessionStatus)).toContainText("当前 ses_c5e1330558c94d8e");
|
||||
await page.locator(selectors.sessionDetailToggle).click();
|
||||
await expect(page.locator(selectors.sessionDetailDialog)).toContainText("ses_c5e1330558c94d8e");
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_c5e1330558c94d8e$/u, { timeout: 500 });
|
||||
const state = await fakeServerState(page);
|
||||
const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? ""));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
|
||||
import { selectors, sessionTab } from "../fixtures/selectors";
|
||||
|
||||
test("session rail keeps low-frequency metadata behind details and preserves active session while collapsed", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page);
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator("#session-model-channel")).toHaveCount(0);
|
||||
await expect(page.locator("#session-status")).toHaveCount(0);
|
||||
await expect(page.locator("#session-sidebar")).not.toContainText("模型通道:codex");
|
||||
await expect(page.locator("#session-sidebar")).not.toContainText(/当前\s+ses_/u);
|
||||
await expect(page.locator("#session-sidebar")).not.toContainText(/trace\s+trc_/u);
|
||||
await saveScreenshot(page, testInfo, "session-rail-default");
|
||||
|
||||
await page.locator(selectors.sessionDetailToggle).click();
|
||||
const detail = page.locator(selectors.sessionDetailDialog);
|
||||
await expect(detail).toBeVisible();
|
||||
await expect(detail).toContainText("sessionId");
|
||||
await expect(detail).toContainText("ses_running");
|
||||
await expect(detail).toContainText("threadId");
|
||||
await expect(detail).toContainText("thr_running");
|
||||
await expect(detail).toContainText("lastTraceId");
|
||||
await expect(detail).toContainText("trc_running");
|
||||
await expect(page.locator(selectors.sessionDetailCopy)).toBeVisible();
|
||||
await saveScreenshot(page, testInfo, "session-rail-detail-dialog");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(`${selectors.sessionDetailDialog} .dialog-close`).click();
|
||||
|
||||
const rail = page.locator("#session-sidebar");
|
||||
const center = page.locator(".workbench-center");
|
||||
const beforeRail = await rail.boundingBox();
|
||||
const beforeCenter = await center.boundingBox();
|
||||
await page.locator(selectors.sessionCollapseToggle).click();
|
||||
await expect(rail).toHaveAttribute("data-collapsed", "true");
|
||||
const collapsedRail = await rail.boundingBox();
|
||||
const collapsedCenter = await center.boundingBox();
|
||||
expect(collapsedRail?.width ?? 0).toBeLessThan(80);
|
||||
expect(collapsedCenter?.width ?? 0).toBeGreaterThan((beforeCenter?.width ?? 0) + 100);
|
||||
expect(collapsedRail?.width ?? 0).toBeLessThan((beforeRail?.width ?? 0) - 120);
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_running/u);
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
|
||||
await expect(page.locator(selectors.commandInput)).toBeVisible();
|
||||
await saveScreenshot(page, testInfo, "session-rail-collapsed");
|
||||
|
||||
await page.locator(selectors.sessionCollapseToggle).click();
|
||||
await expect(rail).toHaveAttribute("data-collapsed", "false");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
|
||||
const expandedRail = await rail.boundingBox();
|
||||
expect(expandedRail?.width ?? 0).toBeGreaterThan(200);
|
||||
await saveScreenshot(page, testInfo, "session-rail-expanded-after-collapse");
|
||||
});
|
||||
|
||||
test.describe("mobile session rail collapse", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("collapse toggle remains reachable on narrow screens", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page);
|
||||
await expect(page.locator(selectors.sessionCollapseToggle)).toBeVisible();
|
||||
await page.locator(selectors.sessionCollapseToggle).click();
|
||||
await expect(page.locator("#session-sidebar")).toHaveAttribute("data-collapsed", "true");
|
||||
await expect(page.locator(selectors.commandInput)).toBeVisible();
|
||||
await saveScreenshot(page, testInfo, "session-rail-mobile-collapsed");
|
||||
await page.locator(selectors.sessionCollapseToggle).click();
|
||||
await expect(page.locator(selectors.sessionTabs)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,8 @@ test("session switch persists active session after reload", async ({ page }, tes
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(selectors.sessionStatus)).toContainText("ses_completed");
|
||||
await page.locator(selectors.sessionDetailToggle).click();
|
||||
await expect(page.locator(selectors.sessionDetailDialog)).toContainText("ses_completed");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
|
||||
await saveScreenshot(page, testInfo, "session-switch-reload");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user