feat(ui): durable session board face and dashboards index (#114262)

* feat(ui): durable session board face and dashboards index

Board face lived only in client-side boardSessionViews, capped at 50 entries,
so the preference never followed the user to another device, evicted as
sessions accumulated, and could not be seen as a set.

Persist it as SessionEntry.boardFace, which rides the existing entry_json blob
and so needs no SQLite schema change or version bump. Expose it on the session
list row and add it to the sessions.patch write-scope allowlist alongside label,
pinned, and archived: setting your own view preference is user-level chat
organization, not policy. Unknown patch fields still fail closed to
operator.admin.

Generic navigation now reads the stored face, so the sidebar and session list
open a thread on the face you left it on. boardSessionViews keeps only
activeTabId and reopenDockByTab, which are genuinely per-device.

Add /dashboards listing threads whose preferred face is dashboard. Filtering
runs server-side in filterSessionEntries before pagination, because the client
holds only a capped page and a client-side filter would silently omit
dashboards.

* test(protocol): assert the pre-rename face param is rejected

The gateway-protocol validator test still passed the pre-rename 'face' key,
which the closed schema rejects. Use boardFace, and pin the old name as a
negative case so it cannot silently return.

* chore(protocol): regenerate Swift bindings and docs map for boardFace

Adding boardFace to the sessions schema changes two committed generated
artifacts: the Swift gateway models (pnpm protocol:gen:swift) and the docs map
(pnpm docs:map:gen), which now lists the dashboards index section.
This commit is contained in:
Peter Steinberger
2026-07-27 00:35:34 -04:00
committed by GitHub
parent a2eceb9b2f
commit 8b66fc103d
55 changed files with 609 additions and 65 deletions
@@ -4014,6 +4014,7 @@ public struct SessionsListParams: Codable, Sendable {
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let boardface: AnyCodable?
public let creatorid: String?
public let spawnedby: String?
public let agentid: String?
@@ -4032,6 +4033,7 @@ public struct SessionsListParams: Codable, Sendable {
includederivedtitles: Bool? = nil,
includelastmessage: Bool? = nil,
label: String? = nil,
boardface: AnyCodable? = nil,
creatorid: String? = nil,
spawnedby: String? = nil,
agentid: String? = nil,
@@ -4049,6 +4051,7 @@ public struct SessionsListParams: Codable, Sendable {
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.boardface = boardface
self.creatorid = creatorid
self.spawnedby = spawnedby
self.agentid = agentid
@@ -4068,6 +4071,7 @@ public struct SessionsListParams: Codable, Sendable {
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case boardface = "boardFace"
case creatorid = "creatorId"
case spawnedby = "spawnedBy"
case agentid = "agentId"
@@ -4938,6 +4942,7 @@ public struct SessionRow: Codable, Sendable {
public let incognito: Bool?
public let kind: AnyCodable
public let label: String?
public let boardface: AnyCodable?
public let displayname: String?
public let derivedtitle: String?
public let lastmessagepreview: String?
@@ -4993,6 +4998,7 @@ public struct SessionRow: Codable, Sendable {
incognito: Bool? = nil,
kind: AnyCodable,
label: String? = nil,
boardface: AnyCodable? = nil,
displayname: String? = nil,
derivedtitle: String? = nil,
lastmessagepreview: String? = nil,
@@ -5047,6 +5053,7 @@ public struct SessionRow: Codable, Sendable {
self.incognito = incognito
self.kind = kind
self.label = label
self.boardface = boardface
self.displayname = displayname
self.derivedtitle = derivedtitle
self.lastmessagepreview = lastmessagepreview
@@ -5103,6 +5110,7 @@ public struct SessionRow: Codable, Sendable {
case incognito
case kind
case label
case boardface = "boardFace"
case displayname = "displayName"
case derivedtitle = "derivedTitle"
case lastmessagepreview = "lastMessagePreview"
@@ -7487,6 +7495,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let agentid: String?
public let label: AnyCodable?
public let category: AnyCodable?
public let boardface: AnyCodable?
public let icon: AnyCodable?
public let statusnote: AnyCodable?
public let attention: AnyCodable?
@@ -7518,6 +7527,7 @@ public struct SessionsPatchParams: Codable, Sendable {
agentid: String? = nil,
label: AnyCodable? = nil,
category: AnyCodable? = nil,
boardface: AnyCodable? = nil,
icon: AnyCodable? = nil,
statusnote: AnyCodable? = nil,
attention: AnyCodable? = nil,
@@ -7548,6 +7558,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.agentid = agentid
self.label = label
self.category = category
self.boardface = boardface
self.icon = icon
self.statusnote = statusnote
self.attention = attention
@@ -7580,6 +7591,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case agentid = "agentId"
case label
case category
case boardface = "boardFace"
case icon
case statusnote = "statusNote"
case attention
+1
View File
@@ -10807,6 +10807,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /web/dashboards
- Headings:
- H2: Find your dashboards
- H2: Build a dashboard by asking
- H2: The board
- H2: What widgets are allowed to do
+13
View File
@@ -16,6 +16,17 @@ There is nothing to set up and no separate app to configure: dashboards are a
core feature, owned by the thread, stored with the agent, and they survive
`/new` and `/reset` (the conversation context clears; the board stays).
## Find your dashboards
Open `/dashboards` to see every thread whose preferred face is Dashboard, with
the most recently updated thread first. Open any row to go directly to that
thread's `/dashboard/<agent>/<sessionRef>` URL.
The Chat or Dashboard face preference is stored server-side per thread. It
therefore follows you when you connect to the same gateway from another device.
The active dashboard tab and remembered chat-dock position remain per-device UI
state, so each browser can keep its own working layout.
## Build a dashboard by asking
Ask your agent for what you want to see:
@@ -95,6 +106,8 @@ one-tap, revision-bound approval as everything else.
- Deleting a thread deletes its board.
- Boards live on your gateway (in the owning agent's database) and appear on
every device you connect from.
- Switching a thread to the Dashboard face adds it to `/dashboards`. Switching
it back to Chat removes it.
- The security model, storage details, and design rationale live in
[Dashboard Architecture](/web/dashboard-architecture), including the
documented sandbox tradeoffs.
+1
View File
@@ -112,6 +112,7 @@ no route-specific URL parameters.
| ------------------- | --------------------------- | ------------------------- | ------------------------------------------------ |
| Chat | `/chat` | - | Key-backed session forms above; `?draft=<text>` |
| Dashboard | `/dashboard` | - | Key-backed session forms above; `?draft=<text>` |
| Dashboards | `/dashboards` | - | - |
| Ask OpenClaw | `/custodian` | - | `?intent=new-agent`, `?onboarding=1` |
| New session | `/new` | - | `?agent=<agentId>`, `?catalog=<catalogId>` |
| Activity | `/activity` | - | - |
@@ -20,6 +20,7 @@ import {
validateSessionsCompanionResetParams,
validateSessionsCompanionStateParams,
validateSessionsObserverVisibilityParams,
validateSessionsPatchParams,
validateSessionsSearchParams,
validateSessionsUsageParams,
validateTasksCancelParams,
@@ -89,6 +90,15 @@ describe("lazy protocol validators", () => {
expect(validateSessionsListParams({ archived: "archived" })).toBe(false);
});
it("validates session board face list and patch values", () => {
expect(validateSessionsListParams({ boardFace: "dashboard" })).toBe(true);
expect(validateSessionsListParams({ boardFace: "grid" })).toBe(false);
expect(validateSessionsPatchParams({ key: "agent:main:main", boardFace: "chat" })).toBe(true);
expect(validateSessionsPatchParams({ key: "agent:main:main", boardFace: "grid" })).toBe(false);
// The schemas are closed objects; the pre-rename name must not slip back in.
expect(validateSessionsListParams({ face: "dashboard" })).toBe(false);
});
it("keeps validation errors readable on the exported validator", () => {
expect(validateConnectParams({})).toBe(false);
expect(formatValidationErrors(validateConnectParams.errors)).toContain("must have required");
@@ -24,6 +24,7 @@ export const SessionRowSchema = Type.Object(
Type.Literal("unknown"),
]),
label: Type.Optional(Type.String()),
boardFace: Type.Optional(Type.Union([Type.Literal("chat"), Type.Literal("dashboard")])),
displayName: Type.Optional(Type.String()),
derivedTitle: Type.Optional(Type.String()),
lastMessagePreview: Type.Optional(Type.String()),
@@ -332,6 +332,8 @@ export const SessionsListParamsSchema = closedObject({
*/
includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString),
/** Limit rows to sessions with an explicitly stored Control UI face preference. */
boardFace: Type.Optional(Type.Union([Type.Literal("chat"), Type.Literal("dashboard")])),
/** Filter rows by their permanent creator identity. */
creatorId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString),
@@ -470,6 +472,7 @@ export const SessionsPatchParamsSchema = closedObject({
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
/** User-defined organization bucket ("category", not chat-group); null clears it. */
category: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
boardFace: Type.Optional(Type.Union([Type.Literal("chat"), Type.Literal("dashboard")])),
icon: Type.Optional(
Type.Union([NonEmptyString, Type.Null()], {
description: "Sidebar icon: one emoji, name:<id>, or svg:<svg ...>...</svg>.",
+3
View File
@@ -11,6 +11,7 @@ import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/
import type { ChatType } from "../../channels/chat-type.js";
import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js";
import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js";
import type { SessionBoardFace } from "../../shared/session-types.js";
import type { Skill } from "../../skills/loading/skill-contract.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { TtsAutoMode } from "../types.tts.js";
@@ -534,6 +535,8 @@ export type SessionEntry = SessionRestartRecoveryState &
label?: string;
/** User-defined organization bucket for session lists; unrelated to chat groupId/groupChannel. */
category?: string;
/** Preferred Control UI face when a caller opens this session without explicit face intent. */
boardFace?: SessionBoardFace;
displayName?: string;
/** Canonical delivery state. Legacy delivery fields are migrated by `openclaw doctor --fix`. */
delivery?: SessionDeliveryState;
+1
View File
@@ -300,6 +300,7 @@ describe("method scope resolution", () => {
resolveLeastPrivilegeOperatorScopesForMethod("sessions.patch", {
key: "agent:main:ios-1",
label: "Trip planning",
boardFace: "dashboard",
icon: "name:spark",
pinned: true,
archived: false,
+1
View File
@@ -89,6 +89,7 @@ const SESSIONS_PATCH_WRITE_SCOPE_FIELDS: ReadonlySet<string> = new Set([
"agentId",
"label",
"category",
"boardFace",
"icon",
"pinned",
"archived",
+84
View File
@@ -0,0 +1,84 @@
/** Gateway durable session-face behavior. */
import { expect, test } from "vitest";
import { rpcReq, writeSessionStore } from "./test-helpers.js";
import {
directSessionReq,
setupGatewaySessionsTestHarness,
} from "./test/server-sessions.test-helpers.js";
const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness();
test("a write-scoped face patch is visible to another client", async () => {
await createSessionStoreDir();
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
const firstClient = await openClient({ scopes: ["operator.read", "operator.write"] });
try {
const patched = await rpcReq<{ ok: true; entry: { boardFace?: string } }>(
firstClient.ws,
"sessions.patch",
{ key: "agent:main:main", boardFace: "dashboard" },
);
expect(patched.ok).toBe(true);
expect(patched.payload?.entry.boardFace).toBe("dashboard");
const unknownField = await rpcReq(firstClient.ws, "sessions.patch", {
key: "agent:main:main",
futureFace: "dashboard",
});
expect(unknownField.ok).toBe(false);
expect(unknownField.error?.message).toContain("missing scope: operator.admin");
} finally {
firstClient.ws.close();
}
const secondClient = await openClient({ scopes: ["operator.read"] });
try {
const listed = await rpcReq<{ sessions: Array<{ key: string; boardFace?: string }> }>(
secondClient.ws,
"sessions.list",
{ boardFace: "dashboard" },
);
expect(listed.ok).toBe(true);
expect(listed.payload?.sessions).toMatchObject([
{ key: "agent:main:main", boardFace: "dashboard" },
]);
} finally {
secondClient.ws.close();
}
});
test("sessions.list applies face filtering before pagination", async () => {
await createSessionStoreDir();
const now = Date.now();
await writeSessionStore({
entries: {
...Object.fromEntries(
Array.from({ length: 51 }, (_, index) => [
`chat-${index}`,
{ sessionId: `sess-chat-${index}`, updatedAt: now - index },
]),
),
dashboard: {
sessionId: "sess-dashboard",
updatedAt: now - 10_000,
boardFace: "dashboard",
},
},
});
const listed = await directSessionReq<{
sessions: Array<{ key: string; boardFace?: string }>;
totalCount: number;
}>("sessions.list", { boardFace: "dashboard", limit: 50 });
expect(listed.ok).toBe(true);
expect(listed.payload?.totalCount).toBe(1);
expect(listed.payload?.sessions).toEqual([
expect.objectContaining({ key: "agent:main:dashboard", boardFace: "dashboard" }),
]);
});
+7
View File
@@ -101,6 +101,7 @@ function filterSessionEntries(params: {
const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const label = normalizeOptionalString(opts.label) ?? "";
const boardFace = opts.boardFace;
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const search = normalizeLowercaseStringOrEmpty(opts.search);
const activeMinutes =
@@ -188,6 +189,12 @@ function filterSessionEntries(params: {
return true;
}
return entry?.label === label;
})
.filter(([, entry]) => {
if (!boardFace) {
return true;
}
return entry?.boardFace === boardFace;
});
if (search) {
+1
View File
@@ -416,6 +416,7 @@ export function buildGatewaySessionRow(params: {
kind: classifySessionKey(key, entry),
label: entry?.label,
category: entry?.category,
boardFace: entry?.boardFace,
displayName,
derivedTitle,
lastMessagePreview,
+3
View File
@@ -23,6 +23,7 @@ import type {
GatewayAgentRuntime,
GatewayAgentRow as SharedGatewayAgentRow,
GatewayThinkingLevelOption,
SessionBoardFace,
SessionsListResultBase,
SessionsPatchResultBase,
} from "../shared/session-types.js";
@@ -83,6 +84,8 @@ export type GatewaySessionRow = {
label?: string;
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
category?: string;
/** Preferred Control UI face for generic session navigation. */
boardFace?: SessionBoardFace;
displayName?: string;
derivedTitle?: string;
lastMessagePreview?: string;
+4
View File
@@ -264,6 +264,10 @@ export async function projectSessionsPatchEntry(params: {
}
}
if ("boardFace" in patch && patch.boardFace !== undefined) {
next.boardFace = patch.boardFace;
}
if ("icon" in patch) {
const raw = patch.icon;
if (raw === null) {
+1
View File
@@ -160,6 +160,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"claudeCliSessionId",
"label",
"category",
"boardFace",
"displayName",
"delivery",
"groupId",
+3
View File
@@ -36,6 +36,9 @@ export type GatewayThinkingLevelOption = {
export type GatewayAgentKind = "agent" | "system";
/** Per-session Control UI face preference carried by session list rows. */
export type SessionBoardFace = "chat" | "dashboard";
/** Common agent row shape used by session list responses. */
export type GatewayAgentRow = {
id: string;
+3
View File
@@ -10,6 +10,7 @@ import type { FastModeSource } from "../../../src/shared/fast-mode.js";
import type {
GatewayAgentRuntime,
GatewayAgentRow as SharedGatewayAgentRow,
SessionBoardFace,
SessionsListResultBase,
SessionsPatchResultBase,
} from "../../../src/shared/session-types.js";
@@ -528,6 +529,8 @@ export type GatewaySessionRow = {
label?: string;
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
category?: string;
/** Preferred Control UI face for generic session navigation. */
boardFace?: SessionBoardFace;
displayName?: string;
derivedTitle?: string;
channel?: string;
+5
View File
@@ -187,6 +187,7 @@ describe("navigationIconForRoute", () => {
apps: "layoutGrid",
approvals: "badgeCheck",
workboard: "kanban",
dashboards: "layoutDashboard",
worktrees: "folder",
channels: "link",
connection: "radio",
@@ -296,6 +297,7 @@ describe("titleForRoute", () => {
apps: "Apps",
approvals: "Approvals",
workboard: "Workboard",
dashboards: "Dashboards",
worktrees: "Worktrees",
channels: "Channels",
connection: "Connection",
@@ -342,6 +344,7 @@ describe("subtitleForRoute", () => {
apps: "Companion apps for phone, watch, desktop, and browser.",
approvals: "Recent exec, plugin, and system-agent approvals.",
workboard: "Agent work queue and thread handoff.",
dashboards: "Threads that open on their dashboard face.",
worktrees: "Isolated agent task checkouts and recovery snapshots.",
channels: "Channels and settings.",
connection: "Gateway endpoint, credentials, and handshake status.",
@@ -381,6 +384,7 @@ describe("pathForRoute", () => {
it("returns correct path without base", () => {
expect(pathForRoute("chat")).toBe("/chat");
expect(pathForRoute("apps")).toBe("/apps");
expect(pathForRoute("dashboards")).toBe("/dashboards");
expect(pathForRoute("custodian")).toBe("/custodian");
expect(pathForRoute("connection")).toBe("/settings/connection");
expect(pathForRoute("debug")).toBe("/debug");
@@ -417,6 +421,7 @@ describe("routeIdFromPath", () => {
expect(routeIdFromPath("/connection")).toBeNull();
expect(routeIdFromPath("/activity")).toBe("activity");
expect(routeIdFromPath("/apps")).toBe("apps");
expect(routeIdFromPath("/dashboards")).toBe("dashboards");
expect(routeIdFromPath("/sessions")).toBe("sessions");
expect(routeIdFromPath("/debug")).toBe("debug");
expect(routeIdFromPath("/logs")).toBe("logs");
+3
View File
@@ -18,6 +18,7 @@ type NavigationItem = {
// Worktrees is a tab of the Sessions hub, so it is not listed either.
export const SIDEBAR_NAV_ROUTES = [
"workboard",
"dashboards",
"usage",
"cron",
"tasks",
@@ -224,6 +225,7 @@ const NAVIGATION_ICONS: NavigationItem = {
nodes: "monitorSmartphone",
chat: "messageSquare",
dashboard: "layoutDashboard",
dashboards: "layoutDashboard",
custodian: "lobster",
config: "settings",
profile: "circleUser",
@@ -326,6 +328,7 @@ const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey
nodes: { titleKey: "tabs.nodes", subtitleKey: "subtitles.nodes" },
chat: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" },
dashboard: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" },
dashboards: { titleKey: "tabs.dashboards", subtitleKey: "subtitles.dashboards" },
custodian: { titleKey: "tabs.custodian", subtitleKey: "subtitles.custodian" },
config: { titleKey: "nav.settings", subtitleKey: "subtitles.config" },
profile: { titleKey: "tabs.profile", subtitleKey: "subtitles.profile" },
+1
View File
@@ -7,6 +7,7 @@ export const INTERNAL_SESSION_PATH_PARAM = "__openclawSessionPath";
const APP_ROUTE_DEFINITIONS = {
chat: { path: "/chat" },
dashboard: { path: "/dashboard" },
dashboards: { path: "/dashboards" },
custodian: { path: "/custodian" },
"new-session": { path: "/new" },
activity: { path: "/activity" },
+2
View File
@@ -20,6 +20,7 @@ import { pages as configPages } from "./pages/config/route.ts";
import { page as connectionPage } from "./pages/connection/route.ts";
import { page as cronPage } from "./pages/cron/route.ts";
import { page as custodianPage } from "./pages/custodian/route.ts";
import { page as dashboardsPage } from "./pages/dashboards/route.ts";
import { page as debugPage } from "./pages/debug/route.ts";
import { page as labsPage } from "./pages/labs/route.ts";
import { page as logsPage } from "./pages/logs/route.ts";
@@ -56,6 +57,7 @@ const APP_ROUTE_TREE = [
custodianPage,
newSessionPage,
activityPage,
dashboardsPage,
appsPage,
agentsPage,
approvalsPage,
+11 -11
View File
@@ -58,7 +58,10 @@ import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { createIdleImport } from "../lib/idle-import.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFaceForKey,
sessionNavigationTarget,
} from "../lib/sessions/route-navigation.ts";
import {
isUiGlobalSessionKey,
normalizeAgentId,
@@ -813,12 +816,10 @@ class OpenClawShell extends OpenClawLightDomElement {
sessions: context.sessions.state.result?.sessions ?? [],
onOpen: (sessionKey) => {
context.gateway.setSessionKey(sessionKey);
const face = resolveSessionPreferredFaceForKey(context, sessionKey);
// Ambiguous one-segment keys intentionally fall back to /chat;
// the removed query deep-link format is not a compatibility path.
this.navigate(
"chat",
sessionNavigationTarget({ context, face: "chat", sessionKey }).options,
);
this.navigate(face, sessionNavigationTarget({ context, face, sessionKey }).options);
},
}),
);
@@ -877,9 +878,10 @@ class OpenClawShell extends OpenClawLightDomElement {
return;
}
context.gateway.setSessionKey(command.sessionKey);
const face = resolveSessionPreferredFaceForKey(context, command.sessionKey);
this.navigate(
"chat",
sessionNavigationTarget({ context, face: "chat", sessionKey: command.sessionKey }).options,
face,
sessionNavigationTarget({ context, face, sessionKey: command.sessionKey }).options,
);
};
@@ -1729,10 +1731,8 @@ class OpenClawShell extends OpenClawLightDomElement {
.onNavigate=${(routeId: RouteId) => this.navigate(routeId)}
.onSelectSession=${(sessionKey: string) => {
context.gateway.setSessionKey(sessionKey);
this.navigate(
"chat",
sessionNavigationTarget({ context, face: "chat", sessionKey }).options,
);
const face = resolveSessionPreferredFaceForKey(context, sessionKey);
this.navigate(face, sessionNavigationTarget({ context, face, sessionKey }).options);
}}
.onSlashCommand=${this.handleCommandPaletteSlashCommand}
></openclaw-command-palette>`
+5 -5
View File
@@ -765,7 +765,7 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().chatSplitLayout).toEqual(chatSplitLayout);
});
it("persists the last dashboard face and active tab per session", () => {
it("persists dashboard tab and dock state per session", () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
@@ -774,11 +774,9 @@ describe("loadSettings default gateway URL derivation", () => {
const settings = loadSettings();
const boardSessionViews = {
"agent:main:main": {
face: "dashboard" as const,
activeTabId: "research",
reopenDockByTab: { research: "left" as const },
},
"agent:main:plain": { face: "chat" as const },
};
saveSettings({ ...settings, boardSessionViews });
@@ -786,7 +784,7 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().boardSessionViews).toEqual(boardSessionViews);
});
it("drops invalid stored dashboard view settings", () => {
it("silently drops legacy local face while preserving per-device tab state", () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
@@ -803,7 +801,9 @@ describe("loadSettings default gateway URL derivation", () => {
}),
);
expect(loadSettings().boardSessionViews).toEqual({});
expect(loadSettings().boardSessionViews).toEqual({
"agent:main:main": { activeTabId: "research" },
});
});
it("persists normalized sidebar layouts per session", () => {
+1 -1
View File
@@ -187,7 +187,7 @@ export type UiSettings = {
talkCameraAutoEnable?: boolean;
chatSplitLayout?: ChatSplitLayout;
chatWorkspaceDock?: ChatWorkspaceDock; // Session workspace rail dock edge (default "right")
boardSessionViews?: BoardSessionViews; // Last face and active dashboard tab per session
boardSessionViews?: BoardSessionViews; // Per-device active dashboard tab and dock state
sidebarSessionLayouts?: SidebarSessionLayouts; // Sidebar columns and widths per session
sidebarSessionActivePanels?: SidebarSessionActivePanels; // Collapsed active panel per session
navCollapsed: boolean; // Collapsible sidebar state
+5 -2
View File
@@ -13,7 +13,10 @@ import { t } from "../i18n/index.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFace,
sessionNavigationTarget,
} from "../lib/sessions/route-navigation.ts";
import {
areUiSessionKeysEquivalent,
normalizeAgentId,
@@ -136,7 +139,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
return html`
<a
href=${sessionNavigationTarget({
face: "chat",
face: resolveSessionPreferredFace(mainRow),
sessionKey: mainKey,
fallbackAgentId: agentId,
basePath: host.basePath,
@@ -19,7 +19,10 @@ import {
filterVisibleSessionRows,
resolveSessionNavigation,
} from "../lib/sessions/index.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFace,
sessionNavigationTarget,
} from "../lib/sessions/route-navigation.ts";
import {
areUiSessionKeysEquivalent,
buildAgentMainSessionKey,
@@ -123,7 +126,7 @@ export function buildSidebarSessionNavigationState(input: {
meta: formatSidebarTimestamp(row.updatedAt),
subtitle: resolveSessionWorkSubtitle(row),
href: sessionNavigationTarget({
face: "chat",
face: resolveSessionPreferredFace(row),
sessionKey: row.key,
fallbackAgentId: navigation.selectedAgentId,
basePath: context?.basePath ?? "",
@@ -142,6 +145,7 @@ export function buildSidebarSessionNavigationState(input: {
draftOwnedBySelf: isSidebarDraftOwnedBySelf(row, context?.gateway.snapshot.selfUser?.id),
icon: row.icon,
category: normalizeOptionalString(row.category),
boardFace: row.boardFace,
channel: channelInfo.channel,
channelSession: channelInfo.channelSession,
workSession:
@@ -12,7 +12,10 @@ import {
filterVisibleSessionRows,
sessionMatchesArchivedFilter,
} from "../lib/sessions/index.ts";
import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFace,
sessionNavigationTarget,
} from "../lib/sessions/route-navigation.ts";
import {
areUiSessionKeysEquivalent,
buildAgentMainSessionKey,
@@ -282,8 +285,9 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
}
readonly selectSession = (sessionKey: string) => {
const face = resolveSessionPreferredFace(this.findSidebarSessionByKey(sessionKey));
const target = sessionNavigationTarget({
face: "chat",
face,
sessionKey,
fallbackAgentId: this.selectedAgentIdForSessions(),
basePath: this.basePath,
@@ -291,7 +295,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
mainKey: this.sessionMainKey(),
});
this.context?.gateway.setSessionKey(sessionKey);
this.onNavigate?.("chat", target.options);
this.onNavigate?.(face, target.options);
};
/** Collapsed zones keep full rows for true header counts and status dots. */
@@ -420,8 +424,9 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
}
readonly replaceCurrentSession = (sessionKey: string) => {
const face = resolveSessionPreferredFace(this.findSidebarSessionByKey(sessionKey));
const target = sessionNavigationTarget({
face: "chat",
face,
sessionKey,
fallbackAgentId: this.selectedAgentIdForSessions(),
basePath: this.basePath,
@@ -430,7 +435,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
});
this.context?.gateway.setSessionKey(sessionKey);
if (isSessionRouteId(this.activeRouteId)) {
this.onNavigate?.("chat", target.options);
this.onNavigate?.(face, target.options);
}
};
@@ -7,6 +7,7 @@ import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionRunStatus } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
import type { BoardFace } from "../lib/board/settings.ts";
import {
normalizeCatalogProjectGrouping,
type CatalogProjectGrouping,
@@ -73,6 +74,7 @@ export type SidebarRecentSession = {
draftOwnedBySelf?: boolean;
icon?: string;
category?: string;
boardFace?: BoardFace;
channel?: string;
channelSession?: boolean;
workSession?: boolean;
+2 -2
View File
@@ -55,10 +55,10 @@ async function openDashboard(page: Page): Promise<void> {
string,
unknown
>;
settings.boardSessionViews = { [key]: { face: "dashboard", activeTabId: "main" } };
settings.boardSessionViews = { [key]: { activeTabId: "main" } };
localStorage.setItem(settingsKey, JSON.stringify(settings));
}, sessionKey);
await page.goto(`${controlUi.baseUrl}chat`);
await page.goto(`${controlUi.baseUrl}dashboard`);
await page.locator(".board-session-surface").waitFor();
}
+6 -6
View File
@@ -145,7 +145,7 @@ async function showDashboard(page: Page): Promise<void> {
unknown
>;
settings.boardSessionViews = {
[key]: { face: "dashboard", activeTabId: "main" },
[key]: { activeTabId: "main" },
};
localStorage.setItem(settingsKey, JSON.stringify(settings));
}, sessionKey);
@@ -303,7 +303,7 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
});
await showDashboard(page);
await page.goto(`${server.baseUrl}chat`);
await page.goto(`${server.baseUrl}dashboard`);
await expect
.poll(async () => (await gateway.getRequests("board.get")).length, { timeout: 30_000 })
.toBeGreaterThan(0);
@@ -403,7 +403,7 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
});
await showDashboard(page);
await page.goto(`${server.baseUrl}chat`);
await page.goto(`${server.baseUrl}dashboard`);
await page.locator(".board-session-surface").waitFor();
const preview = page.locator('.chat-tool-card__preview[data-kind="canvas"]');
await preview.hover();
@@ -481,7 +481,7 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
await showDashboard(page);
try {
await page.goto(`${server.baseUrl}chat`);
await page.goto(`${server.baseUrl}dashboard`);
const cardWidget = page.locator('[data-test-id="workboard-card-widget"]');
const miniWidget = page.locator('[data-test-id="workboard-mini-widget"]');
await cardWidget.waitFor();
@@ -589,7 +589,7 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
await showDashboard(page);
try {
await page.goto(`${server.baseUrl}chat`);
await page.goto(`${server.baseUrl}dashboard`);
const chip = page.locator(".board-session-surface__workboard-chip");
await chip.waitFor();
await expect.poll(() => chip.textContent()).toContain("Ship dashboard stitch");
@@ -721,7 +721,7 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
await showDashboard(page);
try {
await page.goto(`${server.baseUrl}chat`);
await page.goto(`${server.baseUrl}dashboard`);
await expect
.poll(async () => (await gateway.getRequests("board.get")).length)
.toBeGreaterThan(0);
+7
View File
@@ -580,6 +580,11 @@ export const en: TranslationMap = {
"The Gateway changed while this thread was starting. Check recent threads before starting this task again.",
catalogUnavailable: "This thread target is unavailable.",
},
dashboardsPage: {
emptyTitle: "No dashboards yet",
emptyDescription: "Open a thread and switch to the Dashboard face to add it here.",
loadError: "Could not load dashboards: {error}",
},
sessionsView: {
deletePreservedWorktrees:
"{count} thread worktree(s) with uncommitted or unpushed work were kept ({branches}). Manage them under Settings -> Worktrees.",
@@ -1798,6 +1803,7 @@ export const en: TranslationMap = {
skillWorkshop: "Skill Workshop",
nodes: "Devices",
chat: "Chat",
dashboards: "Dashboards",
custodian: "OpenClaw",
config: "Config",
profile: "Profile",
@@ -1835,6 +1841,7 @@ export const en: TranslationMap = {
skillWorkshop: "Review, refine, and apply proposals before they become live skills.",
nodes: "Paired devices, pairing approvals, and exec bindings.",
chat: "Gateway chat for quick interventions.",
dashboards: "Threads that open on their dashboard face.",
custodian: "System setup and care.",
config: "Model defaults, language, and gateway host.",
profile: "Your agent's stats, streaks, and life in the reef.",
+7 -7
View File
@@ -1,8 +1,9 @@
export type BoardFace = "chat" | "dashboard";
import type { SessionBoardFace } from "../../../../src/shared/session-types.js";
export type BoardFace = SessionBoardFace;
export type BoardVisibleChatDock = "bottom" | "left" | "right";
export type BoardSessionView = {
face: BoardFace;
activeTabId?: string;
reopenDockByTab?: Record<string, BoardVisibleChatDock>;
};
@@ -21,9 +22,6 @@ export function normalizeBoardSessionViews(value: unknown): BoardSessionViews {
continue;
}
const view = rawView as Record<string, unknown>;
if (view.face !== "chat" && view.face !== "dashboard") {
continue;
}
const activeTabId = typeof view.activeTabId === "string" ? view.activeTabId.trim() : "";
const reopenDockByTab: Record<string, BoardVisibleChatDock> = {};
if (
@@ -38,8 +36,10 @@ export function normalizeBoardSessionViews(value: unknown): BoardSessionViews {
}
}
}
if (!activeTabId && Object.keys(reopenDockByTab).length === 0) {
continue;
}
normalized[sessionKey] = {
face: view.face,
...(activeTabId ? { activeTabId } : {}),
...(Object.keys(reopenDockByTab).length > 0 ? { reopenDockByTab } : {}),
};
@@ -57,7 +57,7 @@ export function updateBoardSessionView(
return normalizeBoardSessionViews(current);
}
const views = normalizeBoardSessionViews(current);
const previous = views[key] ?? { face: "chat" as const };
const previous = views[key] ?? {};
delete views[key];
views[key] = {
...previous,
+33
View File
@@ -79,4 +79,37 @@ describe("session list requests", () => {
expect(request.mock.calls[2]?.[1]).not.toHaveProperty("activeMinutes");
sessions.dispose();
});
it("forwards the server-side face filter", async () => {
const result: SessionsListResult = {
ts: 1,
path: "(multiple)",
count: 0,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [],
};
const request = vi.fn(async () => result);
const sessions = createSessionCapability({
snapshot: {
client: { request } as unknown as GatewayBrowserClient,
phase: "connected" as const,
sessionKey: "agent:main:main",
assistantAgentId: "main",
hello: null,
},
subscribe: () => () => undefined,
subscribeEvents: () => () => undefined,
});
await sessions.list({ boardFace: "dashboard" });
expect(request).toHaveBeenCalledWith("sessions.list", {
configuredAgentsOnly: true,
boardFace: "dashboard",
includeGlobal: true,
includeUnknown: true,
limit: 50,
});
sessions.dispose();
});
});
+4
View File
@@ -80,6 +80,7 @@ export type { SessionArchivedFilter } from "./navigation.ts";
export type SessionListOptions = {
agentId?: string;
spawnedBy?: string;
boardFace?: "chat" | "dashboard";
activeMinutes?: number;
search?: string;
creatorId?: string;
@@ -386,6 +387,9 @@ function buildSessionListParams(options: SessionListOptions = {}): Record<string
const spawnedBy = options.spawnedBy?.trim();
const search = options.search?.trim();
const creatorId = options.creatorId?.trim();
if (options.boardFace) {
params.boardFace = options.boardFace;
}
if (agentId) {
params.agentId = agentId;
}
+1
View File
@@ -3,6 +3,7 @@ import type { FastMode, SessionsPatchResult } from "../../api/types.ts";
export type SessionPatch = {
label?: string | null;
category?: string | null;
boardFace?: "chat" | "dashboard";
icon?: string | null;
model?: string | null;
thinkingLevel?: string | null;
+7 -1
View File
@@ -1,8 +1,14 @@
import { describe, expect, it } from "vitest";
import { buildCatalogSessionKey } from "./catalog-key.ts";
import { sessionNavigationTarget } from "./route-navigation.ts";
import { resolveSessionPreferredFace, sessionNavigationTarget } from "./route-navigation.ts";
describe("sessionNavigationTarget", () => {
it("defaults generic opens to chat and honors a stored dashboard preference", () => {
expect(resolveSessionPreferredFace(undefined)).toBe("chat");
expect(resolveSessionPreferredFace({ boardFace: "chat" })).toBe("chat");
expect(resolveSessionPreferredFace({ boardFace: "dashboard" })).toBe("dashboard");
});
it("keeps different catalog threads on different destinations", () => {
const first = sessionNavigationTarget({
face: "chat",
+16
View File
@@ -43,6 +43,22 @@ type SessionNavigationTarget = {
options: ApplicationNavigationOptions & { pathname: string };
};
export function resolveSessionPreferredFace(
row: Pick<GatewaySessionRow, "boardFace"> | null | undefined,
): BoardFace {
return row?.boardFace === "dashboard" ? "dashboard" : "chat";
}
export function resolveSessionPreferredFaceForKey<TRouteId extends string>(
context: Pick<ApplicationContext<TRouteId>, "sessions">,
sessionKey: string,
): BoardFace {
const row = context.sessions.state.result?.sessions.find((candidate) =>
areUiSessionKeysEquivalent(candidate.key, sessionKey),
);
return resolveSessionPreferredFace(row);
}
export function resolveSessionNavigationAgentId<TRouteId extends string>(
context: Pick<ApplicationContext<TRouteId>, "agents" | "agentSelection" | "gateway">,
agentId?: string | null,
@@ -0,0 +1,24 @@
import type { ApplicationContext } from "../../app/context.ts";
import type { BoardFace } from "../../lib/board/settings.ts";
import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
/**
* Persists a thread's preferred face so generic navigation opens the same face on
* any device. Catalog threads are synthetic and have no gateway row to patch, so
* they keep using their query identity and are skipped. Failures stay silent: the
* face is already applied locally and a lost preference is not worth a toast.
*/
export function persistSessionBoardFace(
context: Pick<ApplicationContext, "sessions">,
sessionKey: string,
face: BoardFace,
): void {
if (parseCatalogSessionKey(sessionKey)) {
return;
}
const agentId = parseAgentSessionKey(sessionKey)?.agentId;
void context.sessions
.patch(sessionKey, { boardFace: face }, agentId ? { agentId } : {})
.catch(() => undefined);
}
+8 -2
View File
@@ -131,13 +131,14 @@ function getDropIndicator(page: ChatPage) {
function setNavigationContext(page: ChatPage) {
const navigate = vi.fn();
const replace = vi.fn();
const patch = vi.fn(async () => null);
const agentSelectionState = { selectedId: "main" };
const setAgent = vi.fn((agentId: string) => {
agentSelectionState.selectedId = agentId;
});
const context = {
basePath: "",
sessions: { state: { result: null }, subscribe: () => () => undefined },
sessions: { state: { result: null }, subscribe: () => () => undefined, patch },
agents: { state: { agentsList: { defaultId: "main", mainKey: "main" } } },
gateway: { snapshot: { hello: null } },
navigate,
@@ -145,7 +146,7 @@ function setNavigationContext(page: ChatPage) {
agentSelection: { state: agentSelectionState, set: setAgent },
} as unknown as ApplicationContext;
(page as unknown as { context: ApplicationContext }).context = context;
return { context, navigate, replace, setAgent };
return { context, navigate, replace, setAgent, patch };
}
function stubMatchMedia(matches: boolean) {
@@ -482,6 +483,11 @@ describe("chat page split layout host", () => {
expect(navigation.navigate).toHaveBeenCalledWith("dashboard", {
pathname: "/dashboard/main/1234567890",
});
expect(navigation.patch).toHaveBeenCalledWith(
WORK_SESSION_KEY,
{ boardFace: "dashboard" },
{ agentId: "main" },
);
});
it("passes an empty session key while route data is still unresolved", async () => {
+2
View File
@@ -18,6 +18,7 @@ import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts"
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { persistSessionBoardFace } from "./chat-board-face-persistence.ts";
import "../../styles/chat.css";
import "./chat-pane.ts";
import { locationWithoutDraft, type SessionChatRouteData } from "./route-loader.ts";
@@ -389,6 +390,7 @@ export class ChatPage extends OpenClawLightDomElement {
if (layout && layout.activePaneId !== paneId) {
this.persistLayout(setActivePane(layout, paneId));
}
persistSessionBoardFace(this.context, sessionKey, face);
this.updateRoute(sessionKey, false, face);
};
+4 -4
View File
@@ -390,7 +390,7 @@ describe("chat pane board shell", () => {
pane.state.settings = {
...loadSettings(),
boardSessionViews: {
"agent:main:current": { face: "dashboard", activeTabId: "research" },
"agent:main:current": { activeTabId: "research" },
},
};
localStorage.clear();
@@ -410,7 +410,7 @@ describe("chat pane board shell", () => {
it("preserves preferences saved by another split pane", () => {
const initialSettings = patchSettings({
boardSessionViews: {
"agent:main:first": { face: "chat", activeTabId: "main" },
"agent:main:first": { activeTabId: "main" },
},
});
const firstPane = createTestPane();
@@ -438,8 +438,8 @@ describe("chat pane board shell", () => {
secondPane.persistBoardSessionView({ activeTabId: "main" });
expect(loadSettings().boardSessionViews).toMatchObject({
"agent:main:first": { face: "chat", activeTabId: "research" },
"agent:main:second": { face: "chat", activeTabId: "main" },
"agent:main:first": { activeTabId: "research" },
"agent:main:second": { activeTabId: "main" },
});
});
+3 -1
View File
@@ -371,7 +371,9 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
};
}
protected persistBoardSessionView(patch: Partial<BoardSessionView>): void {
protected persistBoardSessionView(
patch: Partial<BoardSessionView> & { face?: "chat" | "dashboard" },
): void {
if (patch.face) {
this.onFaceChange?.(patch.face);
}
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment node
import type { RouteLoaderOptions } from "@openclaw/uirouter";
import { describe, expect, it, vi } from "vitest";
import type { ApplicationContext } from "../../app/context.ts";
import { page } from "./route.ts";
const loaderOptions: RouteLoaderOptions = {
signal: new AbortController().signal,
shouldRun: () => true,
revalidating: false,
location: { pathname: "/dashboards", search: "", hash: "" },
deps: "",
cause: "navigation",
};
describe("dashboards route", () => {
it("requests the dashboard face from the server before pagination", async () => {
const list = vi.fn(async () => null);
const context = {
basePath: "",
sessions: { list, canonicalListRevision: 4 },
agentSelection: { state: { selectedId: "main", scopeId: null } },
agents: { state: { agentsList: null } },
gateway: { snapshot: { hello: null } },
} as unknown as ApplicationContext;
if (!page.loader) {
throw new Error("dashboards route has no loader");
}
await page.loader(context, loaderOptions);
expect(list).toHaveBeenCalledWith({
limit: 50,
boardFace: "dashboard",
archivedFilter: "all",
});
});
});
+46
View File
@@ -0,0 +1,46 @@
import { definePage } from "@openclaw/uirouter";
import { html } from "lit";
import type { ApplicationContext } from "../../app/context.ts";
import { DEFAULT_SESSION_LIST_QUERY } from "../../lib/sessions/index.ts";
import { resolveSessionNavigationAgentId } from "../../lib/sessions/route-navigation.ts";
import { resolveUiConfiguredMainKey } from "../../lib/sessions/session-key.ts";
import type { DashboardsRouteData } from "./view.ts";
async function loadDashboardsRoute(context: ApplicationContext): Promise<DashboardsRouteData> {
const result = await context.sessions
.list({
...DEFAULT_SESSION_LIST_QUERY,
boardFace: "dashboard",
archivedFilter: "all",
...(context.agentSelection.state.scopeId
? { agentId: context.agentSelection.state.scopeId }
: {}),
})
.then(
(value) => ({ value, error: null }),
(error: unknown) => ({ value: null, error: String(error) }),
);
return {
result: result.value,
error: result.error,
basePath: context.basePath,
fallbackAgentId: resolveSessionNavigationAgentId(context),
mainKey: resolveUiConfiguredMainKey({
agentsList: context.agents.state.agentsList,
hello: context.gateway.snapshot.hello,
}),
};
}
export const page = definePage({
id: "dashboards",
path: "/dashboards",
loaderDeps: (context: ApplicationContext) =>
`${context.agentSelection.state.scopeId ?? "all"}\u0000${context.sessions.canonicalListRevision}`,
loader: (context: ApplicationContext) => loadDashboardsRoute(context),
component: () =>
import("./view.ts").then(({ renderDashboards }) => ({
header: true,
render: (data: DashboardsRouteData | undefined) => html`${renderDashboards(data)}`,
})),
});
+55
View File
@@ -0,0 +1,55 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it } from "vitest";
import type { SessionsListResult } from "../../api/types.ts";
import { renderDashboards, type DashboardsRouteData } from "./view.ts";
function routeData(sessions: SessionsListResult["sessions"]): DashboardsRouteData {
return {
result: {
ts: 1,
path: "(multiple)",
count: sessions.length,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions,
},
error: null,
basePath: "",
fallbackAgentId: "main",
mainKey: "main",
};
}
describe("dashboards index", () => {
it("links each row through the dashboard session namespace", () => {
const container = document.createElement("div");
render(
renderDashboards(
routeData([
{
key: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
kind: "direct",
boardFace: "dashboard",
displayName: "Deploy monitor",
updatedAt: 2,
},
]),
),
container,
);
const row = container.querySelector<HTMLAnchorElement>("[data-dashboard-session]");
expect(row?.textContent).toContain("Deploy monitor");
expect(row?.getAttribute("href")).toBe("/dashboard/main/deploy-monitor-12345678");
});
it("explains how to create a dashboard when the list is empty", () => {
const container = document.createElement("div");
render(renderDashboards(routeData([])), container);
const empty = container.querySelector("[data-dashboards-empty]");
expect(empty?.textContent).toContain("No dashboards yet");
expect(empty?.textContent).toContain("Open a thread and switch to the Dashboard face");
});
});
+78
View File
@@ -0,0 +1,78 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
export type DashboardsRouteData = {
result: SessionsListResult | null;
error: string | null;
basePath: string;
fallbackAgentId: string;
mainKey: string;
};
function renderDashboardList(data: DashboardsRouteData) {
const rows = data.result?.sessions ?? [];
if (data.error) {
return html`<section class="card" role="alert">
${t("dashboardsPage.loadError", { error: data.error })}
</section>`;
}
if (rows.length === 0) {
return html`<section class="card stack" data-dashboards-empty role="status">
<div class="list-title">${t("dashboardsPage.emptyTitle")}</div>
<div class="card-sub">${t("dashboardsPage.emptyDescription")}</div>
</section>`;
}
return html`<section class="card stack">
<div class="list" aria-label=${titleForRoute("dashboards")}>
${repeat(
rows,
(row) => row.key,
(row) => {
const target = sessionNavigationTarget({
face: "dashboard",
sessionKey: row.key,
fallbackAgentId: data.fallbackAgentId,
basePath: data.basePath,
row,
mainKey: data.mainKey,
});
return html`<a
class="list-item list-item-clickable"
data-dashboard-session=${row.key}
href=${target.href}
>
<span class="list-main">
<span class="list-title">${resolveSessionDisplayName(row.key, row)}</span>
<span class="list-sub">${row.key}</span>
</span>
<span class="list-meta"
>${row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : nothing}</span
>
</a>`;
},
)}
</div>
</section>`;
}
export function renderDashboards(data: DashboardsRouteData | undefined) {
const body = data
? renderDashboardList(data)
: html`<section class="card" aria-busy="true">${t("common.loading")}</section>`;
return html`
<section class="content-header">
<div>
<div class="page-title">${titleForRoute("dashboards")}</div>
<div class="page-sub">${t("subtitles.dashboards")}</div>
</div>
</section>
${renderSettingsWorkspace(body)}
`;
}
+7 -4
View File
@@ -30,6 +30,7 @@ import {
type SessionArchivedFilter,
} from "../../lib/sessions/index.ts";
import {
resolveSessionPreferredFaceForKey,
resolveSessionNavigationAgentId,
sessionNavigationTarget,
} from "../../lib/sessions/route-navigation.ts";
@@ -1395,16 +1396,18 @@ class SessionsPage extends OpenClawLightDomElement {
this.selectedKeys = new Set();
},
onDeleteSelected: () => void this.deleteSelected(),
onNavigateToChat: (sessionKey) =>
context.navigate("chat", {
onNavigateToChat: (sessionKey) => {
const face = resolveSessionPreferredFaceForKey(context, sessionKey);
context.navigate(face, {
...sessionNavigationTarget({
context,
face: "chat",
face,
sessionKey,
agentId: this.sessionPathAgentId(sessionKey, context),
}).options,
hash: "",
}),
});
},
onOpenSessionMenu: (row, position, trigger) =>
this.openSessionMenu(row, position, trigger),
onToggleDetails: (sessionKey) => void this.toggleSessionDetails(sessionKey),
+22
View File
@@ -103,6 +103,28 @@ function sessionTableHeaders(container: HTMLElement): Array<string | undefined>
const SESSION_TABLE_HEADERS = ["", "Key", "Kind", "Status", "Updated", "Tokens", "Actions"];
describe("sessions view", () => {
it("uses the stored face for generic session links", async () => {
const container = document.createElement("div");
render(
renderSessions(
buildProps(
buildResult({
key: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
kind: "direct",
boardFace: "dashboard",
updatedAt: 1,
}),
),
),
container,
);
await Promise.resolve();
expect(container.querySelector<HTMLAnchorElement>(".session-link")?.getAttribute("href")).toBe(
"/dashboard/main/12345678",
);
});
it("keeps transcript search distinct from the loaded-roster filter", async () => {
const container = document.createElement("div");
const onTranscriptSearchChange = vi.fn();
+5 -2
View File
@@ -47,7 +47,10 @@ import {
UNGROUPED_ID,
} from "../../lib/sessions/grouping.ts";
import type { SessionArchivedFilter } from "../../lib/sessions/index.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFace,
sessionNavigationTarget,
} from "../../lib/sessions/route-navigation.ts";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -1507,7 +1510,7 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) {
const canLink = row.kind !== "global";
const chatUrl = canLink
? sessionNavigationTarget({
face: "chat",
face: resolveSessionPreferredFace(row),
sessionKey: row.key,
fallbackAgentId: props.agentId,
basePath: props.basePath,
+5
View File
@@ -144,6 +144,11 @@ function createContext(
setScope: () => undefined,
subscribe,
},
// Session rows carry the durable boardFace that generic navigation reads.
sessions: {
state: { result: null, loading: false },
subscribe,
},
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
+8 -4
View File
@@ -12,6 +12,7 @@ import { hasOperatorWriteAccess } from "../../app/operator-access.ts";
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { t } from "../../i18n/index.ts";
import {
resolveSessionPreferredFaceForKey,
resolveSessionNavigationAgentId,
sessionNavigationTarget,
} from "../../lib/sessions/route-navigation.ts";
@@ -382,16 +383,19 @@ class TasksPage extends OpenClawLightDomElement {
error: this.error,
tasks: this.tasks,
cancellingTaskIds: this.cancellingTaskIds,
sessionFace: (sessionKey) => resolveSessionPreferredFaceForKey(this.context, sessionKey),
onCancel: (taskId) => void this.cancelTask(taskId),
onNavigateToChat: (sessionKey) =>
onNavigateToChat: (sessionKey) => {
const face = resolveSessionPreferredFaceForKey(this.context, sessionKey);
this.context.navigate(
"chat",
face,
sessionNavigationTarget({
context: this.context,
face: "chat",
face,
sessionKey,
}).options,
),
);
},
})}
`;
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { icon, type IconName } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import type { BoardFace } from "../../lib/board/settings.ts";
import { formatMs, formatRelativeTimestamp } from "../../lib/format.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
import {
@@ -26,6 +27,7 @@ type TasksProps = {
error: string | null;
tasks: TaskSummary[];
cancellingTaskIds: ReadonlySet<string>;
sessionFace: (sessionKey: string) => BoardFace;
onCancel: (taskId: string) => void;
onNavigateToChat: (sessionKey: string) => void;
};
@@ -35,8 +37,9 @@ function renderSessionLink(task: TaskSummary, props: TasksProps) {
if (!sessionKey) {
return nothing;
}
const face = props.sessionFace(sessionKey);
const href = sessionNavigationTarget({
face: "chat",
face,
sessionKey,
fallbackAgentId: props.agentId,
basePath: props.basePath,
+7 -3
View File
@@ -12,7 +12,10 @@ import {
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { renderWorkboardBoardGlyph } from "../../components/workboard-board-glyph.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFaceForKey,
sessionNavigationTarget,
} from "../../lib/sessions/route-navigation.ts";
import { workboardBoardName } from "../../lib/workboard/board-presentation.ts";
import { resetDraftState } from "../../lib/workboard/card-state.ts";
import {
@@ -378,8 +381,9 @@ class WorkboardPage extends OpenClawLightDomElement {
scopeAgentId: context.agentSelection.state.scopeId,
showAgentFilter: context.agentSelection.state.scopeId === null,
onOpenSession: (sessionKey) => {
context.navigate("chat", {
...sessionNavigationTarget({ context, face: "chat", sessionKey }).options,
const face = resolveSessionPreferredFaceForKey(context, sessionKey);
context.navigate(face, {
...sessionNavigationTarget({ context, face, sessionKey }).options,
hash: "",
});
},
+6 -2
View File
@@ -16,7 +16,10 @@ import {
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
import {
resolveSessionPreferredFaceForKey,
sessionNavigationTarget,
} from "../../lib/sessions/route-navigation.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
@@ -341,9 +344,10 @@ class WorktreesPage extends OpenClawLightDomElement {
private renderOwner(record: WorktreeRecord) {
if (record.ownerKind === "session" && record.ownerId) {
const face = resolveSessionPreferredFaceForKey(this.context, record.ownerId);
const href = sessionNavigationTarget({
context: this.context,
face: "chat",
face,
sessionKey: record.ownerId,
}).href;
return html`<a href=${href} title=${record.ownerId}>${t("worktrees.ownerSession")}</a>`;