diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 320b73a1dc48..f4f8da3d9321 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/docs_map.md b/docs/docs_map.md index 07949d104206..d84dfaec39ef 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/web/dashboards.md b/docs/web/dashboards.md index 05f593359c3e..4e6bfe153448 100644 --- a/docs/web/dashboards.md +++ b/docs/web/dashboards.md @@ -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//` 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. diff --git a/docs/web/urls.md b/docs/web/urls.md index 8d5eb65aab31..e01d0901fcdd 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -112,6 +112,7 @@ no route-specific URL parameters. | ------------------- | --------------------------- | ------------------------- | ------------------------------------------------ | | Chat | `/chat` | - | Key-backed session forms above; `?draft=` | | Dashboard | `/dashboard` | - | Key-backed session forms above; `?draft=` | +| Dashboards | `/dashboards` | - | - | | Ask OpenClaw | `/custodian` | - | `?intent=new-agent`, `?onboarding=1` | | New session | `/new` | - | `?agent=`, `?catalog=` | | Activity | `/activity` | - | - | diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 05ae9220969d..7f1bfefbc32b 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -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"); diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index 5b4ddc7bf5f8..1046794b78d7 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -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()), diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 0bf7be4b9bab..4196d1a46b3a 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -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:, or svg:....", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 6d69f6000257..3f106ab44cef 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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; diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 466246d0df28..fa06cf200f6f 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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, diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 44e36d84dc1e..0ff65ea67026 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -89,6 +89,7 @@ const SESSIONS_PATCH_WRITE_SCOPE_FIELDS: ReadonlySet = new Set([ "agentId", "label", "category", + "boardFace", "icon", "pinned", "archived", diff --git a/src/gateway/server.sessions.face.test.ts b/src/gateway/server.sessions.face.test.ts new file mode 100644 index 000000000000..64e6b9c691aa --- /dev/null +++ b/src/gateway/server.sessions.face.test.ts @@ -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" }), + ]); +}); diff --git a/src/gateway/session-utils-list.ts b/src/gateway/session-utils-list.ts index 973dcac7a65e..488abbe103c2 100644 --- a/src/gateway/session-utils-list.ts +++ b/src/gateway/session-utils-list.ts @@ -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) { diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 1560249d7f18..a65f07488ea6 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -416,6 +416,7 @@ export function buildGatewaySessionRow(params: { kind: classifySessionKey(key, entry), label: entry?.label, category: entry?.category, + boardFace: entry?.boardFace, displayName, derivedTitle, lastMessagePreview, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 1df9d54f7d6d..a083936d876e 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -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; diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index d908083f5380..c2b255723b8b 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -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) { diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index f34c328be3de..a352f1b4d2ba 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -160,6 +160,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "claudeCliSessionId", "label", "category", + "boardFace", "displayName", "delivery", "groupId", diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index 68aadbab4800..de2f58d6e44a 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -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; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 6d29c2852c48..0b335e9ef9a2 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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; diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index c6959a642efd..6465f457d232 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -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"); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 0332636f2e46..dc4dc72c76a3 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -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 { 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} >` diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 6ba1af3ec1b4..a30c4773a88f 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -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", () => { diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 31b86f5f5883..dc70c9b36ccd 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -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 diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 451193cd65a6..7078958da4c8 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -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` { + 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); } }; diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index 104450a44ca8..700d09d2d019 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -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; diff --git a/ui/src/e2e/board-mcp-app.e2e.test.ts b/ui/src/e2e/board-mcp-app.e2e.test.ts index 2993423c2849..9a4785c6b590 100644 --- a/ui/src/e2e/board-mcp-app.e2e.test.ts +++ b/ui/src/e2e/board-mcp-app.e2e.test.ts @@ -55,10 +55,10 @@ async function openDashboard(page: Page): Promise { 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(); } diff --git a/ui/src/e2e/session-dashboard.e2e.test.ts b/ui/src/e2e/session-dashboard.e2e.test.ts index bf31c8fa037b..091893c1e014 100644 --- a/ui/src/e2e/session-dashboard.e2e.test.ts +++ b/ui/src/e2e/session-dashboard.e2e.test.ts @@ -145,7 +145,7 @@ async function showDashboard(page: Page): Promise { 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); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1ee8e4c4299a..757cd693459a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/lib/board/settings.ts b/ui/src/lib/board/settings.ts index 79e053ece39d..4718b9448133 100644 --- a/ui/src/lib/board/settings.ts +++ b/ui/src/lib/board/settings.ts @@ -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; }; @@ -21,9 +22,6 @@ export function normalizeBoardSessionViews(value: unknown): BoardSessionViews { continue; } const view = rawView as Record; - if (view.face !== "chat" && view.face !== "dashboard") { - continue; - } const activeTabId = typeof view.activeTabId === "string" ? view.activeTabId.trim() : ""; const reopenDockByTab: Record = {}; 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, diff --git a/ui/src/lib/sessions/index-list.test.ts b/ui/src/lib/sessions/index-list.test.ts index af427a1e5b0e..91a9e8c5dcb6 100644 --- a/ui/src/lib/sessions/index-list.test.ts +++ b/ui/src/lib/sessions/index-list.test.ts @@ -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(); + }); }); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 950136b4cfdf..8bc01c7bb36c 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -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 { + 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", diff --git a/ui/src/lib/sessions/route-navigation.ts b/ui/src/lib/sessions/route-navigation.ts index ff38abc1e5ec..feddc5904cae 100644 --- a/ui/src/lib/sessions/route-navigation.ts +++ b/ui/src/lib/sessions/route-navigation.ts @@ -43,6 +43,22 @@ type SessionNavigationTarget = { options: ApplicationNavigationOptions & { pathname: string }; }; +export function resolveSessionPreferredFace( + row: Pick | null | undefined, +): BoardFace { + return row?.boardFace === "dashboard" ? "dashboard" : "chat"; +} + +export function resolveSessionPreferredFaceForKey( + context: Pick, "sessions">, + sessionKey: string, +): BoardFace { + const row = context.sessions.state.result?.sessions.find((candidate) => + areUiSessionKeysEquivalent(candidate.key, sessionKey), + ); + return resolveSessionPreferredFace(row); +} + export function resolveSessionNavigationAgentId( context: Pick, "agents" | "agentSelection" | "gateway">, agentId?: string | null, diff --git a/ui/src/pages/chat/chat-board-face-persistence.ts b/ui/src/pages/chat/chat-board-face-persistence.ts new file mode 100644 index 000000000000..7da98424641a --- /dev/null +++ b/ui/src/pages/chat/chat-board-face-persistence.ts @@ -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, + 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); +} diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index b858d9907247..7eb8c181429c 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -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 () => { diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index e5c596e8f87d..e1b20da0d678 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -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); }; diff --git a/ui/src/pages/chat/chat-pane-board.test.ts b/ui/src/pages/chat/chat-pane-board.test.ts index fa7df3fc6d62..06cd38080f44 100644 --- a/ui/src/pages/chat/chat-pane-board.test.ts +++ b/ui/src/pages/chat/chat-pane-board.test.ts @@ -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" }, }); }); diff --git a/ui/src/pages/chat/chat-pane-board.ts b/ui/src/pages/chat/chat-pane-board.ts index b4e9ef66d697..fed650f40d80 100644 --- a/ui/src/pages/chat/chat-pane-board.ts +++ b/ui/src/pages/chat/chat-pane-board.ts @@ -371,7 +371,9 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { }; } - protected persistBoardSessionView(patch: Partial): void { + protected persistBoardSessionView( + patch: Partial & { face?: "chat" | "dashboard" }, + ): void { if (patch.face) { this.onFaceChange?.(patch.face); } diff --git a/ui/src/pages/dashboards/route.test.ts b/ui/src/pages/dashboards/route.test.ts new file mode 100644 index 000000000000..83773f9ef779 --- /dev/null +++ b/ui/src/pages/dashboards/route.test.ts @@ -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", + }); + }); +}); diff --git a/ui/src/pages/dashboards/route.ts b/ui/src/pages/dashboards/route.ts new file mode 100644 index 000000000000..e18dce536790 --- /dev/null +++ b/ui/src/pages/dashboards/route.ts @@ -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 { + 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)}`, + })), +}); diff --git a/ui/src/pages/dashboards/view.test.ts b/ui/src/pages/dashboards/view.test.ts new file mode 100644 index 000000000000..c193a591c091 --- /dev/null +++ b/ui/src/pages/dashboards/view.test.ts @@ -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("[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"); + }); +}); diff --git a/ui/src/pages/dashboards/view.ts b/ui/src/pages/dashboards/view.ts new file mode 100644 index 000000000000..8f783fabe50e --- /dev/null +++ b/ui/src/pages/dashboards/view.ts @@ -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``; + } + if (rows.length === 0) { + return html`
+
${t("dashboardsPage.emptyTitle")}
+
${t("dashboardsPage.emptyDescription")}
+
`; + } + return html`
+ +
`; +} + +export function renderDashboards(data: DashboardsRouteData | undefined) { + const body = data + ? renderDashboardList(data) + : html`
${t("common.loading")}
`; + return html` +
+
+
${titleForRoute("dashboards")}
+
${t("subtitles.dashboards")}
+
+
+ ${renderSettingsWorkspace(body)} + `; +} diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index e20721d532fd..745b344a0b97 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -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), diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts index e290e4c2a510..5d0fd33e9e49 100644 --- a/ui/src/pages/sessions/view.test.ts +++ b/ui/src/pages/sessions/view.test.ts @@ -103,6 +103,28 @@ function sessionTableHeaders(container: HTMLElement): Array 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(".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(); diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index b192cabd7e5c..7d4e2fdbdc3c 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -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, diff --git a/ui/src/pages/tasks/tasks-page.test.ts b/ui/src/pages/tasks/tasks-page.test.ts index 29655512af87..b3cb59109eca 100644 --- a/ui/src/pages/tasks/tasks-page.test.ts +++ b/ui/src/pages/tasks/tasks-page.test.ts @@ -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; diff --git a/ui/src/pages/tasks/tasks-page.ts b/ui/src/pages/tasks/tasks-page.ts index edcec827de81..5816a94d073e 100644 --- a/ui/src/pages/tasks/tasks-page.ts +++ b/ui/src/pages/tasks/tasks-page.ts @@ -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, - ), + ); + }, })} `; } diff --git a/ui/src/pages/tasks/view.ts b/ui/src/pages/tasks/view.ts index dd93329520ac..adec6a167686 100644 --- a/ui/src/pages/tasks/view.ts +++ b/ui/src/pages/tasks/view.ts @@ -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; + 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, diff --git a/ui/src/pages/workboard/workboard-page.ts b/ui/src/pages/workboard/workboard-page.ts index b0db0adac178..1ca1c18df786 100644 --- a/ui/src/pages/workboard/workboard-page.ts +++ b/ui/src/pages/workboard/workboard-page.ts @@ -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: "", }); }, diff --git a/ui/src/pages/worktrees/worktrees-page.ts b/ui/src/pages/worktrees/worktrees-page.ts index fd360327af6e..17a3fcda57fa 100644 --- a/ui/src/pages/worktrees/worktrees-page.ts +++ b/ui/src/pages/worktrees/worktrees-page.ts @@ -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`${t("worktrees.ownerSession")}`;