mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
a2eceb9b2f
commit
8b66fc103d
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,6 +89,7 @@ const SESSIONS_PATCH_WRITE_SCOPE_FIELDS: ReadonlySet<string> = new Set([
|
||||
"agentId",
|
||||
"label",
|
||||
"category",
|
||||
"boardFace",
|
||||
"icon",
|
||||
"pinned",
|
||||
"archived",
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -416,6 +416,7 @@ export function buildGatewaySessionRow(params: {
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
category: entry?.category,
|
||||
boardFace: entry?.boardFace,
|
||||
displayName,
|
||||
derivedTitle,
|
||||
lastMessagePreview,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -160,6 +160,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"claudeCliSessionId",
|
||||
"label",
|
||||
"category",
|
||||
"boardFace",
|
||||
"displayName",
|
||||
"delivery",
|
||||
"groupId",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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>`
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
})),
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
`;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
Reference in New Issue
Block a user