Merge pull request #1309 from pikasTech/fix/issue-1294-session-url

fix(web): add workbench session deep links
This commit is contained in:
Lyon
2026-06-16 06:18:42 +08:00
committed by GitHub
10 changed files with 111 additions and 12 deletions
@@ -22,7 +22,7 @@ const CLIENT_DISCONNECT_ERROR_CODES = new Set([
]);
const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu;
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
export async function serveCloudWeb(options) {
@@ -68,7 +68,7 @@ export function createCloudWebServer({
}
const routePath = normalizeCloudWebRoutePath(url.pathname);
const relativePath = routePath === "/" || CLIENT_ROUTE_FALLBACK_PATHS.has(routePath) || isCloudWebClientRouteRequest(request, routePath)
const relativePath = routePath === "/" || CLIENT_ROUTE_FALLBACK_PATHS.has(routePath) || isCloudWebWorkbenchSessionRoute(routePath) || isCloudWebClientRouteRequest(request, routePath)
? "index.html"
: url.pathname.slice(1);
for (const root of roots) {
@@ -115,6 +115,10 @@ export function createCloudWebServer({
});
}
function isCloudWebWorkbenchSessionRoute(routePath) {
return /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/u.test(routePath);
}
function normalizeCloudWebRoutePath(pathname) {
return String(pathname || "/").replace(/\/+$/u, "") || "/";
}
+2 -1
View File
@@ -9,7 +9,8 @@
(function () {
var hash = window.location.hash;
var path = window.location.pathname.replace(/\/+$/g, "") || "/";
if (path !== "/" && path !== "/workspace") return;
var isWorkbenchPath = path === "/" || path === "/workbench" || path === "/workspace" || /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/.test(path);
if (!isWorkbenchPath) return;
if (hash && hash !== "#/workspace" && hash !== "#/") return;
var projectId = new URLSearchParams(window.location.search).get("projectId");
if (!projectId) {
+1
View File
@@ -6,6 +6,7 @@ export const workbenchAPI = {
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
conversations: (projectId: string): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }),
conversation: (conversationId: string): Promise<ApiResult<{ conversation?: ConversationRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { timeoutMs: 8000, timeoutName: "conversation" }),
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" })
};
@@ -23,7 +23,7 @@ const navSections = [
];
const shellless = computed(() => route.name === "Login" || route.name === "Register" || route.name === "NotFound");
const showWorkbenchDiagnostics = computed(() => route.name === "CodeWorkbench");
const showWorkbenchDiagnostics = computed(() => route.meta.section === "workbench");
watch(showWorkbenchDiagnostics, (visible) => {
if (!visible) diagnosticsOpen.value = false;
@@ -38,6 +38,10 @@ async function go(path: string): Promise<void> {
window.setTimeout(() => app.setLoading(false), 120);
}
}
function isNavItemActive(item: { name: string }): boolean {
return route.name === item.name || (item.name === "CodeWorkbench" && route.meta.section === "workbench");
}
</script>
<template>
@@ -56,7 +60,7 @@ async function go(path: string): Promise<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" :title="item.label" :aria-label="item.label" @click="go(item.path)">
<button v-for="item in section.items" :key="item.name" class="nav-item" :data-active="isNavItemActive(item)" 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>
@@ -2,6 +2,7 @@
import { computed, ref } from "vue";
import { useWorkbenchStore } from "@/stores/workbench";
import { useClipboard } from "@/composables/useClipboard";
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId, workbenchSessionPath } from "@/utils";
const workbench = useWorkbenchStore();
const { copied, copy } = useClipboard();
@@ -12,7 +13,14 @@ const activeTab = computed(() => workbench.sessionTabs.find((tab) => tab.active)
function copyActive(): void {
const tab = activeTab.value;
if (!tab) return;
void copy(`conversation=${tab.conversationId}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`);
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 && projectId !== DEFAULT_WORKBENCH_PROJECT_ID) url.searchParams.set("projectId", projectId);
return url.toString();
}
function startResize(event: PointerEvent): void {
+1 -1
View File
@@ -30,7 +30,7 @@ export function installRouterGuards(router: Router): void {
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!auth.checked) await auth.bootstrap();
if (to.meta.requiresAuth !== false && auth.authState === "login") return { name: "Login" };
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;
});
+4 -1
View File
@@ -1,12 +1,15 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { installRouterGuards } from "./guards";
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/workbench" },
{ 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", alias: ["/workspace"], name: "CodeWorkbench", component: () => import("@/views/workbench/CodeWorkbenchView.vue"), meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/workbench/sessions/:conversationId", alias: ["/workspace/sessions/:conversationId"], 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" } },
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, title: "充值与订阅", section: "user" } },
+27 -2
View File
@@ -3,7 +3,7 @@ import { defineStore } from "pinia";
import { api } from "@/api";
import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
@@ -119,6 +119,31 @@ export const useWorkbenchStore = defineStore("workbench", () => {
error.value = response.error ?? "session switch failed";
}
async function selectConversationById(conversationId: string): Promise<boolean> {
const normalized = normalizeWorkbenchConversationId(conversationId);
if (!normalized) {
error.value = "invalid session URL";
return false;
}
const current = await ensureWorkspace();
if (!current) return false;
const existing = visibleConversations.value.find((conversation) => conversation.conversationId === normalized) ?? null;
if (existing) {
if (activeConversationId.value === normalized && messages.value.length > 0) return true;
await selectConversation(existing);
return activeConversationId.value === normalized;
}
const response = await api.workbench.conversation(normalized);
const conversation = response.data?.conversation ?? null;
if (!response.ok || !conversation) {
error.value = response.error ?? "session URL not found";
return false;
}
conversations.value = [conversation, ...conversations.value.filter((item) => item.conversationId !== normalized)];
await selectConversation(conversation);
return activeConversationId.value === normalized;
}
async function deleteCurrentSession(): Promise<void> {
const conversationId = activeConversationId.value;
if (!conversationId) return;
@@ -440,7 +465,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return true;
}
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
});
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
+10
View File
@@ -25,6 +25,16 @@ export function normalizeWorkbenchProjectId(value: unknown): string | null {
return /^prj_[A-Za-z0-9_.:-]{3,96}$/u.test(text) ? text : null;
}
export function normalizeWorkbenchConversationId(value: unknown): string | null {
const text = nonEmptyString(value);
if (!text) return 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 resolveInitialWorkbenchProjectId(): string {
const fromUrl = typeof window !== "undefined" ? normalizeWorkbenchProjectId(new URLSearchParams(window.location.search).get("projectId")) : null;
if (fromUrl) return fromUrl;
@@ -1,15 +1,58 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import CommandComposer from "@/components/workbench/CommandComposer.vue";
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 { useWorkbenchStore } from "@/stores/workbench";
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchConversationId, normalizeWorkbenchProjectId } from "@/utils";
const workbench = useWorkbenchStore();
onMounted(() => void workbench.hydrate());
const route = useRoute();
const router = useRouter();
const routeConversationId = computed(() => normalizeWorkbenchConversationId(route.params.conversationId));
const applyingRouteConversation = ref(Boolean(routeConversationId.value));
onMounted(async () => {
applyingRouteConversation.value = Boolean(routeConversationId.value);
try {
await workbench.hydrate();
await applyRouteConversation();
} finally {
applyingRouteConversation.value = false;
}
if (!routeConversationId.value || routeConversationId.value === workbench.activeConversationId) await reflectActiveConversationInUrl(workbench.activeConversationId);
});
watch(routeConversationId, () => void applyRouteConversation());
watch(() => workbench.activeConversationId, (conversationId) => void reflectActiveConversationInUrl(conversationId));
useAutoRefresh(() => workbench.refreshLive(), 30_000);
async function applyRouteConversation(): Promise<boolean> {
const conversationId = routeConversationId.value;
if (!conversationId || workbench.activeConversationId === conversationId) return true;
applyingRouteConversation.value = true;
try {
return await workbench.selectConversationById(conversationId);
} finally {
applyingRouteConversation.value = false;
}
}
async function reflectActiveConversationInUrl(value: string | null): Promise<void> {
const conversationId = normalizeWorkbenchConversationId(value);
if (!conversationId) return;
const routeTarget = routeConversationId.value;
if (applyingRouteConversation.value && routeTarget && routeTarget !== conversationId) return;
if (routeTarget === conversationId) 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 !== DEFAULT_WORKBENCH_PROJECT_ID ? { projectId } : {}
});
}
</script>
<template>