diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts
index 147cab8d..ec28f184 100644
--- a/web/hwlab-cloud-web/scripts/check.ts
+++ b/web/hwlab-cloud-web/scripts/check.ts
@@ -67,6 +67,7 @@ console.log("hwlab-cloud-web check: right sidebar extracted");
assert.ok(rightSidebar, "Device Pod right sidebar must exist");
assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route");
assert.match(html, /id="session-tabs"[^>]*role="tablist"/u, "Cloud Web workspace must expose session tabs");
+assert.match(html, /class="session-sidebar"[^>]*id="session-sidebar"/u, "Cloud Web must place sessions in a second-level sidebar");
assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view");
assert.match(html, /id="skill-upload-input"[^>]*webkitdirectory/u, "Skills view must support directory upload");
assertIncludes(app, "/v1/skills", "skills frontend must call /v1/skills");
@@ -82,9 +83,19 @@ assertIncludes(styles, ".skill-prompt-assembly", "skills frontend must style pro
assertIncludes(styles, ".skill-prompt-row", "skills frontend must style prompt assembly refs");
assertIncludes(app, "sessionTabsFromMessages", "workspace frontend must group messages by session");
assertIncludes(app, "activeSessionKey", "workspace frontend must track the active session tab");
-assertIncludes(app, "currentSessionView", "workspace frontend must filter conversation rendering by selected session");
+assertIncludes(app, "renderSessionSidebar", "workspace frontend must render account-scoped sessions in the second-level sidebar");
+assertIncludes(app, "selectSessionSidebarSession", "workspace frontend must switch sessions from the second-level sidebar");
assertIncludes(styles, ".session-tabs", "workspace frontend must style session tabs");
assertIncludes(styles, ".session-tab", "workspace frontend must style individual session tabs");
+assertIncludes(app, "sessionTabsFromConversations", "workspace frontend must build session tabs from account conversations");
+assertIncludes(app, "/v1/agent/conversations", "workspace frontend must load account-scoped sessions");
+assertIncludes(app, "select-conversation", "workspace frontend must switch sessions through workbench select-conversation");
+assertIncludes(styles, ".session-sidebar", "workspace frontend must style the second-level session sidebar");
+assertIncludes(styles, "grid-template-columns: var(--rail-width) var(--session-sidebar-width)", "workspace shell must place session sidebar to the right of activity rail");
+assert.match(cssRule(styles, ".activity-rail"), /overflow-x:\s*hidden;/u, "activity rail must not horizontally scroll");
+assert.match(cssRule(styles, ".session-tabs"), /overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll");
+assert.match(cssRule(styles, ".rail-button"), /white-space:\s*nowrap;/u, "activity rail labels must not auto-wrap");
+assert.match(cssRule(styles, ".session-tab-label"), /white-space:\s*nowrap;/u, "session labels must not auto-wrap");
console.log("hwlab-cloud-web check: skills contracts verified");
for (const term of [
diff --git a/web/hwlab-cloud-web/scripts/dist-contract.ts b/web/hwlab-cloud-web/scripts/dist-contract.ts
index 15c4ea7d..44cafa66 100644
--- a/web/hwlab-cloud-web/scripts/dist-contract.ts
+++ b/web/hwlab-cloud-web/scripts/dist-contract.ts
@@ -10,6 +10,7 @@ export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand;
export const cloudWebAppSourceFiles = Object.freeze([
"app.ts",
"app-skills.ts",
+ "app-session-tabs.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
diff --git a/web/hwlab-cloud-web/session-tabs.test.ts b/web/hwlab-cloud-web/session-tabs.test.ts
index 18a88d0f..12f83397 100644
--- a/web/hwlab-cloud-web/session-tabs.test.ts
+++ b/web/hwlab-cloud-web/session-tabs.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { activeSessionKeyFromRuntime, sessionTabsFromMessages } from "./app-session-tabs.ts";
+import { activeSessionKeyFromRuntime, sessionTabsFromConversations, sessionTabsFromMessages } from "./app-session-tabs.ts";
test("session tabs group messages and filter the active session", () => {
const messages = [
@@ -55,6 +55,28 @@ test("runtime active session prefers current request session before global state
assert.equal(key, "ses_current");
});
+test("session tabs use account conversations and keep the selected session active", () => {
+ const view = sessionTabsFromConversations([
+ conversation("cnv_old", "ses_old", "trc_old", "idle", "2026-06-03T00:00:01Z"),
+ conversation("cnv_new", "ses_new", "trc_new", "running", "2026-06-03T00:00:03Z")
+ ], { selectedConversationId: "cnv_old" });
+
+ assert.equal(view.tabs.length, 2);
+ assert.equal(view.activeConversation?.conversationId, "cnv_old");
+ assert.equal(view.tabs[0].conversationId, "cnv_new");
+ assert.equal(view.tabs.find((tab) => tab.conversationId === "cnv_new")?.subtitle, "处理中");
+});
+
+test("session tabs default to newest visible conversation", () => {
+ const view = sessionTabsFromConversations([
+ conversation("cnv_a", "ses_a", "trc_a", "idle", "2026-06-03T00:00:01Z"),
+ conversation("cnv_b", "ses_b", "trc_b", "completed", "2026-06-03T00:00:05Z")
+ ], { selectedConversationId: "missing" });
+
+ assert.equal(view.activeConversation?.conversationId, "cnv_b");
+ assert.equal(view.activeKey, "ses_b");
+});
+
function message(role, sessionId, conversationId, traceId, status, createdAt) {
return {
id: `${role}_${traceId}`,
@@ -68,3 +90,14 @@ function message(role, sessionId, conversationId, traceId, status, createdAt) {
createdAt
};
}
+
+function conversation(conversationId, sessionId, traceId, status, updatedAt) {
+ return {
+ conversationId,
+ sessionId,
+ status,
+ updatedAt,
+ lastTraceId: traceId,
+ messages: [{ role: "agent", text: traceId, status, traceId, updatedAt }]
+ };
+}
diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css
index 05119190..84220607 100644
--- a/web/hwlab-cloud-web/styles.css
+++ b/web/hwlab-cloud-web/styles.css
@@ -89,6 +89,7 @@ ul {
.workbench-shell {
--rail-width: clamp(var(--left-sidebar-min-width), var(--left-sidebar-width), var(--left-sidebar-max-width));
+ --session-sidebar-width: clamp(174px, 17vw, 236px);
--right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
position: relative;
@@ -97,7 +98,7 @@ ul {
max-height: 100dvh;
min-height: 0;
display: grid;
- grid-template-columns: var(--rail-width) minmax(420px, 1fr) var(--right-width-expanded);
+ grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(420px, 1fr) var(--right-width-expanded);
grid-template-rows: 1fr;
overflow: hidden;
overscroll-behavior: contain;
@@ -239,6 +240,7 @@ body > [data-app-shell][hidden] {
}
.activity-rail,
+.session-sidebar,
.right-sidebar,
.center-workspace {
min-width: 0;
@@ -252,7 +254,157 @@ body > [data-app-shell][hidden] {
padding: 8px;
background: var(--rail);
border-right: 1px solid var(--line);
- overflow: auto;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.session-sidebar {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 8px;
+ background: rgba(19, 22, 20, 0.98);
+ border-right: 1px solid var(--line);
+ overflow: hidden;
+ overscroll-behavior: contain;
+}
+
+.session-sidebar-head {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ padding: 6px 6px 8px;
+ border-bottom: 1px solid var(--line);
+}
+
+.session-sidebar-head h2 {
+ font-size: 13px;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.session-icon-button {
+ width: 30px;
+ height: 30px;
+ border: 1px solid var(--line-strong);
+ background: var(--surface-2);
+ color: var(--text);
+ font-size: 18px;
+ font-weight: 760;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.session-icon-button:hover,
+.session-icon-button:focus-visible,
+.session-delete-button:hover,
+.session-delete-button:focus-visible {
+ border-color: var(--accent);
+ outline: 0;
+}
+
+.session-tabs {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: 6px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+}
+
+.session-tab {
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px 8px;
+ align-items: center;
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-left-width: 3px;
+ background: rgba(16, 19, 17, 0.86);
+ color: var(--muted);
+ text-align: left;
+ cursor: pointer;
+}
+
+.session-tab[aria-selected="true"] {
+ background: rgba(31, 39, 34, 0.94);
+ border-color: var(--line-strong);
+ color: var(--text);
+}
+
+.session-tab:hover,
+.session-tab:focus-visible {
+ border-color: var(--accent);
+ outline: 0;
+}
+
+.session-tab-label,
+.session-tab-subtitle,
+.session-tab-count,
+.session-status {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.session-tab-label {
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 760;
+ white-space: nowrap;
+}
+
+.session-tab-subtitle {
+ color: var(--dim);
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.session-tab-count {
+ grid-row: span 2;
+ color: var(--accent);
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.session-sidebar-foot {
+ min-width: 0;
+ display: grid;
+ gap: 6px;
+ padding-top: 8px;
+ border-top: 1px solid var(--line);
+}
+
+.session-status {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.session-delete-button {
+ min-width: 0;
+ min-height: 32px;
+ border: 1px solid var(--line-strong);
+ background: var(--surface-2);
+ color: var(--text);
+ font-size: 11px;
+ font-weight: 760;
+ cursor: pointer;
+}
+
+.session-delete-button:disabled,
+.session-icon-button:disabled {
+ cursor: progress;
+ opacity: 0.58;
}
.rail-spacer {
@@ -272,7 +424,10 @@ body > [data-app-shell][hidden] {
line-height: 1.2;
cursor: pointer;
text-align: center;
- overflow-wrap: anywhere;
+ overflow: hidden;
+ overflow-wrap: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
word-break: keep-all;
}
@@ -2352,12 +2507,13 @@ tbody tr:last-child td {
@media (max-width: 1240px) {
.workbench-shell {
--rail-width: 86px;
- grid-template-columns: var(--rail-width) minmax(0, 1fr);
+ --session-sidebar-width: clamp(158px, 22vw, 204px);
+ grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(220px, 36dvh, 360px);
}
.right-sidebar {
- grid-column: 2;
+ grid-column: 2 / 4;
grid-row: 2;
min-height: 0;
padding-left: 10px;
@@ -2374,7 +2530,8 @@ tbody tr:last-child td {
@media (max-width: 860px) {
.workbench-shell {
--rail-width: 68px;
- grid-template-columns: var(--rail-width) minmax(0, 1fr);
+ --session-sidebar-width: 148px;
+ grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(214px, 30dvh, 280px);
}
@@ -2401,7 +2558,25 @@ tbody tr:last-child td {
.center-workspace,
.right-sidebar {
+ grid-column: 3;
+ }
+
+ .session-sidebar {
grid-column: 2;
+ grid-row: 1 / 3;
+ padding: 7px;
+ }
+
+ .session-sidebar-head {
+ padding: 5px 4px 7px;
+ }
+
+ .session-sidebar-head h2 {
+ font-size: 12px;
+ }
+
+ .session-tab {
+ padding: 7px 6px;
}
.center-workspace {
@@ -2551,7 +2726,8 @@ tbody tr:last-child td {
@media (max-width: 520px) {
.workbench-shell {
--rail-width: 64px;
- grid-template-columns: var(--rail-width) minmax(0, 1fr);
+ --session-sidebar-width: 128px;
+ grid-template-columns: var(--rail-width) var(--session-sidebar-width) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(260px, 34dvh, 320px);
}