diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html
index 0c58db7e..5857b09d 100644
--- a/web/hwlab-cloud-web/index.html
+++ b/web/hwlab-cloud-web/index.html
@@ -5,30 +5,6 @@
<\/div>/u, "index.html must expose Vue mount root");
assert.match(html, /src="\/src\/main\.ts"/u, "index.html must load Vue TS entry");
-assert.match(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must still start during HTML parse");
-assert.match(html, /\/v1\/workbench\/workspace/u, "HTML parse bootstrap must use the same-origin workbench API");
+assert.doesNotMatch(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must not start during HTML parse");
+assert.doesNotMatch(html, /HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP/u, "workspace snapshot must not be injected before Vue boot");
+assert.doesNotMatch(html, /\/v1\/workbench\/workspace/u, "HTML parse must not request legacy workspace authority");
assert.doesNotMatch(html, /\/auth\/workspace-bootstrap/u, "HTML parse bootstrap must not use stale auth workspace bootstrap route");
assert.doesNotMatch(html, /src="\/src\/main\.tsx"|id="root"/u, "React entry must be gone");
@@ -103,7 +103,7 @@ assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_
assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint");
assertIncludes(appSource, "/v1/agent/traces/", "trace hydration must use RESTful Cloud Web same-origin trace API");
assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API");
-assertIncludes(appSource, "/v1/workbench/workspace", "workspace bootstrap must use Cloud Web same-origin workbench API");
+assert.doesNotMatch(appSource, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "early workspace bootstrap helper must not remain in Cloud Web app source");
assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source");
assert.doesNotMatch(appSource, /trace-meta-details/u, "Trace details must not render a second message-detail exclamation button");
assert.doesNotMatch(traceTimelineSource, /trace-meta-panel|trace-summary-chip|trace-meta-actions|原始事件|traceEventLabel/u, "Expanded TraceTimeline body must not render debug identity or raw-event controls");
diff --git a/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts b/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts
index c6f7fc5e..fe5ca338 100644
--- a/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts
+++ b/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts
@@ -1,22 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { isWorkbenchNavigationContext, shouldReflectWorkbenchConversationUrl } from "../src/router/workbench-navigation.ts";
+import { isWorkbenchNavigationContext, shouldReflectWorkbenchSessionUrl } from "../src/router/workbench-navigation.ts";
test("workbench navigation context recognizes workbench routes only while the component is active", () => {
- assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_1" }), true);
+ assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_1" }), true);
assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeName: "CodeWorkbench", routePath: "/workspace" }), true);
assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard" }), false);
- assert.equal(isWorkbenchNavigationContext({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_1" }), false);
+ assert.equal(isWorkbenchNavigationContext({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_1" }), false);
});
test("workbench URL reflection never overrides a user navigation outside Workbench", () => {
- assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard", routeConversationId: null, conversationId: "cnv_async" }), false);
- assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeConversationId: null, conversationId: "cnv_async" }), false);
+ assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard", routeSessionId: null, sessionId: "ses_async" }), false);
+ assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeSessionId: null, sessionId: "ses_async" }), false);
});
test("workbench URL reflection still updates empty Workbench routes and respects route hydration", () => {
- assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeConversationId: null, conversationId: "cnv_selected" }), true);
- assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_selected", routeConversationId: "cnv_selected", conversationId: "cnv_selected" }), false);
- assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_route", routeConversationId: "cnv_route", conversationId: "cnv_store", applyingRouteConversation: true }), false);
+ assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeSessionId: null, sessionId: "ses_selected" }), true);
+ assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_selected", routeSessionId: "ses_selected", sessionId: "ses_selected" }), false);
+ assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_route", routeSessionId: "ses_route", sessionId: "ses_store", applyingRouteSession: true }), false);
});
diff --git a/web/hwlab-cloud-web/src/App.vue b/web/hwlab-cloud-web/src/App.vue
index ca875afd..435e79bd 100644
--- a/web/hwlab-cloud-web/src/App.vue
+++ b/web/hwlab-cloud-web/src/App.vue
@@ -4,13 +4,11 @@ import { RouterView } from "vue-router";
import ToastHost from "@/components/common/ToastHost.vue";
import AppShell from "@/components/layout/AppShell.vue";
import { useAuthStore } from "@/stores/auth";
-import { useWorkbenchStore } from "@/stores/workbench";
const auth = useAuthStore();
-const workbench = useWorkbenchStore();
onMounted(async () => {
- if (!auth.checked) await auth.bootstrap(workbench.projectId);
+ if (!auth.checked) await auth.bootstrap();
});
diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts
index e3f7448d..febf9698 100644
--- a/web/hwlab-cloud-web/src/api/workbench-events.ts
+++ b/web/hwlab-cloud-web/src/api/workbench-events.ts
@@ -1,4 +1,4 @@
-// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
+// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Workbench SSE client. Realtime events accelerate UI projection; REST snapshots remain gap-fill authority.
import type { TraceEvent, WorkspaceRecord } from "@/types";
@@ -18,7 +18,7 @@ export interface WorkbenchRealtimeEvent {
status?: string;
serverSentAt?: string | null;
eventCreatedAt?: string | null;
- projectId?: string;
+ projectId?: string | null;
workspace?: WorkspaceRecord | null;
workspaceId?: string | null;
workspaceRevision?: number | null;
@@ -34,7 +34,7 @@ export interface WorkbenchRealtimeEvent {
}
export interface WorkbenchEventStreamOptions {
- projectId: string;
+ projectId?: string | null;
workspaceId?: string | null;
conversationId?: string | null;
sessionId?: string | null;
@@ -61,7 +61,8 @@ const WORKBENCH_EVENT_NAMES = [
export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): WorkbenchEventStream | null {
if (typeof EventSource === "undefined") return null;
- const params = new URLSearchParams({ projectId: options.projectId });
+ const params = new URLSearchParams();
+ appendParam(params, "projectId", options.projectId);
appendParam(params, "workspaceId", options.workspaceId);
appendParam(params, "conversationId", options.conversationId);
appendParam(params, "sessionId", options.sessionId);
diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts
index 27ef80e8..e45e3eb3 100644
--- a/web/hwlab-cloud-web/src/api/workbench.ts
+++ b/web/hwlab-cloud-web/src/api/workbench.ts
@@ -1,5 +1,30 @@
import { fetchJson } from "./client";
-import type { ApiResult, ConversationRecord, WorkspaceRecord } from "@/types";
+import type { ApiResult, ChatMessage, ConversationRecord, WorkspaceRecord } from "@/types";
+
+export interface WorkbenchSessionListResponse {
+ projectId?: string | null;
+ sessions?: ConversationRecord[];
+ conversations?: ConversationRecord[];
+ count?: number;
+}
+
+export interface WorkbenchSessionDetailResponse {
+ session?: ConversationRecord;
+}
+
+export interface WorkbenchMessagePageResponse {
+ sessionId?: string | null;
+ conversationId?: string | null;
+ messages?: ChatMessage[];
+ count?: number;
+ total?: number;
+ hasMore?: boolean;
+}
+
+export interface SessionListOptions {
+ includeSessionId?: string | null;
+ timeoutMs?: number | null;
+}
export interface ConversationListOptions {
includeConversationId?: string | null;
@@ -11,10 +36,21 @@ export interface ConversationDetailOptions {
timeoutMs?: number | null;
}
+export interface SessionMessageOptions {
+ limit?: number | null;
+ cursor?: string | null;
+ timeoutMs?: number | null;
+}
+
+const SESSION_LIST_TIMEOUT_MS = 30_000;
+const SESSION_DETAIL_TIMEOUT_MS = 45_000;
const CONVERSATION_LIST_TIMEOUT_MS = 30_000;
const CONVERSATION_DETAIL_TIMEOUT_MS = 45_000;
export const workbenchAPI = {
+ sessions: (options: SessionListOptions = {}): Promise
> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }),
+ session: (sessionId: string, timeoutMs: number | null = null): Promise> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }),
+ sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }),
workspace: (projectId: string): Promise> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
@@ -24,6 +60,23 @@ export const workbenchAPI = {
saveConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" })
};
+function sessionListPath(options: SessionListOptions): string {
+ const params = new URLSearchParams();
+ const includeSessionId = typeof options.includeSessionId === "string" ? options.includeSessionId.trim() : "";
+ if (includeSessionId) params.set("includeSessionId", includeSessionId);
+ const query = params.toString();
+ return `/v1/workbench/sessions${query ? `?${query}` : ""}`;
+}
+
+function sessionMessagesPath(sessionId: string, options: SessionMessageOptions): string {
+ const params = new URLSearchParams();
+ if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit)))));
+ const cursor = typeof options.cursor === "string" ? options.cursor.trim() : "";
+ if (cursor) params.set("cursor", cursor);
+ const query = params.toString();
+ return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages${query ? `?${query}` : ""}`;
+}
+
function conversationDetailPath(conversationId: string, options: ConversationDetailOptions): string {
const params = new URLSearchParams();
const projectId = typeof options.projectId === "string" ? options.projectId.trim() : "";
diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue
index 72ef61c1..b3dcfa4a 100644
--- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue
+++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
import { useWorkbenchStore } from "@/stores/workbench";
import { useClipboard } from "@/composables/useClipboard";
import LoadingState from "@/components/common/LoadingState.vue";
-import { normalizeWorkbenchProjectId, workbenchSessionPath } from "@/utils";
+import { workbenchSessionPath } from "@/utils";
const workbench = useWorkbenchStore();
const { copied, copy } = useClipboard();
@@ -18,10 +18,8 @@ function copyActive(): void {
void copy(`url=${sessionUrl(tab)}\nconversation=${tab.conversationId}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`);
}
-function sessionUrl(tab: { conversationId: string }): string {
- const url = new URL(workbenchSessionPath(tab.conversationId), window.location.origin);
- const projectId = normalizeWorkbenchProjectId(workbench.workspace?.projectId) ?? normalizeWorkbenchProjectId(workbench.workspace?.workspace?.projectId);
- if (projectId) url.searchParams.set("projectId", projectId);
+function sessionUrl(tab: { sessionId?: string | null; conversationId: string }): string {
+ const url = new URL(workbenchSessionPath(tab.sessionId || tab.conversationId), window.location.origin);
return url.toString();
}
diff --git a/web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts b/web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts
deleted file mode 100644
index 0b9a1ab0..00000000
--- a/web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { authAPI, workbenchAPI, type WorkspaceBootstrapResponse } from "@/api";
-import type { ApiResult, WorkspaceRecord } from "@/types";
-import { DEFAULT_WORKBENCH_PROJECT_ID, asRecord, normalizeWorkbenchProjectId } from "@/utils";
-
-export async function consumeEarlyWorkspaceBootstrap(projectId: string): Promise | null> {
- const pending = window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP;
- if (!pending) return null;
- window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = undefined;
- const early = await pending;
- const earlyProjectId = normalizeWorkbenchProjectId(early.projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
- if (earlyProjectId !== projectId) return null;
- return {
- ok: early.ok === true,
- status: Number.isFinite(Number(early.status)) ? Number(early.status) : 0,
- data: asRecord(early.data) as WorkspaceBootstrapResponse | null,
- error: early.ok === true ? null : early.error ?? `HTTP ${early.status ?? 0}`
- };
-}
-
-export async function resolveWorkspaceBootstrap(projectId: string): Promise> {
- const normalizedProjectId = normalizeWorkbenchProjectId(projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
- const earlyWorkspace = await consumeEarlyWorkspaceBootstrap(normalizedProjectId);
- return resolveWorkspaceBootstrapFromCurrentApi(normalizedProjectId, earlyWorkspace);
-}
-
-async function resolveWorkspaceBootstrapFromCurrentApi(projectId: string, earlyWorkspace: ApiResult | null): Promise> {
- const session = await authAPI.session();
- const sessionPayload = session.data ?? { authenticated: false, mode: "server" as const };
- if (!session.ok || sessionPayload.authenticated !== true) {
- return {
- ok: session.ok,
- status: session.status,
- data: { ...sessionPayload, projectId, workspace: null, workspaceStatus: earlyWorkspace?.status ?? null, workspaceError: session.error ?? session.status },
- error: session.error
- };
- }
- const workspace = earlyWorkspace?.ok ? earlyWorkspace : await workbenchAPI.workspace(projectId);
- const workspacePayload = workspace.ok ? asRecord(workspace.data) : null;
- return {
- ok: true,
- status: session.status,
- data: {
- ...sessionPayload,
- projectId,
- workspace: (workspacePayload?.workspace as WorkspaceRecord | null | undefined) ?? null,
- workspaceStatus: workspace.status,
- workspaceError: workspace.ok ? null : workspace.error ?? workspace.status
- },
- error: null
- };
-}
-
-export function publishWorkspaceBootstrap(projectId: string, workspace: WorkspaceRecord | null | undefined): void {
- if (!workspace) return;
- window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = { projectId, workspace };
-}
diff --git a/web/hwlab-cloud-web/src/router/guards.ts b/web/hwlab-cloud-web/src/router/guards.ts
index 027f88b4..18e3999f 100644
--- a/web/hwlab-cloud-web/src/router/guards.ts
+++ b/web/hwlab-cloud-web/src/router/guards.ts
@@ -1,7 +1,6 @@
import type { Router } from "vue-router";
import { isNavigationFailure } from "vue-router";
import { useAuthStore } from "@/stores/auth";
-import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId } from "@/utils";
import { resolveDocumentTitle } from "./title";
const CHUNK_RELOAD_KEY = "hwlab:v03:chunk-reload-at";
@@ -30,7 +29,7 @@ export function installChunkRecovery(router: Router): void {
export function installRouterGuards(router: Router): void {
router.beforeEach(async (to) => {
const auth = useAuthStore();
- if (!auth.checked) await auth.bootstrap(projectIdFromRoute(to));
+ if (!auth.checked) await auth.bootstrap();
if (to.meta.requiresAuth !== false && auth.authState === "login") return { name: "Login", query: { redirect: to.fullPath } };
if (to.meta.requiresAdmin === true && !auth.isAdmin) return { name: "Dashboard", query: { blocked: "admin_required" } };
return true;
@@ -43,9 +42,3 @@ export function installRouterGuards(router: Router): void {
installChunkRecovery(router);
}
-
-function projectIdFromRoute(to: { query?: Record }): string {
- const raw = to.query?.projectId;
- const value = Array.isArray(raw) ? raw[0] : raw;
- return normalizeWorkbenchProjectId(value) ?? DEFAULT_WORKBENCH_PROJECT_ID;
-}
diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts
index 1c8280a6..6a002f24 100644
--- a/web/hwlab-cloud-web/src/router/index.ts
+++ b/web/hwlab-cloud-web/src/router/index.ts
@@ -8,7 +8,7 @@ const routes: RouteRecordRaw[] = [
{ path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } },
{ path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } },
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, title: "平台概览", section: "user" } },
- { path: "/workbench/sessions/:conversationId", alias: ["/workspace/sessions/:conversationId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
+ { path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } },
{ path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, title: "用量与性能", section: "user" } },
diff --git a/web/hwlab-cloud-web/src/router/workbench-navigation.ts b/web/hwlab-cloud-web/src/router/workbench-navigation.ts
index c6812fff..17ec66af 100644
--- a/web/hwlab-cloud-web/src/router/workbench-navigation.ts
+++ b/web/hwlab-cloud-web/src/router/workbench-navigation.ts
@@ -3,9 +3,9 @@ export interface WorkbenchNavigationContext {
routeName?: unknown;
routePath?: unknown;
routeSection?: unknown;
- routeConversationId?: string | null;
- conversationId?: string | null;
- applyingRouteConversation?: boolean;
+ routeSessionId?: string | null;
+ sessionId?: string | null;
+ applyingRouteSession?: boolean;
}
const WORKBENCH_ROUTE_NAMES = new Set(["CodeWorkbench", "CodeWorkbenchSession"]);
@@ -19,9 +19,13 @@ export function isWorkbenchNavigationContext(input: WorkbenchNavigationContext):
}
export function shouldReflectWorkbenchConversationUrl(input: WorkbenchNavigationContext): boolean {
- if (!input.conversationId) return false;
+ return shouldReflectWorkbenchSessionUrl(input);
+}
+
+export function shouldReflectWorkbenchSessionUrl(input: WorkbenchNavigationContext): boolean {
+ if (!input.sessionId) return false;
if (!isWorkbenchNavigationContext(input)) return false;
- if (input.routeConversationId === input.conversationId) return false;
- if (input.applyingRouteConversation && input.routeConversationId && input.routeConversationId !== input.conversationId) return false;
+ if (input.routeSessionId === input.sessionId) return false;
+ if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId) return false;
return true;
}
diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts
index b2a11514..22c2b1c1 100644
--- a/web/hwlab-cloud-web/src/stores/auth.ts
+++ b/web/hwlab-cloud-web/src/stores/auth.ts
@@ -1,9 +1,8 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api";
-import { publishWorkspaceBootstrap, resolveWorkspaceBootstrap } from "@/composables/useWorkspaceBootstrap";
import type { AuthSession, AuthState, AuthUser } from "@/types";
-import { DEFAULT_WORKBENCH_PROJECT_ID, asRecord, nonEmptyString, normalizeWorkbenchProjectId } from "@/utils";
+import { asRecord, nonEmptyString } from "@/utils";
export const useAuthStore = defineStore("auth", () => {
const authState = ref("checking");
@@ -15,17 +14,13 @@ export const useAuthStore = defineStore("auth", () => {
const user = computed(() => session.value?.user ?? session.value?.actor ?? null);
const isAdmin = computed(() => user.value?.role === "admin");
- async function bootstrap(projectId = DEFAULT_WORKBENCH_PROJECT_ID): Promise {
+ async function bootstrap(): Promise {
if (authState.value !== "checking") checked.value = true;
- const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
- const mode = config?.mode ?? "auto";
- const normalizedProjectId = normalizeWorkbenchProjectId(projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
- const current = mode === "auto" ? await resolveWorkspaceBootstrap(normalizedProjectId) : await authAPI.session();
+ const current = await authAPI.session();
if (current.ok && current.data?.authenticated === true) {
session.value = sessionFromPayload(current.data);
authState.value = "authenticated";
checked.value = true;
- publishWorkspaceBootstrap(normalizedProjectId, workspaceFromUnknown(asRecord(current.data)?.workspace));
document.body.dataset.authState = "authenticated";
return;
}
@@ -88,12 +83,6 @@ function userFromUnknown(value: unknown): AuthUser | undefined {
return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: nonEmptyString(record.role) ?? undefined, status: nonEmptyString(record.status) ?? undefined };
}
-function workspaceFromUnknown(value: unknown): { workspaceId: string } | null {
- const record = asRecord(value);
- const workspaceId = nonEmptyString(record?.workspaceId);
- return workspaceId ? { ...record, workspaceId } : null;
-}
-
function loginFailureMessage(response: Awaited>): string {
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
diff --git a/web/hwlab-cloud-web/src/stores/workbench-projection.ts b/web/hwlab-cloud-web/src/stores/workbench-projection.ts
index 31fb404e..c82651d3 100644
--- a/web/hwlab-cloud-web/src/stores/workbench-projection.ts
+++ b/web/hwlab-cloud-web/src/stores/workbench-projection.ts
@@ -1,7 +1,7 @@
-// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0.
+// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1.
// Responsibility: Workbench UI projection helpers that keep route/explicit selection separate from server snapshots.
-import { firstNonEmptyString, normalizeWorkbenchConversationId } from "@/utils";
+import { firstNonEmptyString, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils";
export interface WorkbenchActiveConversationInput {
switchingConversationId?: string | null;
@@ -23,6 +23,17 @@ export function initialWorkbenchConversationIdFromLocation(location: Pick | null = typeof window === "undefined" ? null : window.location): string | null {
+ const pathname = location?.pathname ?? "";
+ const match = /^\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u.exec(pathname);
+ if (!match?.[1]) return null;
+ try {
+ return normalizeWorkbenchSessionId(decodeURIComponent(match[1]));
+ } catch {
+ return normalizeWorkbenchSessionId(match[1]);
+ }
+}
+
export function nextExplicitWorkbenchConversationId(current: string | null, candidate: string | null | undefined): string | null {
return normalizeWorkbenchConversationId(candidate) ?? current;
}
diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts
index 74602e05..cb17b7b9 100644
--- a/web/hwlab-cloud-web/src/stores/workbench.ts
+++ b/web/hwlab-cloud-web/src/stores/workbench.ts
@@ -7,10 +7,10 @@ import { api } from "@/api";
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { assistantTextFromTraceEvents, mergeRunnerTrace, snapshotToRunnerTrace, 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 { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedConversation, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session";
-import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "./workbench-projection";
+import { initialWorkbenchConversationIdFromLocation, initialWorkbenchSessionIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "./workbench-projection";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
@@ -21,6 +21,7 @@ const TRACE_HYDRATION_MAX_ATTEMPTS = 3;
const TRACE_HYDRATION_RETRY_DELAY_MS = 700;
interface HydrateOptions {
+ sessionId?: string | null;
conversationId?: string | null;
}
@@ -31,7 +32,7 @@ interface SessionCreatePersistenceResult {
export const useWorkbenchStore = defineStore("workbench", () => {
const projectId = ref(resolveInitialWorkbenchProjectId());
- const workspace = ref(window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?.workspace ?? null);
+ const workspace = ref(null);
const conversations = ref([]);
const messages = ref([]);
const providerProfile = ref(readString("hwlab.workbench.providerProfile.v1", "codex"));
@@ -49,6 +50,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const explicitConversationId = ref(initialWorkbenchConversationIdFromLocation());
+ const explicitSessionId = ref(initialWorkbenchSessionIdFromLocation());
const workspaceSelectionEpoch = ref(0);
const serverState = ref(createWorkbenchServerState());
const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value));
@@ -65,6 +67,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const activeConversationId = computed(() => resolveWorkbenchActiveConversationId({ switchingConversationId: switchingConversationId.value, explicitConversationId: explicitConversationId.value }));
const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
const selectedSessionId = computed(() => firstNonEmptyString(activeConversation.value?.sessionId));
+ const activeSessionId = computed(() => firstNonEmptyString(selectedSessionId.value, explicitSessionId.value));
const selectedThreadId = computed(() => firstNonEmptyString(activeConversation.value?.threadId));
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.value));
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value }));
@@ -78,7 +81,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
async function hydrate(options: HydrateOptions = {}): Promise {
+ const routeSessionId = normalizeWorkbenchSessionId(options.sessionId);
const routeConversationId = normalizeWorkbenchConversationId(options.conversationId);
+ if (routeSessionId) setActiveSessionSelection(routeSessionId);
if (routeConversationId) setActiveConversationSelection(routeConversationId);
const requestEpoch = workspaceSelectionEpoch.value;
const previousConversationId = activeConversationId.value;
@@ -86,58 +91,51 @@ export const useWorkbenchStore = defineStore("workbench", () => {
conversationsReady.value = conversations.value.length > 0;
loading.value = true;
error.value = null;
- const workspaceResult = await api.workbench.workspace(projectId.value);
- if (!workspaceResult.ok) {
+ const includeSessionId = routeSessionId ?? activeSessionId.value;
+ const sessionsResult = await api.workbench.sessions({ includeSessionId });
+ if (!sessionsResult.ok) {
clearConversationDetailLoading(routeConversationId);
loading.value = false;
- error.value = workspaceResult.error ?? "workspace unavailable";
+ error.value = sessionsResult.error ?? "session list unavailable";
return;
}
- const nextWorkspace = workspaceResult.data?.workspace ?? null;
- const nextProjectId = workspaceProjectId(nextWorkspace, projectId.value);
- rememberWorkspaceSnapshot(nextProjectId, nextWorkspace);
- projectId.value = nextProjectId;
- const selectedConversationId = routeConversationId ?? activeConversationId.value ?? selectedConversationIdFromWorkspace(nextWorkspace);
+ const sessionConversations = workbenchConversationsFromSessionPayload(sessionsResult.data);
+ const selectedConversationId = routeConversationId
+ ?? conversationIdForSessionId(sessionConversations, includeSessionId)
+ ?? activeConversationId.value
+ ?? selectedConversationIdFromWorkspace(workspace.value)
+ ?? sessionConversations[0]?.conversationId
+ ?? null;
bootstrapActiveConversationSelection(selectedConversationId);
- const conversationsPromise = api.workbench.conversations(nextProjectId, { includeConversationId: selectedConversationId });
if (selectedConversationId) conversationDetailLoadingId.value = selectedConversationId;
- const selectedConversationResult = selectedConversationId ? await api.workbench.conversation(selectedConversationId, { projectId: nextProjectId }) : null;
+ const selectedSessionIdForDetail = includeSessionId ?? sessionIdFromConversation(sessionConversations.find((item) => item.conversationId === selectedConversationId));
+ const selectedConversation = selectedSessionIdForDetail
+ ? await loadWorkbenchSessionConversation(selectedSessionIdForDetail, sessionConversations.find((item) => item.sessionId === selectedSessionIdForDetail) ?? null)
+ : selectedConversationId
+ ? await loadLegacyConversationDetail(selectedConversationId, activeProjectId.value)
+ : null;
clearConversationDetailLoading(selectedConversationId);
- const selectedConversation = selectedConversationResult?.ok && !isArchivedConversation(selectedConversationResult.data?.conversation) ? selectedConversationResult.data?.conversation ?? null : null;
rememberConversationDetail(selectedConversation);
if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
- if (selectedConversationId && selectedConversationResult && (!selectedConversationResult.ok || isArchivedConversation(selectedConversationResult.data?.conversation))) error.value = isArchivedConversation(selectedConversationResult.data?.conversation) ? "session archived" : selectedConversationResult.error ?? "conversation unavailable";
- const workspaceSnapshot = selectedConversation
- ? workspaceWithSelectedConversation(nextWorkspace, selectedConversation, conversationProjectId(selectedConversation, nextProjectId))
- : nextWorkspace;
- if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: workspaceSnapshot })) {
- workspace.value = workspaceSnapshot;
- rememberWorkspaceSnapshot(nextProjectId, workspaceSnapshot);
+ if (selectedConversationId && !selectedConversation) error.value = "session URL not found";
+ const selectedProjectId = conversationProjectId(selectedConversation, activeProjectId.value);
+ if (selectedConversation && isCurrentWorkspaceSelection(requestEpoch, selectedConversation.conversationId)) {
+ workspace.value = workspaceWithSelectedConversation(workspace.value, selectedConversation, selectedProjectId);
+ if (workspace.value) rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
+ setActiveSessionSelection(sessionIdFromConversation(selectedConversation));
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
- rememberWorkbenchProjectId(workspaceProjectId(workspace.value, nextProjectId));
+ rememberWorkbenchProjectId(workspaceProjectId(workspace.value, selectedProjectId));
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, previousConversationId, messages.value), messages.value);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
void hydrateTraceEvents(messages.value);
reattachRestoredActiveTrace();
}
- const conversationsResult = await conversationsPromise;
- let nextConversations = conversations.value;
- if (conversationsResult.ok) {
- nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationId, selectedConversation);
- conversations.value = nextConversations;
- rememberConversationList(nextConversations);
- conversationsReady.value = true;
- void refreshSessionStatusAuthority(nextConversations);
- } else {
- if (selectedConversation) {
- nextConversations = mergeConversationIntoList(nextConversations, selectedConversation);
- conversations.value = nextConversations;
- rememberConversationDetail(selectedConversation);
- }
- conversationsReady.value = conversations.value.length > 0;
- error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable";
- }
+ const nextConversations = stableConversationList(conversations.value, sessionConversations, selectedConversationId, selectedConversation);
+ conversations.value = nextConversations;
+ rememberConversationList(nextConversations);
+ conversationsReady.value = true;
+ void refreshSessionStatusAuthority(nextConversations);
loading.value = false;
await refreshProviderOptions();
restartRealtime("hydrate");
@@ -162,6 +160,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const selectedProjectId = activeProjectId.value;
const optimisticConversation = makeOptimisticConversation(selectedProjectId);
setActiveConversationSelection(optimisticConversation.conversationId);
+ setActiveSessionSelection(optimisticConversation.sessionId);
conversations.value = mergeConversationIntoList(conversations.value, optimisticConversation);
rememberConversationDetail(optimisticConversation);
conversationsReady.value = true;
@@ -189,6 +188,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const tabProjectId = conversation.projectId ?? activeProjectId.value;
switchingConversationId.value = conversation.conversationId;
setActiveConversationSelection(conversation.conversationId);
+ setActiveSessionSelection(sessionIdFromConversation(conversation));
conversationDetailLoadingId.value = conversation.conversationId;
conversations.value = mergeConversationIntoList(conversations.value, conversation);
rememberConversationDetail(conversation);
@@ -266,6 +266,36 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return activeConversationId.value === normalized;
}
+ async function selectSessionById(sessionId: string): Promise {
+ const normalized = normalizeWorkbenchSessionId(sessionId);
+ if (!normalized) return selectConversationById(sessionId);
+ const previousConversationId = activeConversationId.value;
+ const previousMessages = messages.value;
+ setActiveSessionSelection(normalized);
+ const existing = visibleConversations.value.find((conversation) => sessionIdFromConversation(conversation) === normalized) ?? null;
+ if (existing && (existing.messages?.length ?? 0) > 0) {
+ applySelectedConversationDetail(existing, workspace.value, conversationProjectId(existing, activeProjectId.value), previousConversationId, previousMessages);
+ return sessionIdFromConversation(activeConversation.value) === normalized;
+ }
+ const requestEpoch = beginWorkspaceSelection();
+ startWorkbenchSessionSwitch({ conversationId: existing?.conversationId ?? normalized, source: "deeplink", targetState: existing?.status ?? "unknown", cache: "cold" });
+ const conversation = await loadWorkbenchSessionConversation(normalized, existing);
+ if (!conversation || isArchivedConversation(conversation)) {
+ clearConversationDetailLoading(existing?.conversationId ?? null);
+ isolateConversationLoadFailure(existing?.conversationId ?? previousConversationId ?? "", previousConversationId, previousMessages, isArchivedConversation(conversation) ? "session archived" : "session URL not found");
+ failWorkbenchSessionSwitch(existing?.conversationId ?? normalized);
+ return false;
+ }
+ if (!isCurrentWorkspaceSelection(requestEpoch)) return true;
+ applySelectedConversationDetail(conversation, workspace.value, conversationProjectId(conversation, activeProjectId.value), previousConversationId, previousMessages);
+ clearSwitchingConversation(conversation.conversationId);
+ clearConversationDetailLoading(conversation.conversationId);
+ await refreshSelectedSessionStatus(conversation);
+ await refreshConversations(conversation.conversationId);
+ finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, "ok");
+ return sessionIdFromConversation(activeConversation.value) === normalized;
+ }
+
async function deleteCurrentSession(): Promise {
const conversationId = activeConversationId.value;
if (!conversationId) return;
@@ -285,9 +315,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise {
- const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId });
+ const includeSessionId = sessionIdFromConversation(visibleConversations.value.find((item) => item.conversationId === includeConversationId)) ?? activeSessionId.value;
+ const response = await api.workbench.sessions({ includeSessionId });
if (response.ok) {
- conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value);
+ conversations.value = stableConversationList(conversations.value, workbenchConversationsFromSessionPayload(response.data), includeConversationId, activeConversation.value);
rememberConversationList(conversations.value);
conversationsReady.value = true;
await refreshSessionStatusAuthority(conversations.value);
@@ -418,6 +449,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
explicitConversationId.value = nextExplicitWorkbenchConversationId(explicitConversationId.value, conversationId);
}
+ function setActiveSessionSelection(sessionId: string | null | undefined): void {
+ explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value;
+ }
+
function replaceActiveConversationSelection(conversationId: string | null | undefined): void {
explicitConversationId.value = normalizeWorkbenchConversationId(conversationId);
}
@@ -784,17 +819,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function restartRealtime(reason: string): void {
const traceId = realtimeTraceId();
const workspaceId = workspace.value?.workspaceId ?? null;
- const conversationId = activeConversationId.value ?? null;
const sessionId = selectedSessionId.value ?? null;
- const key = [activeProjectId.value, workspaceId ?? "", conversationId ?? "", sessionId ?? "", traceId ?? ""].join("|");
+ const key = [sessionId ?? "", traceId ?? "", workspaceId ?? ""].join("|");
if (key === realtimeKey && realtimeStream) return;
stopRealtime();
realtimeKey = key;
- if (!workspaceId && !traceId) return;
+ if (!sessionId && !workspaceId && !traceId) return;
realtimeStream = connectWorkbenchEvents({
- projectId: activeProjectId.value,
workspaceId,
- conversationId,
sessionId,
traceId,
onOpen: () => scheduleRealtimeGapHydration(`${reason}:open`),
@@ -915,12 +947,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function hydrateRealtimeGap(reason: string): Promise {
recordActivity(`realtime-gap:${reason}`);
- const [workspaceResult] = await Promise.all([
- api.workbench.workspace(activeProjectId.value),
+ await Promise.all([
+ refreshConversations(),
hydrateTurnStatusAuthority(messages.value),
hydrateTraceEvents(messages.value)
]);
- if (workspaceResult.ok && workspaceResult.data?.workspace) applyRealtimeWorkspaceSnapshot(workspaceResult.data.workspace);
restartRealtime(`gap:${reason}`);
}
@@ -1034,7 +1065,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function ensureWorkspace(): Promise {
if (workspace.value) return workspace.value;
- await hydrate();
+ const response = await api.workbench.workspace(activeProjectId.value);
+ if (response.ok && response.data?.workspace) {
+ workspace.value = response.data.workspace;
+ rememberWorkspaceSnapshot(activeProjectId.value, workspace.value);
+ return workspace.value;
+ }
+ await hydrate({ sessionId: activeSessionId.value });
return workspace.value;
}
@@ -1204,9 +1241,72 @@ export const useWorkbenchStore = defineStore("workbench", () => {
installRealtimeVisibilityHandler();
- return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, conversationDetailLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
+ return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, conversationDetailLoading, chatPending, error, activeConversationId, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, selectSessionById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
});
+function workbenchConversationsFromSessionPayload(payload: unknown): ConversationRecord[] {
+ const record = recordValue(payload);
+ const conversations = Array.isArray(record?.conversations) ? record.conversations as ConversationRecord[] : [];
+ if (conversations.length > 0) return conversations.filter((item) => Boolean(normalizeWorkbenchConversationId(item.conversationId)));
+ const sessions = Array.isArray(record?.sessions) ? record.sessions : [];
+ return sessions.map((item) => conversationFromWorkbenchSession(item)).filter((item): item is ConversationRecord => Boolean(item));
+}
+
+function conversationFromWorkbenchSession(value: unknown): ConversationRecord | null {
+ const record = recordValue(value);
+ const sessionId = normalizeWorkbenchSessionId(record?.sessionId);
+ const conversationId = normalizeWorkbenchConversationId(record?.conversationId);
+ if (!sessionId || !conversationId) return null;
+ return {
+ conversationId,
+ sessionId,
+ projectId: firstNonEmptyString(record?.projectId),
+ threadId: firstNonEmptyString(record?.threadId),
+ status: firstNonEmptyString(record?.status),
+ updatedAt: firstNonEmptyString(record?.updatedAt),
+ startedAt: firstNonEmptyString(record?.startedAt),
+ lastTraceId: firstNonEmptyString(record?.lastTraceId),
+ messageCount: firstFiniteNumber(record?.messageCount) ?? undefined,
+ firstUserMessagePreview: firstNonEmptyString(record?.firstUserMessagePreview),
+ session: { sessionId, threadId: firstNonEmptyString(record?.threadId), status: firstNonEmptyString(record?.status) }
+ };
+}
+
+function conversationIdForSessionId(conversations: ConversationRecord[], sessionId: string | null | undefined): string | null {
+ const id = normalizeWorkbenchSessionId(sessionId);
+ if (!id) return null;
+ return conversations.find((conversation) => sessionIdFromConversation(conversation) === id)?.conversationId ?? null;
+}
+
+async function loadWorkbenchSessionConversation(sessionId: string, seed: ConversationRecord | null = null): Promise {
+ const id = normalizeWorkbenchSessionId(sessionId);
+ if (!id) return null;
+ const [detail, messages] = await Promise.all([
+ api.workbench.session(id),
+ api.workbench.sessionMessages(id, { limit: 100 })
+ ]);
+ const detailConversation = detail.ok ? conversationFromWorkbenchSession(detail.data?.session) : null;
+ const base = detailConversation ?? seed;
+ if (!base) return null;
+ const page = messages.ok ? messages.data : null;
+ const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages;
+ return {
+ ...base,
+ sessionId: id,
+ conversationId: normalizeWorkbenchConversationId(base.conversationId) ?? normalizeWorkbenchConversationId(page?.conversationId) ?? base.conversationId,
+ messages: pageMessages,
+ messageCount: page?.total ?? pageMessages?.length ?? base.messageCount
+ };
+}
+
+async function loadLegacyConversationDetail(conversationId: string, projectId: string): Promise {
+ const id = normalizeWorkbenchConversationId(conversationId);
+ if (!id) return null;
+ const response = await api.workbench.conversation(id, { projectId });
+ const conversation = response.ok && !isArchivedConversation(response.data?.conversation) ? response.data?.conversation ?? null : null;
+ return conversation;
+}
+
function sessionIdFromConversation(conversation: ConversationRecord | null | undefined): string | null {
return firstNonEmptyString(conversation?.sessionId, conversation?.session?.sessionId) ?? null;
}
@@ -1304,6 +1404,7 @@ function messagesFromSelectedConversation(conversation: ConversationRecord | nul
}
function normalizeChatMessage(message: ChatMessage): ChatMessage {
+ const role = (message.role as string) === "assistant" ? "agent" : message.role;
const runnerTrace = normalizeMessageRunnerTrace(message);
const error = normalizeAgentError(message.error ?? runnerTrace?.error);
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
@@ -1312,11 +1413,11 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage {
const finalText = firstNonEmptyString(finalResponseText((message as Record).finalResponse), finalResponseText(runnerTrace?.finalResponse));
const traceAssistantText = assistantTextFromTraceEvents(Array.isArray(runnerTrace?.events) ? runnerTrace.events : []);
const errorText = agentErrorDisplayText(error);
- const text = message.role === "agent" && isTerminalMessageStatus(status)
+ const text = role === "agent" && isTerminalMessageStatus(status)
? firstNonEmptyString(finalText, errorText, traceAssistantText, baseText) ?? ""
: firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? "";
const messageId = firstNonEmptyString((message as Record).messageId, message.id) ?? nextProtocolId("msg");
- return { ...message, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(message.role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
+ return { ...message, role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
}
function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record): string | null {
diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts
index 25255e54..f61083c2 100644
--- a/web/hwlab-cloud-web/src/types/global.d.ts
+++ b/web/hwlab-cloud-web/src/types/global.d.ts
@@ -1,20 +1,8 @@
-import type { WorkspaceRecord } from "./index";
-
-export interface WorkbenchEarlyWorkspaceBootstrap {
- ok: boolean;
- status: number;
- data: unknown;
- error?: string;
- projectId?: string | null;
-}
-
declare global {
interface Window {
HWLAB_CLOUD_WEB_CONFIG?: {
auth?: { mode?: "auto" | "manual"; username?: string; password?: string };
};
- HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP?: Promise;
- HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?: { projectId: string; workspace: WorkspaceRecord };
}
}
diff --git a/web/hwlab-cloud-web/src/utils/index.ts b/web/hwlab-cloud-web/src/utils/index.ts
index 20e2d6aa..1d37864e 100644
--- a/web/hwlab-cloud-web/src/utils/index.ts
+++ b/web/hwlab-cloud-web/src/utils/index.ts
@@ -31,8 +31,14 @@ export function normalizeWorkbenchConversationId(value: unknown): string | null
return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
}
-export function workbenchSessionPath(conversationId: string): string {
- return `/workbench/sessions/${encodeURIComponent(conversationId)}`;
+export function normalizeWorkbenchSessionId(value: unknown): string | null {
+ const text = nonEmptyString(value);
+ if (!text) return null;
+ return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
+}
+
+export function workbenchSessionPath(sessionId: string): string {
+ return `/workbench/sessions/${encodeURIComponent(sessionId)}`;
}
export function resolveInitialWorkbenchProjectId(): string {
diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue
index f89ae7e9..9c1dffc1 100644
--- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue
+++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue
@@ -9,67 +9,68 @@ import ConversationPanel from "@/components/workbench/ConversationPanel.vue";
import SessionRail from "@/components/workbench/SessionRail.vue";
import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue";
import { useAutoRefresh } from "@/composables/useAutoRefresh";
-import { shouldReflectWorkbenchConversationUrl } from "@/router/workbench-navigation";
+import { shouldReflectWorkbenchSessionUrl } from "@/router/workbench-navigation";
import { useWorkbenchStore } from "@/stores/workbench";
-import { normalizeWorkbenchConversationId, normalizeWorkbenchProjectId } from "@/utils";
+import { normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils";
import { finishWorkbenchOpenFullLoad, startWorkbenchOpenJourney } from "@/utils/workbench-performance";
const workbench = useWorkbenchStore();
const route = useRoute();
const router = useRouter();
-const routeConversationId = computed(() => normalizeWorkbenchConversationId(route.params.conversationId));
-const applyingRouteConversation = ref(Boolean(routeConversationId.value));
+const routeSessionId = computed(() => normalizeWorkbenchSessionId(route.params.sessionId));
+const routeLegacyConversationId = computed(() => normalizeWorkbenchConversationId(route.params.sessionId));
+const applyingRouteSession = ref(Boolean(routeSessionId.value || routeLegacyConversationId.value));
const componentActive = ref(false);
onMounted(async () => {
componentActive.value = true;
- applyingRouteConversation.value = Boolean(routeConversationId.value);
- startWorkbenchOpenJourney({ route: route.path, cache: routeConversationId.value ? "cold" : "warm", authState: "warm" });
+ applyingRouteSession.value = Boolean(routeSessionId.value || routeLegacyConversationId.value);
+ startWorkbenchOpenJourney({ route: route.path, cache: routeSessionId.value || routeLegacyConversationId.value ? "cold" : "warm", authState: "warm" });
try {
- await workbench.hydrate({ conversationId: routeConversationId.value });
- await applyRouteConversation();
+ await workbench.hydrate({ sessionId: routeSessionId.value, conversationId: routeLegacyConversationId.value });
+ await applyRouteSession();
finishWorkbenchOpenFullLoad(workbench.error ? "error" : "ok");
} finally {
- applyingRouteConversation.value = false;
+ applyingRouteSession.value = false;
}
- if (!routeConversationId.value || routeConversationId.value === workbench.activeConversationId) await reflectActiveConversationInUrl(workbench.activeConversationId);
+ if (!routeSessionId.value || routeSessionId.value === workbench.activeSessionId) await reflectActiveSessionInUrl(workbench.activeSessionId);
});
onBeforeUnmount(() => {
componentActive.value = false;
});
-watch(routeConversationId, () => void applyRouteConversation());
-watch(() => workbench.activeConversationId, (conversationId) => void reflectActiveConversationInUrl(conversationId));
+watch(() => route.params.sessionId, () => void applyRouteSession());
+watch(() => workbench.activeSessionId, (sessionId) => void reflectActiveSessionInUrl(sessionId));
useAutoRefresh(() => workbench.refreshLive(), 30_000);
-async function applyRouteConversation(): Promise {
- const conversationId = routeConversationId.value;
- if (!conversationId) return true;
- applyingRouteConversation.value = true;
+async function applyRouteSession(): Promise {
+ const sessionId = routeSessionId.value;
+ const legacyConversationId = routeLegacyConversationId.value;
+ if (!sessionId && !legacyConversationId) return true;
+ applyingRouteSession.value = true;
try {
- return await workbench.selectConversationById(conversationId);
+ return sessionId ? await workbench.selectSessionById(sessionId) : await workbench.selectConversationById(legacyConversationId ?? "");
} finally {
- applyingRouteConversation.value = false;
- await reflectActiveConversationInUrl(workbench.activeConversationId);
+ applyingRouteSession.value = false;
+ await reflectActiveSessionInUrl(workbench.activeSessionId);
}
}
-async function reflectActiveConversationInUrl(value: string | null): Promise {
- const conversationId = normalizeWorkbenchConversationId(value);
- const routeTarget = routeConversationId.value;
- if (!shouldReflectWorkbenchConversationUrl({
+async function reflectActiveSessionInUrl(value: string | null): Promise {
+ const sessionId = normalizeWorkbenchSessionId(value);
+ const routeTarget = routeSessionId.value;
+ if (!shouldReflectWorkbenchSessionUrl({
componentActive: componentActive.value,
routeName: route.name,
routePath: route.path,
routeSection: route.meta.section,
- routeConversationId: routeTarget,
- conversationId,
- applyingRouteConversation: applyingRouteConversation.value
+ routeSessionId: routeTarget,
+ sessionId,
+ applyingRouteSession: applyingRouteSession.value
})) return;
- const projectId = normalizeWorkbenchProjectId(route.query.projectId) ?? normalizeWorkbenchProjectId(workbench.workspace?.projectId) ?? normalizeWorkbenchProjectId(workbench.workspace?.workspace?.projectId);
await router.replace({
name: "CodeWorkbenchSession",
- params: { conversationId },
- query: projectId ? { projectId } : {}
+ params: { sessionId },
+ query: {}
});
}