feat: align session sidebar and cli management

This commit is contained in:
Codex
2026-06-03 09:35:46 +08:00
parent d39d184eae
commit da25cb4c02
16 changed files with 1013 additions and 110 deletions
+45
View File
@@ -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);
+114 -1
View File
@@ -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();
+5
View File
@@ -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;
+10 -1
View File
@@ -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/") ||
@@ -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"
});
});
+57
View File
@@ -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: {
+168
View File
@@ -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();
+1 -88
View File
@@ -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 });
+6
View File
@@ -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;
+86 -10
View File
@@ -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);
+267 -1
View File
@@ -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 : {};
+15
View File
@@ -62,6 +62,21 @@
title="拖拽调整左侧导航宽度;用左右方向键微调,Home/End 到最小或最大。"
></div>
<aside class="session-sidebar" id="session-sidebar" aria-label="Code Agent sessions">
<header class="session-sidebar-head">
<div>
<p class="eyebrow">Sessions</p>
<h2>Agent Sessions</h2>
</div>
<button class="session-icon-button" id="session-create" type="button" aria-label="创建 session" title="创建 session">+</button>
</header>
<div class="session-tabs" id="session-tabs" role="tablist" aria-label="Code Agent sessions"></div>
<div class="session-sidebar-foot">
<span class="session-status" id="session-status">等待 workspace</span>
<button class="session-delete-button" id="session-delete" type="button" disabled>删除当前</button>
</div>
</aside>
<section class="center-workspace">
<section class="view workbench-view" id="workspace" data-view="workspace" aria-labelledby="workspace-title">
<div class="conversation-column">
+12 -1
View File
@@ -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 [
@@ -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",
+34 -1
View File
@@ -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 }]
};
}
+183 -7
View File
@@ -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);
}