From da25cb4c0209632bf5d2d2fd3d3e7f3880fa2b7d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 3 Jun 2026 09:35:46 +0800 Subject: [PATCH] feat: align session sidebar and cli management --- internal/cloud/access-control.test.ts | 45 +++ internal/cloud/access-control.ts | 115 +++++++- internal/cloud/server.ts | 5 + internal/dev-entrypoint/cloud-web-routes.mjs | 11 +- .../dev-entrypoint/cloud-web-routes.test.mjs | 9 + tools/hwlab-cli/client.test.ts | 57 ++++ tools/src/hwlab-cli-lib.ts | 168 +++++++++++ web/hwlab-cloud-web/app-conversation.ts | 89 +----- web/hwlab-cloud-web/app-device-pod.ts | 6 + web/hwlab-cloud-web/app-session-tabs.ts | 96 ++++++- web/hwlab-cloud-web/app.ts | 268 +++++++++++++++++- web/hwlab-cloud-web/index.html | 15 + web/hwlab-cloud-web/scripts/check.ts | 13 +- web/hwlab-cloud-web/scripts/dist-contract.ts | 1 + web/hwlab-cloud-web/session-tabs.test.ts | 35 ++- web/hwlab-cloud-web/styles.css | 190 ++++++++++++- 16 files changed, 1013 insertions(+), 110 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 2e1d818e..e0e44820 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1263,6 +1263,34 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac assert.equal(bobDirect.status, 404); assert.equal(bobDirect.body.error.code, "agent_conversation_not_found"); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { + projectId: "prj_device_pod_workbench", + conversationId: "cnv_account_sync", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(selected.status, 200); + assert.equal(selected.body.workspace.selectedConversationId, "cnv_account_sync"); + + const deleted = await deleteJson(port, "/v1/agent/conversations/cnv_account_sync", { + projectId: "prj_device_pod_workbench", + workspaceId: workspace.body.workspace.workspaceId, + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(deleted.status, 200); + assert.equal(deleted.body.status, "deleted"); + assert.equal(deleted.body.archivedCount, 1); + assert.equal(deleted.body.workspace.selectedConversationId, null); + + const afterDeleteList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(afterDeleteList.status, 200); + assert.equal(afterDeleteList.body.count, 0); + + const archivedDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", aliceLogin.cookie); + assert.equal(archivedDirect.status, 200); + assert.equal(archivedDirect.body.conversation.status, "archived"); + const logout = await postJson(port, "/auth/logout", {}, aliceLogin.cookie); assert.equal(logout.status, 200); const relogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); @@ -2114,6 +2142,23 @@ async function getJson(port, path, cookie = null, extraHeaders = {}) { }; } +async function deleteJson(port, path, body = {}, cookie = null, extraHeaders = {}) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: "DELETE", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + ...extraHeaders + }, + body: JSON.stringify(body) + }); + return { + status: response.status, + body: await response.json(), + cookie: response.headers.get("set-cookie") + }; +} + async function requestJson(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index ee0be4e6..3f1dbd52 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -612,6 +612,7 @@ class AccessController { ownerScoped: true, projectId, limit, + includeArchived: false, now: this.now() }) ?? []; const conversations = conversationsFromAgentSessions(sessions); @@ -640,6 +641,7 @@ class AccessController { ownerScoped: true, conversationId, limit: 20, + includeArchived: true, now: this.now() }) ?? []; const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; @@ -671,6 +673,62 @@ class AccessController { return sendJson(response, 200, { ok: true, status: "stored", contractVersion: "agent-conversations-v1", conversation }); } + async deleteAgentConversation(request, response, conversationId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!safeConversationIdLocal(conversationId)) { + return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); + } + const body = await jsonBody(request); + const projectId = textOr(body.projectId, "prj_device_pod_workbench"); + const visible = await this.visibleConversationForActor(auth.actor, conversationId, projectId, { includeArchived: true }); + if (!visible) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); + const archived = await this.store.archiveAgentConversation?.({ + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + ownerScoped: true, + conversationId, + projectId, + now: this.now() + }); + let workspace = null; + const workspaceId = textOr(body.workspaceId, ""); + if (safeWorkspaceId(workspaceId)) { + const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role }); + if (current?.selectedConversationId === conversationId) { + const next = await this.nextVisibleConversationForActor(auth.actor, projectId, conversationId); + workspace = await this.store.updateWorkspace?.({ + workspaceId, + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + selectedConversationId: next?.conversationId ?? null, + selectedAgentSessionId: next?.sessionId ?? null, + activeTraceId: null, + patch: { + selectedConversationId: next?.conversationId ?? null, + selectedAgentSessionId: next?.sessionId ?? null, + activeTraceId: null, + messages: next?.messages ?? [], + deletedConversationId: conversationId, + source: "workbench-delete-conversation" + }, + updatedBySessionId: currentSessionIdFromAuth(auth), + updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"), + now: this.now() + }); + } + } + return sendJson(response, 200, { + ok: true, + status: "deleted", + contractVersion: "agent-conversations-v1", + conversationId, + archivedCount: archived?.count ?? 0, + workspace: workspace ? await this.publicWorkbenchWorkspace(workspace, auth.actor) : null + }); + } + async getWorkbenchWorkspace(request, response, url) { await this.ensureBootstrap(); const auth = await this.authenticate(request, { required: true }); @@ -971,7 +1029,7 @@ class AccessController { }); } - async visibleConversationForActor(actor, conversationId, projectId = "") { + async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) { if (!safeConversationIdLocal(conversationId)) return null; const sessions = await this.store.listAgentSessionsForUser?.({ ownerUserId: actor.id, @@ -980,11 +1038,25 @@ class AccessController { conversationId, projectId: textOr(projectId, ""), limit: 20, + includeArchived: options.includeArchived === true, now: this.now() }) ?? []; return conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; } + async nextVisibleConversationForActor(actor, projectId = "", excludedConversationId = "") { + const sessions = await this.store.listAgentSessionsForUser?.({ + ownerUserId: actor.id, + actorRole: actor.role, + ownerScoped: true, + projectId: textOr(projectId, ""), + limit: 20, + includeArchived: false, + now: this.now() + }) ?? []; + return conversationsFromAgentSessions(sessions).find((item) => item.conversationId !== excludedConversationId) ?? null; + } + async requireGrantTargets({ devicePodId, userId }) { const pod = await this.store.getDevicePod(devicePodId); if (!pod || pod.status !== "active") { @@ -1416,9 +1488,26 @@ class MemoryAccessStore { .filter((session) => input.ownerScoped === true || role !== "admin" ? session.ownerUserId === ownerUserId : true) .filter((session) => !projectId || session.projectId === projectId) .filter((session) => !conversationId || session.conversationId === conversationId) + .filter((session) => input.includeArchived === true || session.status !== "archived") .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))) .slice(0, limit); } + async archiveAgentConversation(input = {}) { + const ownerUserId = textOr(input.ownerUserId, ""); + const role = textOr(input.actorRole, "user"); + const conversationId = textOr(input.conversationId, ""); + const projectId = textOr(input.projectId, ""); + const now = input.now ?? this.now(); + let count = 0; + for (const [id, session] of this.agentSessions.entries()) { + if ((input.ownerScoped === true || role !== "admin") && session.ownerUserId !== ownerUserId) continue; + if (conversationId && session.conversationId !== conversationId) continue; + if (projectId && session.projectId !== projectId) continue; + this.agentSessions.set(id, { ...session, status: "archived", endedAt: session.endedAt ?? now, updatedAt: now }); + count += 1; + } + return { count }; + } async getOrCreateDefaultWorkspace(input = {}) { const ownerUserId = textOr(input.ownerUserId, ""); const projectId = textOr(input.projectId, "prj_device_pod_workbench"); @@ -1529,11 +1618,34 @@ class PostgresAccessStore extends MemoryAccessStore { params.push(conversationId); clauses.push(`conversation_id = $${params.length}`); } + if (input.includeArchived !== true) clauses.push("status <> 'archived'"); params.push(limit); const sql = `SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC NULLS LAST LIMIT $${params.length}`; const result = await this.query(sql, params); return result.rows.map(pgAgentSession); } + async archiveAgentConversation(input = {}) { + await this.ensureSchema(); + const role = textOr(input.actorRole, "user"); + const ownerUserId = textOr(input.ownerUserId, ""); + const conversationId = textOr(input.conversationId, ""); + const projectId = textOr(input.projectId, ""); + const now = input.now ?? this.now(); + const clauses = ["conversation_id = $1"]; + const params = [conversationId]; + if (input.ownerScoped === true || role !== "admin") { + params.push(ownerUserId); + clauses.push(`owner_user_id = $${params.length}`); + } + if (projectId) { + params.push(projectId); + clauses.push(`project_id = $${params.length}`); + } + params.push(now); + const sql = `UPDATE agent_sessions SET status = 'archived', ended_at = COALESCE(ended_at, $${params.length}), updated_at = $${params.length} WHERE ${clauses.join(" AND ")} RETURNING id`; + const result = await this.query(sql, params); + return { count: result.rows?.length ?? 0 }; + } async getOrCreateDefaultWorkspace(input = {}) { await this.ensureSchema(); const ownerUserId = textOr(input.ownerUserId, ""); @@ -1849,6 +1961,7 @@ function threadResumeFailureThreadId(result = {}) { ), 240) || null; } function terminalWorkbenchSessionStatus(result = {}) { + if (isThreadResumeFailedResult(result)) return "failed"; const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase(); if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus; const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase(); diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index ab282683..7d9c1eed 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -334,6 +334,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (agentConversationMatch && request.method === "DELETE") { + await options.accessController.deleteAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1])); + return; + } + if (url.pathname === "/v1/workbench/workspace" && request.method === "GET") { await options.accessController.getWorkbenchWorkspace(request, response, url); return; diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index beec533c..8dc75f67 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -49,7 +49,10 @@ export function isCloudApiProxyRoute(method, pathname) { isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath); } if (normalizedMethod === "PATCH" || normalizedMethod === "PUT") { - return isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath); + return isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath) || isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); + } + if (normalizedMethod === "DELETE") { + return isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); } return false; } @@ -70,6 +73,12 @@ function isWorkbenchWorkspaceWriteProxyRoute(method, pathname) { return false; } +function isAgentConversationWriteProxyRoute(method, pathname) { + const parts = pathname.split("/").filter(Boolean); + if (parts.length !== 4 || parts[0] !== "v1" || parts[1] !== "agent" || parts[2] !== "conversations") return false; + return ["PUT", "PATCH", "DELETE"].includes(method); +} + function isPublicCodeAgentPollRoute(method, pathname) { return method === "GET" && ( pathname.startsWith("/v1/agent/chat/result/") || diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 5813346e..d61b4864 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -92,3 +92,12 @@ test("cloud web proxies Code Agent steer through the Web-equivalent public API p routeKey: "POST /v1/agent/chat/steer" }); }); + +test("cloud web proxies authenticated session deletion through account conversation API", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/agent/conversations/cnv_test"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "DELETE /v1/agent/conversations/cnv_test" + }); +}); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 4ad522f8..a45e5b79 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -171,9 +171,66 @@ test("hwlab-cli client group help is visible and does not issue HTTP requests", assert.equal(agent.payload.action, "client.agent.help"); assert.equal(agent.payload.serviceRuntime, false); assert.ok(agent.payload.commands.some((command: string) => command.startsWith("send "))); + + const session = await runHwlabCli(["client", "session", "--help", "--base-url", "http://web.test"], { fetchImpl }); + assert.equal(session.exitCode, 0); + assert.equal(session.payload.action, "client.session.help"); + assert.equal(session.payload.webEquivalent, true); + assert.ok(session.payload.commands.some((command: string) => command.startsWith("create "))); assert.equal(fetchCount, 0); }); +test("hwlab-cli client session management uses Cloud Web workspace and conversation paths", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-session-management-")); + const calls: any[] = []; + const fetchImpl = async (url: string | URL, init: any = {}) => { + const body = init?.body ? JSON.parse(String(init.body)) : null; + calls.push({ url: String(url), method: init?.method ?? "GET", body }); + if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) { + return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_cli_session", revision: 4, selectedConversationId: "cnv_existing", selectedAgentSessionId: "ses_existing", workspace: {} } }), { status: 200 }); + } + if (String(url).endsWith("/v1/workbench/workspace/wsp_cli_session/select-conversation")) { + const conversationId = body.conversationId || "cnv_created"; + const sessionId = body.sessionId || "ses_created"; + return new Response(JSON.stringify({ + ok: true, + workspace: { + workspaceId: "wsp_cli_session", + revision: 5, + selectedConversationId: conversationId, + selectedAgentSessionId: sessionId, + selectedConversation: { conversationId, sessionId, threadId: body.threadId || "thread-cli", status: "active", messages: [] }, + workspace: {} + } + }), { status: 200 }); + } + if (String(url).endsWith("/v1/agent/conversations/cnv_existing")) { + return new Response(JSON.stringify({ ok: true, status: "deleted", archivedCount: 1, conversationId: "cnv_existing", workspace: { workspaceId: "wsp_cli_session", revision: 6, selectedConversationId: null, selectedAgentSessionId: null, workspace: {} } }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); + }; + + const created = await runHwlabCli(["client", "session", "create", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--conversation-id", "cnv_created", "--session-id", "ses_created", "--thread-id", "thread-created"], { cwd, fetchImpl }); + const switched = await runHwlabCli(["client", "session", "switch", "cnv_existing", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a", "--session-id", "ses_existing", "--thread-id", "thread-existing"], { cwd, fetchImpl }); + const deleted = await runHwlabCli(["client", "session", "delete", "cnv_existing", "--confirm", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { cwd, fetchImpl }); + + assert.equal(created.exitCode, 0); + assert.equal(switched.exitCode, 0); + assert.equal(deleted.exitCode, 0); + assert.equal(calls[1].url, "http://web.test/v1/workbench/workspace/wsp_cli_session/select-conversation"); + assert.equal(calls[1].method, "POST"); + assert.equal(calls[1].body.create, true); + assert.equal(calls[1].body.updatedByClient, "hwlab-cli"); + assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_cli_session/select-conversation"); + assert.equal(calls[3].body.conversationId, "cnv_existing"); + assert.equal(calls[5].url, "http://web.test/v1/agent/conversations/cnv_existing"); + assert.equal(calls[5].method, "DELETE"); + assert.equal(calls[5].body.workspaceId, "wsp_cli_session"); + assert.equal(created.payload.route.path, "/v1/workbench/workspace/wsp_cli_session/select-conversation"); + assert.equal(switched.payload.current.conversationId, "cnv_existing"); + assert.equal(deleted.payload.body.status, "deleted"); +}); + test("hwlab-cli rejects manual endpoint override in locked assembled runtime", async () => { const result = await runHwlabCli(["client", "auth", "status", "--base-url", "http://74.48.78.17:17666"], { env: { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 397e0956..65889151 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -72,6 +72,7 @@ async function clientCommand(context: any) { if (group === "runtime") return runtimeCommand(next); if (group === "gateway") return gatewayCommand(next); if (group === "agent") return agentCommand(next); + if (group === "session" || group === "sessions") return sessionCommand(next); if (["harness", "harness-ops", "harness-opt"].includes(group)) return harnessCommand(next); if (group === "workbench") return workbenchCommand(next); if (group === "rpc") return rpcCommand(next); @@ -85,6 +86,7 @@ function clientSubcommandHelp(group: string) { if (group === "runtime") return runtimeHelp(); if (group === "gateway") return gatewayHelp(); if (group === "agent") return agentHelp(); + if (group === "session" || group === "sessions") return sessionHelp(); if (["harness", "harness-ops", "harness-opt"].includes(group)) return harnessHelp(); if (group === "workbench") return workbenchHelp(); if (group === "rpc") return rpcHelp(); @@ -117,6 +119,10 @@ function help() { "hwlab-cli client runtime routes", "hwlab-cli client gateway sessions", "hwlab-cli client gateway pressure --gateway-session-id gws_D601_F103", + "hwlab-cli client session list", + "hwlab-cli client session create", + "hwlab-cli client session switch cnv_...", + "hwlab-cli client session delete cnv_... --confirm", "hwlab-cli client workbench summary --pod-id device-pod-71-freq", "hwlab-cli client workbench restore --profile NAME", "hwlab-cli client workbench status --profile NAME", @@ -633,6 +639,128 @@ function agentHelp() { }); } +async function sessionCommand(context: any) { + const subcommand = context.rest[0] || "list"; + if (wantsHelp(context)) return sessionHelp(); + if (subcommand === "list" || subcommand === "ls") return sessionListCommand(context); + if (subcommand === "current" || subcommand === "status") return sessionCurrentCommand(context); + if (subcommand === "create" || subcommand === "new") return sessionCreateCommand(context); + if (subcommand === "switch" || subcommand === "select") return sessionSwitchCommand(context); + if (subcommand === "delete" || subcommand === "rm") return sessionDeleteCommand(context); + throw cliError("unsupported_session_command", `unsupported session command: ${subcommand}`, { subcommand }); +} + +function sessionHelp() { + return ok("client.session.help", { + serviceRuntime: false, + imagePublished: false, + webEquivalent: true, + commands: [ + "list [--limit N]", + "current", + "create [--conversation-id ID] [--session-id ID] [--thread-id ID]", + "switch CONVERSATION_ID|--conversation-id ID", + "delete CONVERSATION_ID|--conversation-id ID --confirm" + ] + }); +} + +async function sessionListCommand(context: any) { + const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; + const limit = numberOption(context.parsed.limit) ?? 100; + const pathName = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.session.list", response, context, { + route: route("GET", pathName), + sessions: sessionSummaries(response.body?.conversations), + body: responseBodyForCli(response.body, context.parsed) + }); +} + +async function sessionCurrentCommand(context: any) { + const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); + return ok("client.session.current", { + baseUrl: baseUrl(context.parsed, context.env), + runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")), + route: route("GET", `/v1/workbench/workspace?projectId=${encodeURIComponent(text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID)}`), + workspace, + current: sessionSummary(workspace?.selectedConversation) ?? sessionSummary({ + conversationId: workspace?.selectedConversationId, + sessionId: workspace?.selectedAgentSessionId, + threadId: workspace?.threadId, + status: workspace?.sessionStatus, + lastTraceId: workspace?.activeTraceId, + updatedAt: workspace?.updatedAt + }) + }); +} + +async function sessionCreateCommand(context: any) { + const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); + const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId); + if (!workspaceId) throw cliError("workspace_restore_required", "session create requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); + const conversationId = safeOptionalConversationId(context.parsed.conversationId); + const sessionId = safeOptionalSessionId(context.parsed.sessionId); + const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`; + const response = await requestJson({ + ...context, + method: "POST", + path: pathName, + body: clean({ + projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, + create: true, + conversationId, + sessionId, + threadId: text(context.parsed.threadId), + updatedByClient: "hwlab-cli" + }) + }); + if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); + return responsePayload("client.session.create", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) }); +} + +async function sessionSwitchCommand(context: any) { + const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId); + const workspace = await restoreWorkbenchWorkspace(context, { quiet: false }); + const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId); + if (!workspaceId) throw cliError("workspace_restore_required", "session switch requires a restored workspace or --workspace-id", { nextCommand: "client workbench restore" }); + const pathName = `/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`; + const response = await requestJson({ + ...context, + method: "POST", + path: pathName, + body: clean({ + projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, + conversationId, + sessionId: safeOptionalSessionId(context.parsed.sessionId), + threadId: text(context.parsed.threadId), + updatedByClient: "hwlab-cli" + }) + }); + if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); + return responsePayload("client.session.switch", response, context, { route: route("POST", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), current: sessionSummary(response.body?.workspace?.selectedConversation), body: responseBodyForCli(response.body, context.parsed) }); +} + +async function sessionDeleteCommand(context: any) { + const conversationId = requiredConversationId(context.rest[1] ?? context.parsed.conversationId); + if (context.parsed.confirm !== true) throw cliError("session_delete_confirmation_required", "session delete requires --confirm", { flag: "--confirm", conversationId }); + const workspace = await restoreWorkbenchWorkspace(context, { quiet: true }); + const workspaceId = text(context.parsed.workspaceId) || text(workspace?.workspaceId); + const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`; + const response = await requestJson({ + ...context, + method: "DELETE", + path: pathName, + body: clean({ + projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, + workspaceId, + updatedByClient: "hwlab-cli" + }) + }); + if (responseSucceeded(response) && response.body?.workspace) await saveWorkspaceState(context, response.body.workspace); + return responsePayload("client.session.delete", response, context, { route: route("DELETE", pathName), workspace: normalizeWorkbenchWorkspace(response.body?.workspace), body: responseBodyForCli(response.body, context.parsed) }); +} + async function agentComposer(context: any) { const subcommand = context.rest[1] || "status"; if (subcommand === "status") return agentComposerStatus(context); @@ -1875,6 +2003,24 @@ function normalizeWorkbenchWorkspace(workspace: any) { }); } +function sessionSummaries(conversations: any) { + return Array.isArray(conversations) ? conversations.map(sessionSummary).filter(Boolean) : []; +} + +function sessionSummary(conversation: any) { + if (!conversation || typeof conversation !== "object") return null; + return clean({ + conversationId: text(conversation.conversationId), + sessionId: text(conversation.sessionId ?? conversation.session?.sessionId), + threadId: text(conversation.threadId ?? conversation.session?.threadId), + status: text(conversation.status ?? conversation.snapshot?.sessionStatus), + lastTraceId: text(conversation.lastTraceId), + updatedAt: text(conversation.updatedAt), + messageCount: Array.isArray(conversation.messages) ? conversation.messages.length : undefined, + valuesRedacted: conversation.valuesRedacted === true ? true : undefined + }); +} + function workbenchThreadId(workspace: any, workspaceJson: any) { const selectedConversation = workspace?.selectedConversation && typeof workspace.selectedConversation === "object" ? workspace.selectedConversation : {}; const selectedAgentSession = workspace?.selectedAgentSession && typeof workspace.selectedAgentSession === "object" ? workspace.selectedAgentSession : {}; @@ -2257,6 +2403,28 @@ function requiredTraceId(value: unknown) { return result; } +function requiredConversationId(value: unknown) { + const result = requiredText(value, "conversationId"); + if (!/^cnv_[A-Za-z0-9_.:-]+$/u.test(result)) { + throw cliError("invalid_conversation_id", "conversationId must start with cnv_", { conversationId: result }); + } + return result; +} + +function safeOptionalConversationId(value: unknown) { + const result = text(value); + if (!result) return ""; + if (!/^cnv_[A-Za-z0-9_.:-]+$/u.test(result)) throw cliError("invalid_conversation_id", "conversationId must start with cnv_", { conversationId: result }); + return result; +} + +function safeOptionalSessionId(value: unknown) { + const result = text(value); + if (!result) return ""; + if (!/^ses_[A-Za-z0-9_.:-]+$/u.test(result)) throw cliError("invalid_session_id", "sessionId must start with ses_", { sessionId: result }); + return result; +} + function preview(value: unknown, max = 500) { if (value === undefined || value === null) return null; const result = String(value).replace(/\s+/gu, " ").trim(); diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 4fb76695..c7fc2bc3 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -3,8 +3,6 @@ function renderConversation() { captureConversationScrollPosition(); captureTraceScrollPositions(); captureTraceBodyScrollPositions(); - const sessionView = currentSessionView(); - renderSessionTabs(sessionView); const introMessages = [ { role: "system", @@ -14,8 +12,7 @@ function renderConversation() { }, codeAgentStatusMessage(state.codeAgentAvailability) ]; - const messages = sessionView.tabs.length > 0 ? sessionView.messages : state.chatMessages; - replaceChildren(el.conversationList, ...[...introMessages, ...messages].map(messageCard)); + replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); restoreConversationScrollPosition(); restoreTraceScrollPositions(); restoreTraceBodyScrollPositions(); @@ -28,90 +25,6 @@ function renderConversation() { }); } -function currentSessionView() { - return sessionTabsFromMessages(state.chatMessages, { - activeSessionKey: activeSessionKeyFromRuntime({ - activeSessionKey: state.activeSessionKey, - currentRequest: state.currentRequest, - sessionId: state.sessionId, - conversationId: state.conversationId, - activeTraceId: state.activeTraceId - }) - }); -} - -function renderSessionTabs(sessionView) { - if (!el.sessionTabs) return; - if (!sessionView.tabs.length) { - state.activeSessionKey = null; - replaceChildren(el.sessionTabs); - el.sessionTabs.hidden = true; - return; - } - state.activeSessionKey = sessionView.activeKey; - el.sessionTabs.hidden = false; - replaceChildren(el.sessionTabs, ...sessionView.tabs.map(sessionTabButton)); -} - -function sessionTabButton(tab) { - const button = document.createElement("button"); - button.type = "button"; - button.className = `session-tab tone-border-${toneClass(sessionTabTone(tab))}`; - button.dataset.sessionKey = tab.key; - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", tab.active ? "true" : "false"); - button.setAttribute("aria-label", sessionTabAriaLabel(tab)); - button.title = sessionTabTitle(tab); - button.append( - textSpan(tab.label, "session-tab-label"), - textSpan(tab.subtitle, "session-tab-subtitle"), - textSpan(`${tab.messageCount} 条`, "session-tab-count") - ); - button.addEventListener("click", () => { - selectSessionTab(tab); - renderAgentChatStatus(deriveAgentChatStatus(), latestChatResultForActiveSession()); - renderCodeAgentSummary(); - renderConversation(); - }); - return button; -} - -function selectSessionTab(tab) { - state.activeSessionKey = tab.key; - state.conversationId = tab.conversationId ?? state.conversationId; - state.sessionId = tab.sessionId ?? (String(tab.key).startsWith("ses_") || String(tab.key).startsWith("ses-") ? tab.key : state.sessionId); - state.threadId = tab.threadId ?? state.threadId; - state.activeTraceId = tab.running ? tab.lastTraceId ?? state.activeTraceId : null; - state.sessionStatus = tab.status ?? state.sessionStatus; - state.conversationScrollPosition = { top: 0, left: 0, bottomGap: 0 }; -} - -function latestChatResultForActiveSession() { - const sessionView = currentSessionView(); - return [...sessionView.messages].reverse().find((message) => message.role === "agent") ?? latestChatResult(); -} - -function sessionTabTone(tab) { - if (tab.status === "running" || tab.running) return "pending"; - if (["failed", "timeout", "error", "canceled"].includes(tab.status)) return "blocked"; - if (tab.status === "completed") return "dev-live"; - return "source"; -} - -function sessionTabTitle(tab) { - return [ - `session=${tab.sessionId ?? "unknown"}`, - `conversation=${tab.conversationId ?? "unknown"}`, - `thread=${tab.threadId ?? "unknown"}`, - `trace=${tab.lastTraceId ?? "unknown"}`, - `messages=${tab.messageCount}` - ].join(" / "); -} - -function sessionTabAriaLabel(tab) { - return `${tab.label},${tab.messageCount} 条消息,${tab.subtitle}`; -} - function initConversationScrollMemory() { for (const eventName of ["wheel", "touchstart", "pointerdown"]) { el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true }); diff --git a/web/hwlab-cloud-web/app-device-pod.ts b/web/hwlab-cloud-web/app-device-pod.ts index 1b1f18e1..117e6684 100644 --- a/web/hwlab-cloud-web/app-device-pod.ts +++ b/web/hwlab-cloud-web/app-device-pod.ts @@ -169,6 +169,7 @@ async function submitAgentMessage(value, options = {}) { renderAgentChatStatus("running"); renderCodeAgentSummary(); renderConversation(); + renderSessionSidebar(); renderDrafts(); renderDevicePodPanel(state.liveSurface); @@ -197,6 +198,7 @@ async function submitAgentMessage(value, options = {}) { renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]); renderCodeAgentSummary(); persistCodeAgentSessionState(); + refreshSessionSidebar(); } catch (error) { stopTraceStream(); if (state.canceledTraces.has(traceId)) return; @@ -239,6 +241,7 @@ async function submitAgentMessage(value, options = {}) { renderAgentChatStatus(failedStatus, state.chatMessages[index]); renderCodeAgentSummary(); persistCodeAgentSessionState(); + refreshSessionSidebar(); } finally { if (state.currentRequest?.traceId === traceId) { state.currentRequest = null; @@ -250,6 +253,7 @@ async function submitAgentMessage(value, options = {}) { stopTraceStream(); renderCodeAgentSummary(); renderConversation(); + renderSessionSidebar(); renderDrafts(); renderDevicePodPanel(state.liveSurface); } @@ -295,6 +299,7 @@ async function submitAgentSteer(value, options = {}) { renderAgentChatStatus(deriveAgentChatStatus(), latestChatResult()); renderCodeAgentSummary(); renderConversation(); + refreshSessionSidebar(); renderDrafts(); renderDevicePodPanel(state.liveSurface); } @@ -1063,6 +1068,7 @@ async function reconcileCodeAgentResult(messageId, options = {}) { renderAgentChatStatus(updated.status, updated); renderCodeAgentSummary(); renderConversation(); + refreshSessionSidebar(); renderDrafts(); renderDevicePodPanel(state.liveSurface); return true; diff --git a/web/hwlab-cloud-web/app-session-tabs.ts b/web/hwlab-cloud-web/app-session-tabs.ts index 27ce8037..08d24ade 100644 --- a/web/hwlab-cloud-web/app-session-tabs.ts +++ b/web/hwlab-cloud-web/app-session-tabs.ts @@ -1,10 +1,12 @@ +const EMPTY_CONVERSATION_SESSION_LIST = Object.freeze({ tabs: [], activeKey: null, activeConversation: null }); + export function sessionTabsFromMessages(messages = [], options = {}) { const list = Array.isArray(messages) ? messages : []; const aliases = sessionAliasesFromMessages(list); const sessions = new Map(); for (const message of list) { const key = sessionKeyFromMessage(message, aliases); - const existing = sessions.get(key) ?? createSessionTab(key, message); + const existing = sessions.get(key) ?? createMessageSessionTab(key, message); mergeMessageIntoSessionTab(existing, message); sessions.set(key, existing); } @@ -13,8 +15,8 @@ export function sessionTabsFromMessages(messages = [], options = {}) { .map((tab, index) => ({ ...tab, ordinal: index + 1, - label: tab.sessionId ? `Session ${shortSessionToken(tab.sessionId)}` : `会话 ${index + 1}`, - subtitle: tab.status === "running" ? "处理中" : tab.lastTraceId ? `trace ${shortSessionToken(tab.lastTraceId)}` : "无 trace" + label: tab.sessionId ? `Session ${shortToken(tab.sessionId)}` : `会话 ${index + 1}`, + subtitle: tab.status === "running" ? "处理中" : tab.lastTraceId ? `trace ${shortToken(tab.lastTraceId)}` : "无 trace" })); const preferredKey = firstNonEmptyString(options.activeSessionKey, options.sessionId, options.conversationId, options.traceId); const activeKey = resolveActiveSessionKey(tabs, preferredKey); @@ -25,6 +27,28 @@ export function sessionTabsFromMessages(messages = [], options = {}) { }; } +export function sessionTabsFromConversations(conversations = [], options = {}) { + const selectedConversationId = firstNonEmptyString(options.selectedConversationId, options.conversationId); + const selectedSessionId = firstNonEmptyString(options.selectedSessionId, options.sessionId); + const selectedTraceId = firstNonEmptyString(options.activeTraceId, options.traceId); + const tabs = (Array.isArray(conversations) ? conversations : []) + .map((conversation) => sessionTabFromConversation(conversation)) + .filter(Boolean) + .sort((left, right) => right.lastTimestampMs - left.lastTimestampMs) + .map((tab, index) => ({ + ...tab, + ordinal: index + 1, + label: tab.title || `Session ${index + 1}`, + subtitle: conversationSessionTabSubtitle(tab), + active: conversationSessionTabMatches(tab, selectedConversationId, selectedSessionId, selectedTraceId) + })); + if (tabs.length === 0) return { ...EMPTY_CONVERSATION_SESSION_LIST }; + let active = tabs.find((tab) => tab.active) ?? tabs[0]; + const normalized = tabs.map((tab) => ({ ...tab, active: tab.key === active.key })); + active = normalized.find((tab) => tab.key === active.key) ?? normalized[0]; + return { tabs: normalized, activeKey: active?.key ?? null, activeConversation: active ?? null }; +} + export function sessionKeyFromMessage(message = {}, aliases = {}) { const traceId = firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId); const conversationId = firstNonEmptyString(message?.conversationId, message?.runnerTrace?.conversationId); @@ -55,7 +79,32 @@ export function activeSessionKeyFromRuntime(input = {}) { ); } -function createSessionTab(key, message) { +export function sessionTabFromConversation(conversation = {}) { + const conversationId = firstNonEmptyString(conversation.conversationId); + if (!conversationId) return null; + const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId); + const messages = Array.isArray(conversation.messages) ? conversation.messages : []; + const lastMessage = newestMessage(messages); + const updatedAt = firstNonEmptyString(lastMessage?.updatedAt, conversation.updatedAt, conversation.snapshot?.updatedAt, lastMessage?.createdAt, conversation.startedAt); + const lastTimestampMs = timestampMs(updatedAt); + const status = normalizedStatus(firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, lastMessage?.status)); + const lastTraceId = firstNonEmptyString(conversation.lastTraceId, lastMessage?.traceId, lastMessage?.runnerTrace?.traceId); + return { + key: sessionId || conversationId, + conversationId, + sessionId, + threadId: firstNonEmptyString(conversation.threadId, conversation.session?.threadId, lastMessage?.threadId, lastMessage?.session?.threadId), + lastTraceId, + status, + title: conversationSessionTitle(conversation, sessionId, conversationId), + messageCount: messages.length, + lastTimestampMs, + updatedAt, + lastMessagePreview: firstNonEmptyString(lastMessage?.text, lastMessage?.title, conversation.snapshot?.source) + }; +} + +function createMessageSessionTab(key, message) { const timestampMs = timestampFromMessage(message); return { key, @@ -64,7 +113,7 @@ function createSessionTab(key, message) { threadId: firstNonEmptyString(message?.threadId, message?.session?.threadId, message?.sessionReuse?.threadId, message?.providerTrace?.threadId, message?.runnerTrace?.threadId), firstTraceId: firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId), lastTraceId: firstNonEmptyString(message?.traceId, message?.runnerTrace?.traceId), - status: normalizedStatus(sessionStatusFromMessage(message)), + status: normalizedStatus(messageSessionStatusFromMessage(message)), messageCount: 0, agentCount: 0, userCount: 0, @@ -99,7 +148,7 @@ function mergeMessageIntoSessionTab(tab, message) { tab.messageCount += 1; if (message?.role === "agent") tab.agentCount += 1; if (message?.role === "user") tab.userCount += 1; - const messageStatus = normalizedStatus(sessionStatusFromMessage(message)); + const messageStatus = normalizedStatus(messageSessionStatusFromMessage(message)); tab.running = tab.running || messageStatus === "running"; tab.status = tab.running ? "running" : messageStatus || tab.status; tab.firstTimestampMs = Math.min(tab.firstTimestampMs, timestampMs); @@ -116,11 +165,10 @@ function resolveActiveSessionKey(tabs, preferredKey) { } function timestampFromMessage(message) { - const timestamp = Date.parse(message?.updatedAt ?? message?.createdAt ?? message?.runnerTrace?.updatedAt ?? ""); - return Number.isFinite(timestamp) ? timestamp : 0; + return timestampMs(message?.updatedAt ?? message?.createdAt ?? message?.runnerTrace?.updatedAt); } -function sessionStatusFromMessage(message) { +function messageSessionStatusFromMessage(message) { return firstNonEmptyString( message?.sessionLifecycleStatus, message?.sessionSummary?.status, @@ -133,11 +181,39 @@ function sessionStatusFromMessage(message) { ); } +function conversationSessionTitle(conversation, sessionId, conversationId) { + const explicit = firstNonEmptyString(conversation.title, conversation.name, conversation.snapshot?.title); + if (explicit) return explicit; + const token = shortToken(sessionId || conversationId); + return `Session ${token}`; +} + +function conversationSessionTabSubtitle(tab) { + if (["running", "busy", "pending", "active"].includes(tab.status)) return "处理中"; + if (tab.lastTraceId) return `trace ${shortToken(tab.lastTraceId)}`; + if (tab.threadId) return `thread ${shortToken(tab.threadId)}`; + return "未开始"; +} + +function conversationSessionTabMatches(tab, conversationId, sessionId, traceId) { + const values = [tab.key, tab.conversationId, tab.sessionId, tab.lastTraceId].filter(Boolean); + return Boolean(values.includes(conversationId) || values.includes(sessionId) || values.includes(traceId)); +} + +function newestMessage(messages) { + return [...messages].sort((left, right) => timestampMs(right?.updatedAt ?? right?.createdAt) - timestampMs(left?.updatedAt ?? left?.createdAt))[0] ?? null; +} + +function timestampMs(value) { + const timestamp = Date.parse(String(value ?? "")); + return Number.isFinite(timestamp) ? timestamp : 0; +} + function normalizedStatus(value) { return String(value ?? "source").trim().toLowerCase() || "source"; } -function shortSessionToken(value) { +function shortToken(value) { const text = String(value ?? "").trim(); if (!text) return "unknown"; const parts = text.split(/[-_:]/u).filter(Boolean); diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index 46481996..ef34f94e 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -9,9 +9,9 @@ import { import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.ts"; import { computeCodeAgentComposerState } from "./composer-policy.ts"; import { classifyWorkbenchLiveStatus } from "./live-status.ts"; +import { activeSessionKeyFromRuntime, sessionTabsFromConversations, sessionTabsFromMessages } from "./app-session-tabs.ts"; import { marked } from "./third_party/marked/marked.esm.js"; import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.ts"; -import { activeSessionKeyFromRuntime, sessionTabsFromMessages } from "./app-session-tabs.ts"; const DEFAULT_API_TIMEOUT_MS = 4500; const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench"; @@ -133,6 +133,9 @@ const el = { codeAgentSummaryTrace: byId("code-agent-summary-trace"), codeAgentSummaryDetail: byId("code-agent-summary-detail"), sessionTabs: byId("session-tabs"), + sessionCreate: byId("session-create"), + sessionDelete: byId("session-delete"), + sessionStatus: byId("session-status"), conversationList: byId("conversation-list"), gateSourceStatus: byId("gate-source-status"), gateStatusFilter: byId("gate-status-filter"), @@ -210,6 +213,13 @@ const state = { canceledTraces: new Set(), currentRequest: null, sessionStatus: null, + sessionList: { + loading: false, + error: null, + items: [], + loadedAt: null, + pendingAction: null + }, chatPending: false, liveSurface: null, gateDiagnostics: { @@ -271,6 +281,7 @@ initLeftSidebarResize(); initRightSidebarResize(); initDevicePodPanel(); initSkillsPanel(); +initSessionSidebar(); initCodeAgentTimeoutControl(); initConversationScrollMemory(); initCommandBar(); @@ -519,6 +530,7 @@ async function hydrateAccountCodeAgentSessionState() { }); if (!response.ok) return; applyWorkbenchWorkspace(response.data?.workspace); + loadSessionSidebar({ quiet: true }); const conversation = response.data?.workspace?.selectedConversation ?? null; const payload = conversation ? accountConversationToStoredSessionPayload(conversation) @@ -561,6 +573,7 @@ function applyWorkbenchWorkspace(workspace) { state.activeTraceId = nonEmptyString(workspace.activeTraceId ?? workspace.workspace?.activeTraceId) ?? state.activeTraceId; state.conversationId = nonEmptyString(workspace.selectedConversationId) ?? nonEmptyString(workspace.workspace?.selectedConversationId) ?? state.conversationId; state.sessionId = nonEmptyString(workspace.selectedAgentSessionId) ?? nonEmptyString(workspace.workspace?.selectedAgentSessionId) ?? state.sessionId; + state.threadId = nonEmptyString(workspace.selectedConversation?.threadId ?? workspace.selectedConversation?.session?.threadId ?? workspace.workspace?.threadId) ?? state.threadId; state.sessionStatus = nonEmptyString(workspace.workspace?.sessionStatus) ?? state.sessionStatus; CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(workspace.providerProfile ?? workspace.workspace?.providerProfile ?? CODE_AGENT_PROVIDER_PROFILE); syncCodeAgentTimeoutControl(); @@ -568,6 +581,259 @@ function applyWorkbenchWorkspace(workspace) { return workspace; } +function initSessionSidebar() { + renderSessionSidebar(); + el.sessionCreate.addEventListener("click", () => createSessionSidebarSession()); + el.sessionDelete.addEventListener("click", () => deleteCurrentSessionSidebarSession()); +} + +async function loadSessionSidebar(options = {}) { + state.sessionList.loading = true; + state.sessionList.error = null; + renderSessionSidebar(); + const response = await fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}&limit=100`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutName: "Code Agent session list" + }); + state.sessionList.loading = false; + if (!response.ok) { + state.sessionList.error = response.error || "session list unavailable"; + if (options.quiet !== true) renderSessionSidebar(); + return false; + } + state.sessionList.items = Array.isArray(response.data?.conversations) ? response.data.conversations : []; + state.sessionList.loadedAt = new Date().toISOString(); + renderSessionSidebar(); + return true; +} + +function refreshSessionSidebar(options = {}) { + if (state.sessionList.loading) return; + void loadSessionSidebar({ quiet: options.quiet !== false }); +} + +function renderSessionSidebar() { + const view = sessionTabsFromConversations(sessionSidebarConversations(), { + selectedConversationId: state.conversationId, + selectedSessionId: state.sessionId, + activeTraceId: state.activeTraceId + }); + replaceChildren(el.sessionTabs, ...view.tabs.map(sessionTabButton)); + const pending = Boolean(state.sessionList.pendingAction); + el.sessionCreate.disabled = pending; + el.sessionDelete.disabled = pending || !view.activeConversation?.conversationId; + el.sessionStatus.textContent = sessionSidebarStatusText(view.tabs.length); + el.sessionStatus.title = state.sessionList.error || state.sessionList.loadedAt || "session list not loaded"; +} + +function sessionSidebarConversations() { + const items = Array.isArray(state.sessionList.items) ? [...state.sessionList.items] : []; + if (state.conversationId && !items.some((item) => item?.conversationId === state.conversationId)) { + items.unshift(currentStateConversationSnapshot()); + } + return items.filter(Boolean); +} + +function currentStateConversationSnapshot() { + return { + conversationId: state.conversationId, + sessionId: state.sessionId, + threadId: state.threadId, + status: state.sessionStatus || deriveAgentChatStatus(), + lastTraceId: activeTraceIdFromStoredMessages(state.chatMessages) || state.activeTraceId, + updatedAt: new Date().toISOString(), + messages: state.chatMessages + }; +} + +function sessionSidebarStatusText(count) { + if (state.sessionList.pendingAction === "create") return "创建中"; + if (state.sessionList.pendingAction === "switch") return "切换中"; + if (state.sessionList.pendingAction === "delete") return "删除中"; + if (state.sessionList.loading) return count > 0 ? `刷新中 / ${count} 个` : "加载中"; + if (state.sessionList.error) return "列表受阻"; + return count > 0 ? `${count} 个 session` : "无 session"; +} + +function sessionTabButton(tab) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `session-tab tone-border-${toneClass(sessionTabTone(tab))}`; + button.dataset.conversationId = tab.conversationId; + button.dataset.sessionId = tab.sessionId || ""; + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", tab.active ? "true" : "false"); + button.setAttribute("aria-label", `${tab.label},${tab.subtitle},${tab.messageCount} 条消息`); + button.title = sessionTabTitle(tab); + button.append( + textSpan(tab.label, "session-tab-label"), + textSpan(tab.subtitle, "session-tab-subtitle"), + textSpan(`${tab.messageCount} 条`, "session-tab-count") + ); + button.addEventListener("click", () => selectSessionSidebarSession(tab)); + return button; +} + +function sessionTabTone(tab) { + if (["running", "busy", "pending", "active"].includes(tab.status)) return "pending"; + if (["failed", "timeout", "error", "canceled", "archived"].includes(tab.status)) return "blocked"; + if (["completed", "idle"].includes(tab.status)) return "dev-live"; + return "source"; +} + +function sessionTabTitle(tab) { + return [ + `conversation=${tab.conversationId ?? "unknown"}`, + `session=${tab.sessionId ?? "unknown"}`, + `thread=${tab.threadId ?? "unknown"}`, + `trace=${tab.lastTraceId ?? "unknown"}` + ].join(" / "); +} + +async function ensureWorkbenchWorkspace() { + if (state.workspaceId) return true; + const response = await fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutName: "Workbench workspace restore" + }); + if (!response.ok) { + state.sessionList.error = response.error || "workspace restore failed"; + renderSessionSidebar(); + return false; + } + applyWorkbenchWorkspace(response.data?.workspace); + return Boolean(state.workspaceId); +} + +async function createSessionSidebarSession() { + if (!(await ensureWorkbenchWorkspace())) return; + state.sessionList.pendingAction = "create"; + renderSessionSidebar(); + try { + const response = await fetchJson(`/v1/workbench/workspace/${encodeURIComponent(state.workspaceId)}/select-conversation`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + timeoutMs: Math.min(API_TIMEOUT_MS, 8000), + timeoutName: "Code Agent session create", + body: JSON.stringify({ projectId: WORKBENCH_PROJECT_ID, create: true, updatedByClient: "cloud-web" }) + }); + if (!response.ok) throw new Error(response.error || "session create failed"); + applyWorkbenchWorkspace(response.data?.workspace); + applyConversationToActiveSession(response.data?.workspace?.selectedConversation, { clearWhenMissing: true }); + clearCodeAgentSessionState(); + await loadSessionSidebar({ quiet: true }); + renderAgentChatStatus("idle"); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + } catch (error) { + state.sessionList.error = error?.message || String(error); + renderSessionSidebar(); + } finally { + state.sessionList.pendingAction = null; + renderSessionSidebar(); + } +} + +async function selectSessionSidebarSession(tab) { + if (!tab?.conversationId || tab.conversationId === state.conversationId) return; + if (!(await ensureWorkbenchWorkspace())) return; + state.sessionList.pendingAction = "switch"; + renderSessionSidebar(); + try { + const response = await fetchJson(`/v1/workbench/workspace/${encodeURIComponent(state.workspaceId)}/select-conversation`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + timeoutMs: Math.min(API_TIMEOUT_MS, 8000), + timeoutName: "Code Agent session switch", + body: JSON.stringify({ + projectId: WORKBENCH_PROJECT_ID, + conversationId: tab.conversationId, + sessionId: tab.sessionId, + threadId: tab.threadId, + updatedByClient: "cloud-web" + }) + }); + if (!response.ok) throw new Error(response.error || "session switch failed"); + applyWorkbenchWorkspace(response.data?.workspace); + applyConversationToActiveSession(response.data?.workspace?.selectedConversation, { clearWhenMissing: true }); + state.conversationScrollPosition = { top: 0, left: 0, bottomGap: 0 }; + persistCodeAgentSessionState(); + renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult()); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + } catch (error) { + state.sessionList.error = error?.message || String(error); + renderSessionSidebar(); + } finally { + state.sessionList.pendingAction = null; + renderSessionSidebar(); + } +} + +async function deleteCurrentSessionSidebarSession() { + const conversationId = state.conversationId; + if (!conversationId) return; + if (!window.confirm("删除当前 session?历史 trace 仍会保留在后端审计记录中。")) return; + state.sessionList.pendingAction = "delete"; + renderSessionSidebar(); + try { + const response = await fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + timeoutMs: Math.min(API_TIMEOUT_MS, 8000), + timeoutName: "Code Agent session delete", + body: JSON.stringify({ projectId: WORKBENCH_PROJECT_ID, workspaceId: state.workspaceId, updatedByClient: "cloud-web" }) + }); + if (!response.ok) throw new Error(response.error || "session delete failed"); + if (response.data?.workspace) applyWorkbenchWorkspace(response.data.workspace); + applyConversationToActiveSession(response.data?.workspace?.selectedConversation, { clearWhenMissing: true }); + clearCodeAgentSessionState(); + await loadSessionSidebar({ quiet: true }); + renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult()); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + } catch (error) { + state.sessionList.error = error?.message || String(error); + renderSessionSidebar(); + } finally { + state.sessionList.pendingAction = null; + renderSessionSidebar(); + } +} + +function applyConversationToActiveSession(conversation, options = {}) { + const payload = accountConversationToStoredSessionPayload(conversation); + if (!payload) { + if (options.clearWhenMissing === true) clearActiveConversationState(); + return false; + } + state.conversationId = nonEmptyString(payload.conversationId); + state.sessionId = nonEmptyString(payload.sessionId); + state.threadId = nonEmptyString(payload.threadId); + state.sessionStatus = nonEmptyString(payload.sessionStatus); + state.chatMessages = Array.isArray(payload.chatMessages) + ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) + : []; + state.activeTraceId = activeTraceIdFromStoredMessages(state.chatMessages); + state.currentRequest = null; + state.chatPending = false; + return true; +} + +function clearActiveConversationState() { + state.conversationId = null; + state.sessionId = null; + state.threadId = null; + state.activeTraceId = null; + state.sessionStatus = null; + state.chatMessages = []; + state.currentRequest = null; + state.chatPending = false; +} + function workspaceToStoredSessionPayload(workspace) { if (!workspace || typeof workspace !== "object") return null; const workspaceJson = workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}; diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index b449ecdb..593a8ab0 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -62,6 +62,21 @@ title="拖拽调整左侧导航宽度;用左右方向键微调,Home/End 到最小或最大。" > + +
diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 147cab8d..ec28f184 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -67,6 +67,7 @@ console.log("hwlab-cloud-web check: right sidebar extracted"); assert.ok(rightSidebar, "Device Pod right sidebar must exist"); assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route"); assert.match(html, /id="session-tabs"[^>]*role="tablist"/u, "Cloud Web workspace must expose session tabs"); +assert.match(html, /class="session-sidebar"[^>]*id="session-sidebar"/u, "Cloud Web must place sessions in a second-level sidebar"); assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view"); assert.match(html, /id="skill-upload-input"[^>]*webkitdirectory/u, "Skills view must support directory upload"); assertIncludes(app, "/v1/skills", "skills frontend must call /v1/skills"); @@ -82,9 +83,19 @@ assertIncludes(styles, ".skill-prompt-assembly", "skills frontend must style pro assertIncludes(styles, ".skill-prompt-row", "skills frontend must style prompt assembly refs"); assertIncludes(app, "sessionTabsFromMessages", "workspace frontend must group messages by session"); assertIncludes(app, "activeSessionKey", "workspace frontend must track the active session tab"); -assertIncludes(app, "currentSessionView", "workspace frontend must filter conversation rendering by selected session"); +assertIncludes(app, "renderSessionSidebar", "workspace frontend must render account-scoped sessions in the second-level sidebar"); +assertIncludes(app, "selectSessionSidebarSession", "workspace frontend must switch sessions from the second-level sidebar"); assertIncludes(styles, ".session-tabs", "workspace frontend must style session tabs"); assertIncludes(styles, ".session-tab", "workspace frontend must style individual session tabs"); +assertIncludes(app, "sessionTabsFromConversations", "workspace frontend must build session tabs from account conversations"); +assertIncludes(app, "/v1/agent/conversations", "workspace frontend must load account-scoped sessions"); +assertIncludes(app, "select-conversation", "workspace frontend must switch sessions through workbench select-conversation"); +assertIncludes(styles, ".session-sidebar", "workspace frontend must style the second-level session sidebar"); +assertIncludes(styles, "grid-template-columns: var(--rail-width) var(--session-sidebar-width)", "workspace shell must place session sidebar to the right of activity rail"); +assert.match(cssRule(styles, ".activity-rail"), /overflow-x:\s*hidden;/u, "activity rail must not horizontally scroll"); +assert.match(cssRule(styles, ".session-tabs"), /overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll"); +assert.match(cssRule(styles, ".rail-button"), /white-space:\s*nowrap;/u, "activity rail labels must not auto-wrap"); +assert.match(cssRule(styles, ".session-tab-label"), /white-space:\s*nowrap;/u, "session labels must not auto-wrap"); console.log("hwlab-cloud-web check: skills contracts verified"); for (const term of [ diff --git a/web/hwlab-cloud-web/scripts/dist-contract.ts b/web/hwlab-cloud-web/scripts/dist-contract.ts index 15c4ea7d..44cafa66 100644 --- a/web/hwlab-cloud-web/scripts/dist-contract.ts +++ b/web/hwlab-cloud-web/scripts/dist-contract.ts @@ -10,6 +10,7 @@ export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand; export const cloudWebAppSourceFiles = Object.freeze([ "app.ts", "app-skills.ts", + "app-session-tabs.ts", "app-device-pod.ts", "app-conversation.ts", "app-trace.ts", diff --git a/web/hwlab-cloud-web/session-tabs.test.ts b/web/hwlab-cloud-web/session-tabs.test.ts index 18a88d0f..12f83397 100644 --- a/web/hwlab-cloud-web/session-tabs.test.ts +++ b/web/hwlab-cloud-web/session-tabs.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { activeSessionKeyFromRuntime, sessionTabsFromMessages } from "./app-session-tabs.ts"; +import { activeSessionKeyFromRuntime, sessionTabsFromConversations, sessionTabsFromMessages } from "./app-session-tabs.ts"; test("session tabs group messages and filter the active session", () => { const messages = [ @@ -55,6 +55,28 @@ test("runtime active session prefers current request session before global state assert.equal(key, "ses_current"); }); +test("session tabs use account conversations and keep the selected session active", () => { + const view = sessionTabsFromConversations([ + conversation("cnv_old", "ses_old", "trc_old", "idle", "2026-06-03T00:00:01Z"), + conversation("cnv_new", "ses_new", "trc_new", "running", "2026-06-03T00:00:03Z") + ], { selectedConversationId: "cnv_old" }); + + assert.equal(view.tabs.length, 2); + assert.equal(view.activeConversation?.conversationId, "cnv_old"); + assert.equal(view.tabs[0].conversationId, "cnv_new"); + assert.equal(view.tabs.find((tab) => tab.conversationId === "cnv_new")?.subtitle, "处理中"); +}); + +test("session tabs default to newest visible conversation", () => { + const view = sessionTabsFromConversations([ + conversation("cnv_a", "ses_a", "trc_a", "idle", "2026-06-03T00:00:01Z"), + conversation("cnv_b", "ses_b", "trc_b", "completed", "2026-06-03T00:00:05Z") + ], { selectedConversationId: "missing" }); + + assert.equal(view.activeConversation?.conversationId, "cnv_b"); + assert.equal(view.activeKey, "ses_b"); +}); + function message(role, sessionId, conversationId, traceId, status, createdAt) { return { id: `${role}_${traceId}`, @@ -68,3 +90,14 @@ function message(role, sessionId, conversationId, traceId, status, createdAt) { createdAt }; } + +function conversation(conversationId, sessionId, traceId, status, updatedAt) { + return { + conversationId, + sessionId, + status, + updatedAt, + lastTraceId: traceId, + messages: [{ role: "agent", text: traceId, status, traceId, updatedAt }] + }; +} diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 05119190..84220607 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -89,6 +89,7 @@ ul { .workbench-shell { --rail-width: clamp(var(--left-sidebar-min-width), var(--left-sidebar-width), var(--left-sidebar-max-width)); + --session-sidebar-width: clamp(174px, 17vw, 236px); --right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width)); --right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width)); position: relative; @@ -97,7 +98,7 @@ ul { max-height: 100dvh; min-height: 0; display: grid; - grid-template-columns: var(--rail-width) minmax(420px, 1fr) var(--right-width-expanded); + grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(420px, 1fr) var(--right-width-expanded); grid-template-rows: 1fr; overflow: hidden; overscroll-behavior: contain; @@ -239,6 +240,7 @@ body > [data-app-shell][hidden] { } .activity-rail, +.session-sidebar, .right-sidebar, .center-workspace { min-width: 0; @@ -252,7 +254,157 @@ body > [data-app-shell][hidden] { padding: 8px; background: var(--rail); border-right: 1px solid var(--line); - overflow: auto; + overflow-x: hidden; + overflow-y: auto; +} + +.session-sidebar { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 8px; + padding: 8px; + background: rgba(19, 22, 20, 0.98); + border-right: 1px solid var(--line); + overflow: hidden; + overscroll-behavior: contain; +} + +.session-sidebar-head { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 6px 6px 8px; + border-bottom: 1px solid var(--line); +} + +.session-sidebar-head h2 { + font-size: 13px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-icon-button { + width: 30px; + height: 30px; + border: 1px solid var(--line-strong); + background: var(--surface-2); + color: var(--text); + font-size: 18px; + font-weight: 760; + line-height: 1; + cursor: pointer; +} + +.session-icon-button:hover, +.session-icon-button:focus-visible, +.session-delete-button:hover, +.session-delete-button:focus-visible { + border-color: var(--accent); + outline: 0; +} + +.session-tabs { + min-width: 0; + min-height: 0; + display: grid; + align-content: start; + gap: 6px; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.session-tab { + width: 100%; + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + align-items: center; + padding: 8px; + border: 1px solid var(--line); + border-left-width: 3px; + background: rgba(16, 19, 17, 0.86); + color: var(--muted); + text-align: left; + cursor: pointer; +} + +.session-tab[aria-selected="true"] { + background: rgba(31, 39, 34, 0.94); + border-color: var(--line-strong); + color: var(--text); +} + +.session-tab:hover, +.session-tab:focus-visible { + border-color: var(--accent); + outline: 0; +} + +.session-tab-label, +.session-tab-subtitle, +.session-tab-count, +.session-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-tab-label { + color: var(--text); + font-size: 12px; + font-weight: 760; + white-space: nowrap; +} + +.session-tab-subtitle { + color: var(--dim); + font-family: var(--mono); + font-size: 10px; +} + +.session-tab-count { + grid-row: span 2; + color: var(--accent); + font-family: var(--mono); + font-size: 10px; +} + +.session-sidebar-foot { + min-width: 0; + display: grid; + gap: 6px; + padding-top: 8px; + border-top: 1px solid var(--line); +} + +.session-status { + color: var(--muted); + font-family: var(--mono); + font-size: 10px; +} + +.session-delete-button { + min-width: 0; + min-height: 32px; + border: 1px solid var(--line-strong); + background: var(--surface-2); + color: var(--text); + font-size: 11px; + font-weight: 760; + cursor: pointer; +} + +.session-delete-button:disabled, +.session-icon-button:disabled { + cursor: progress; + opacity: 0.58; } .rail-spacer { @@ -272,7 +424,10 @@ body > [data-app-shell][hidden] { line-height: 1.2; cursor: pointer; text-align: center; - overflow-wrap: anywhere; + overflow: hidden; + overflow-wrap: normal; + text-overflow: ellipsis; + white-space: nowrap; word-break: keep-all; } @@ -2352,12 +2507,13 @@ tbody tr:last-child td { @media (max-width: 1240px) { .workbench-shell { --rail-width: 86px; - grid-template-columns: var(--rail-width) minmax(0, 1fr); + --session-sidebar-width: clamp(158px, 22vw, 204px); + grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) clamp(220px, 36dvh, 360px); } .right-sidebar { - grid-column: 2; + grid-column: 2 / 4; grid-row: 2; min-height: 0; padding-left: 10px; @@ -2374,7 +2530,8 @@ tbody tr:last-child td { @media (max-width: 860px) { .workbench-shell { --rail-width: 68px; - grid-template-columns: var(--rail-width) minmax(0, 1fr); + --session-sidebar-width: 148px; + grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) clamp(214px, 30dvh, 280px); } @@ -2401,7 +2558,25 @@ tbody tr:last-child td { .center-workspace, .right-sidebar { + grid-column: 3; + } + + .session-sidebar { grid-column: 2; + grid-row: 1 / 3; + padding: 7px; + } + + .session-sidebar-head { + padding: 5px 4px 7px; + } + + .session-sidebar-head h2 { + font-size: 12px; + } + + .session-tab { + padding: 7px 6px; } .center-workspace { @@ -2551,7 +2726,8 @@ tbody tr:last-child td { @media (max-width: 520px) { .workbench-shell { --rail-width: 64px; - grid-template-columns: var(--rail-width) minmax(0, 1fr); + --session-sidebar-width: 128px; + grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) clamp(260px, 34dvh, 320px); }