feat: align session sidebar and cli management
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 : {};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user