From d4c3b9e78388fc7ca015b4eed4bbbead640d711e Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Sun, 9 Aug 2026 21:51:56 -0700 Subject: [PATCH] refactor(sessions): remove custom icon feature (#121263) * refactor(sessions): remove custom icon feature * test(sessions): expect legacy icons to be stripped * fix(gateway): preserve retired icon request compatibility * chore(plugin-sdk): refresh API baseline * fix(plugins): reserve retired session icon slot --- .../OpenClawProtocol/GatewayModels.swift | 12 - .../.generated/plugin-sdk-api-baseline.sha256 | 158 +++--- docs/concepts/session-tool.md | 2 +- packages/gateway-protocol/src/index.test.ts | 1 - packages/gateway-protocol/src/index.ts | 2 +- .../src/schema/sessions-patch.ts | 7 +- .../src/schema/sessions-row.ts | 1 - .../src/session-agent-status.ts | 16 + .../gateway-protocol/src/session-icon.test.ts | 51 -- packages/gateway-protocol/src/session-icon.ts | 372 -------------- .../src/validator-registry.ts | 14 +- scripts/control-ui-mock-dev.ts | 2 - src/agents/tools/sessions-tool.test.ts | 21 +- src/agents/tools/sessions-tool.ts | 11 +- .../doctor-session-canonical-keys.test.ts | 2 +- ...ession-accessor.sqlite-canonical-repair.ts | 1 - .../session-accessor.sqlite-entry-store.ts | 3 +- .../session-accessor.sqlite-session-row.ts | 3 +- src/config/sessions/store-entry-shape.ts | 1 + src/config/sessions/types.ts | 4 +- src/gateway/method-scopes.test.ts | 1 - .../server-methods/sessions-mutations.ts | 8 +- .../server.sessions.list-changed.test.ts | 20 - .../server.sessions.reset-cleanup.test.ts | 4 +- src/gateway/server.sessions.store-rpc.test.ts | 35 +- src/gateway/session-event-payload.ts | 1 - src/gateway/session-reset-service.ts | 1 - src/gateway/session-utils-row.ts | 1 - src/gateway/session-utils.test.ts | 3 +- src/gateway/session-utils.types.ts | 1 - src/gateway/sessions-patch.test.ts | 44 -- src/gateway/sessions-patch.ts | 14 - .../session-entry-projection.contract.test.ts | 9 + src/plugins/session-entry-slot-keys.ts | 2 +- src/sessions/session-agent-status.ts | 2 +- src/shared/session-method-scopes.ts | 8 +- ...enclaw-agent-db-session-nodes-migration.ts | 2 +- src/state/openclaw-agent-db.test.ts | 2 +- ui/src/api/types.ts | 2 +- .../app-sidebar-session-navigation-logic.ts | 1 - .../components/app-sidebar-session-types.ts | 4 +- .../session-attention-controller.ts | 2 +- .../session-attention-icon-registry.ts | 16 + .../session-attention-presentation.ts | 2 +- ui/src/components/session-icon-registry.ts | 68 --- .../components/session-leading-indicator.ts | 5 +- ui/src/components/session-menu.test.ts | 102 +--- ui/src/components/session-menu.ts | 472 ++++++------------ ui/src/components/sidebar-menus-render.ts | 9 - .../session-management.sidebar.e2e.test.ts | 19 +- ui/src/i18n/locales/en.ts | 5 - ui/src/lib/sessions/patch.ts | 1 - ui/src/lib/sessions/reconcile.test.ts | 17 - ui/src/lib/sessions/reconcile.ts | 3 - ui/src/pages/sessions/sessions-page.ts | 17 - ui/src/pages/sessions/view.ts | 1 - ui/src/styles/layout.css | 136 ----- .../app-sidebar-cases/session-indicators.ts | 1 - .../app-sidebar-cases/sessions.ts | 19 - .../app-sidebar-cases/sidebar-zone.ts | 29 +- 60 files changed, 356 insertions(+), 1417 deletions(-) create mode 100644 packages/gateway-protocol/src/session-agent-status.ts delete mode 100644 packages/gateway-protocol/src/session-icon.test.ts delete mode 100644 packages/gateway-protocol/src/session-icon.ts create mode 100644 ui/src/components/session-attention-icon-registry.ts delete mode 100644 ui/src/components/session-icon-registry.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 624688dec4e2..393b78c5de2f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5182,7 +5182,6 @@ public struct SessionRow: Codable, Sendable { public let archivedby: SessionCreatedActor? public let pinned: Bool? public let pinnedat: Double? - public let icon: String? public let unread: Bool? public let lastreadat: Double? public let lastactivityat: Double? @@ -5245,7 +5244,6 @@ public struct SessionRow: Codable, Sendable { archivedby: SessionCreatedActor? = nil, pinned: Bool? = nil, pinnedat: Double? = nil, - icon: String? = nil, unread: Bool? = nil, lastreadat: Double? = nil, lastactivityat: Double? = nil, @@ -5307,7 +5305,6 @@ public struct SessionRow: Codable, Sendable { self.archivedby = archivedby self.pinned = pinned self.pinnedat = pinnedat - self.icon = icon self.unread = unread self.lastreadat = lastreadat self.lastactivityat = lastactivityat @@ -5371,7 +5368,6 @@ public struct SessionRow: Codable, Sendable { case archivedby = "archivedBy" case pinned case pinnedat = "pinnedAt" - case icon case unread case lastreadat = "lastReadAt" case lastactivityat = "lastActivityAt" @@ -7792,7 +7788,6 @@ public struct SessionsPatchParams: Codable, Sendable { public let label: AnyCodable? public let category: AnyCodable? public let boardface: AnyCodable? - public let icon: AnyCodable? public let statusnote: AnyCodable? public let attention: AnyCodable? public let ttlminutes: Int? @@ -7827,7 +7822,6 @@ public struct SessionsPatchParams: Codable, Sendable { label: AnyCodable? = nil, category: AnyCodable? = nil, boardface: AnyCodable? = nil, - icon: AnyCodable? = nil, statusnote: AnyCodable? = nil, attention: AnyCodable? = nil, ttlminutes: Int? = nil, @@ -7861,7 +7855,6 @@ public struct SessionsPatchParams: Codable, Sendable { self.label = label self.category = category self.boardface = boardface - self.icon = icon self.statusnote = statusnote self.attention = attention self.ttlminutes = ttlminutes @@ -7897,7 +7890,6 @@ public struct SessionsPatchParams: Codable, Sendable { case label case category case boardface = "boardFace" - case icon case statusnote = "statusNote" case attention case ttlminutes = "ttlMinutes" @@ -7930,7 +7922,6 @@ public struct SessionsPatchMutation: Codable, Sendable { public let label: AnyCodable? public let category: AnyCodable? public let boardface: AnyCodable? - public let icon: AnyCodable? public let statusnote: AnyCodable? public let attention: AnyCodable? public let ttlminutes: Int? @@ -7961,7 +7952,6 @@ public struct SessionsPatchMutation: Codable, Sendable { label: AnyCodable? = nil, category: AnyCodable? = nil, boardface: AnyCodable? = nil, - icon: AnyCodable? = nil, statusnote: AnyCodable? = nil, attention: AnyCodable? = nil, ttlminutes: Int? = nil, @@ -7991,7 +7981,6 @@ public struct SessionsPatchMutation: Codable, Sendable { self.label = label self.category = category self.boardface = boardface - self.icon = icon self.statusnote = statusnote self.attention = attention self.ttlminutes = ttlminutes @@ -8023,7 +8012,6 @@ public struct SessionsPatchMutation: Codable, Sendable { case label case category case boardface = "boardFace" - case icon case statusnote = "statusNote" case attention case ttlminutes = "ttlMinutes" diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4e5621a06590..8d22896189f3 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,116 +1,116 @@ -abe3df6fc59f74c8a0e12aafcde8ef06b0fe8c25a1a6025be9ae4d666cc533a9 module/account-core -fcc4a78acfe1b07f6a9c7f65fcc3467941f21c3783edb3e1d23d1d3a4fb738ea module/account-helpers +3afa3ddcd32a58c57bd2a4e0ff4f6892c30c5c44522b6bd88791e6483def6142 module/account-core +b41e6e6f7a1197d85922d010de1abf1de64f25c535200f6506a8c9b4334952f1 module/account-helpers 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id -96bcaa5a97445164026eb1a8ae3a7190c8b3bfdfcc1edb10232ee6eaeeac646e module/account-resolution +1f9b6f85f7886fcca451930f5be6898ba917f9a9c01973fc7cc0c76929bdcc76 module/account-resolution 4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives -0b7384f469aa8a42637203665377f96f4b5f11878b400f1fca2047ed1a8c92b1 module/agent-harness -f86acd7a8497dcee988d85a21124da1c1af7988b302622e4e9c7fa025d85eb35 module/agent-harness-runtime +79a1401976a7f74c302edb059572e8014b4525dc60c1674a14e4c7df16476b46 module/agent-harness +6d8436bb623d9908b7b0fb33a974562378bb35158113407c25d83b5e4d057bbb module/agent-harness-runtime d6097cfa1b410f4b5267a56a7bd19c2a33fbaf6642683dae6aec68e998e48f6c module/agent-media-payload -7e39f1161acc4e9e15312b726fa21cba80583af6659a502163e26b0cbf7419bf module/agent-runtime -b57a3cb274a9977c48c5387772df7707e50dde9a6132e532e7367a27225dc142 module/agent-scope-runtime +e533832bff0a9b6b3b861565c68c0a14851fc59d278fa93c1ac1a2c4ef2d6585 module/agent-runtime +cbcd1201250087b16f41e87cd5b9d3be303620dd27e945b619e7117a5dbc069f module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from -2572e35200838bff7692c7218566589b2a78c79c07b7679dd051abd980b5f85f module/allowlist-config-edit -380aece9050c46d0660b640602cbf86f01b02394cf5fd5cc51f443502a58d2fb module/approval-auth-runtime -760aafdd2002d633ae954c92e8a8034532f08fba0e54ed7ad15491511913a333 module/approval-client-runtime -7e1c18824145a97a453e44a6e03b26f185b936457c860ca8a96f2a2def88920c module/approval-delivery-runtime -fca9de6851d929547fa3ad91e4501fd2b58cee98dab3ad0a4803b22291501c0a module/approval-gateway-runtime +af6c69278be1aaab26d97dcc27cdfd73f56b3f18df327e71f346a75882a09436 module/allowlist-config-edit +846e6be23e110ed8591775e09f66d4c860a7e88517a1e5d84b9af9d7ab9518b5 module/approval-auth-runtime +b0f0ad13a668a79e99e754cb0938186224ff4c86d683d22f12f2f35c96032cfc module/approval-client-runtime +12bbfe2e88289fa12ba0dde237f001cd5c0725ada0908152657b4e2f9b3c95c9 module/approval-delivery-runtime +ba39ac4f12fa4f2579e65bec93e0504395f988cc25a797c7441193bd652d9a0f module/approval-gateway-runtime 00583c41e60108630d09a5f6d22404fdc005156026d7f912141f126c5b2d9e37 module/approval-handler-adapter-runtime -0ffc2dcde88e3c45eeb237053f78333401537c09c329228c02884be4714213b8 module/approval-handler-runtime -fbb152e828397f1b7d804471554c7ccb6833fe90e3ac5e5efd6b7dc90d010a71 module/approval-native-runtime -f80b8064a51b5dc4814de28449deec83ab8ed2f40ad9fd15a9052b6c68ab7849 module/approval-reply-runtime -bcb23d2bad37e0d6cefecc60cdd96d97941b8ebb31f552e07d3983a3fe6c1f7b module/approval-runtime +f58b20c83ee79dc0a6d51e33907829f31962ed11f40a4c318810ea1565e6d71d module/approval-handler-runtime +9e3063fdec75eaf73960130ca60f93bb54a00fedc553f94a46d2774b27a45fa3 module/approval-native-runtime +1ec6ff040213d9cdfcc3e871b408e2c98ae5bc7bbdb7a9d847095df70050cbab module/approval-reply-runtime +649645f4cdcf1e2b17eafd696b3478dfdfe8a5e369e71c9074b6d6835681161a module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param b11b9d8fb991e26acd6e7efcb96b77635455d2c9b94d0a7e840dbdac94ad5b85 module/channel-actions -f02181e6e2a9da167d0b106ffa8934458ccfc9d3572509cc732c6a7300849829 module/channel-config-helpers +b33ade7748d6818914a6e9b9a23b98d0140432f5dcbe1f000a84d76617e9f639 module/channel-config-helpers c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives a6cca5706f3aba6abb2178b175a0986d921ade98c2c05d2a54451e2fb7e16825 module/channel-config-schema -8b5cd3810460cc85e72b1ca4382005c257336b62626362ba54092c417a62aa0e module/channel-contract -391c7dae1098f887ee6ccf29bc15b2b4418a2280ee88c623de7f54435257e13f module/channel-core -eccb43e6d478bee9ccbc6e76d57006dded896a6a2ae749b68e9b1a599e128898 module/channel-dm-policy -e8b0331142cf153199e6375f8122d1a1e7f34d3f8ee7ddb8c237e049e59b7194 module/channel-entry-contract +e4e4a5e78619abf1c8d78dd00d90e9bc8fe748adbf8a0dadbf0d9052bfcf5ab1 module/channel-contract +b48d30c0000175d7111950f98f7f975354a98515049510e628e1f134f7f60e15 module/channel-core +f6596a8bcf85e9cfeb29e785c0f3b21fe438e07b1a4f179b9ec49c624516ed72 module/channel-dm-policy +b7e5378f200bba78abe6d90194c446fcd593cce659c1d6afc88db628081e1600 module/channel-entry-contract 47cf8765e76c151ae7d2991d41beca62922a8837c1521e10a3fd23f9992c2d7c module/channel-feedback -c45e600724ec739ff3ba77a29ff7d39c64a4ade3083055864ffef85917e91b4e module/channel-inbound +9738aa21ed567df30ca0fcc452e4fa057c94357aae40ce3e3d0c6dd30c47a40e module/channel-inbound 3115366026efa38e07bfe0bfecd483e93454cc7bcad432385139c956777accff module/channel-inbound-debounce bc59c696ee45fb500d105c619c0ec81b8186bebe99135f23dc818ac16004deb0 module/channel-ingress-runtime -41a3c1cbfb690e37983ded136e1d0aefb1c73ebdb21c70ee34528e74e2f187e0 module/channel-lifecycle +bf81078cfeebe50ac260a3b6e56d842756f24ed2dd9af1a29f00a3ff2039be93 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -470fadadc7ac509dfd25220345ce2c51cec5878edf3161668a0e64b399d2d50c module/channel-message -3789f7171bd487f6f4a3b812817a7d78b9ac5e15e09887070527131fb3dd698f module/channel-outbound -a074a0e1f35f11ba528c158314e4b4d2c04e24def604ec9ae2ed912c5f8cec9e module/channel-pairing -67627a8a2aa39d4894ff5a28e515df6b5b0d431a63b05e91ec4f2159ebdc9a90 module/channel-plugin-common -31db1bb899aa81d83d9120e7f87e25862f2e2510d1e62c28b2daa5a3aeb6799c module/channel-policy -8a4a9a781f458c679a43bfe4072b3706e81ae722f77fd8f420f44898c94c5541 module/channel-reply-pipeline +fe92d881c05105228925c4b8fdf36aa7ff344b50366465b5a95fffd33c865bc4 module/channel-message +3c1356b7b27827e5ffa643d54509f786fa296d50d349419279756aef97d0edbe module/channel-outbound +5bf2cface89abd1a536077b5e050abf5cc36139bd20620b521726f73346c6e56 module/channel-pairing +f9956c820a1971243d93734ee6a61a56233aa40f854c4166f20d8cd18ec587e0 module/channel-plugin-common +e9f6fa5006304028e54f4ee84f1b006c14356b9f64b9c8508491cdae5290ccfd module/channel-policy +3c454d34f2c70b1741c2c977bd1367004c5d0e79ee44f3aece7345e40bc84394 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context 241050b8979ed905727789e99011535631dd8c205aa6967401677d78281900de module/channel-secret-basic-runtime 2edd63d294a8ee128a18c1815f6ae53be915569c49ebbad0dd139eb60475b0e6 module/channel-secret-runtime -331baf55a75adedcc882d1c03b2c1ad4e594c182879decb91fcf7b6ae59e32a5 module/channel-send-result -f352cc8c8f096d3d2ddb0e374bae66f1ab49697f678e5f703165c62a3f492fb9 module/channel-setup -d849e6ab5d37a686fee9d33f5dc305c7c142b5477a1cb7826d3c4b03e96950fc module/channel-status +306c4dcb4d88bd6a4f8f8636184670d995c3bba8bc8a9b6463d3d5d1b2e49d65 module/channel-send-result +1c6dc79f7cc591b1d043f48d0b726cd989f4b3e15665962bc31f1cb6b1c5e9c0 module/channel-setup +d9dc27767e1d3496fb7a8a8e36796815d0ed582034daf393a655a10378e7abaf module/channel-status f6c25ae55d49d90431682ca53c9dd836304942a738425d6ff028146a0c6d2c97 module/channel-streaming b2f920ff4a6b4190e6d6ea0a3effb001751e092f0e3ac0cf296721ff8c383d86 module/channel-streaming-config fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -48388ca2c61744282d3d0b6d23e45890926f8238c737dce7e60d8163c079c8b5 module/command-auth -5d2ba0ec9a10fe7cc1e41ecfc7ba03f50a2e0d9209cb7383f281c5390364f187 module/command-auth-native +c82399b8ce4cd51be079383960e1e83b60673c994780e57e25bc2331da1d3cfa module/command-auth +932935176b72425a4d364e61b4189724df348025dadf9e1a26ac45dd4cafce3d module/command-auth-native 50c24235bca2c1d3c011f6bc266b57078b78a76d2d27ee12e7b3245f2947b493 module/command-detection e24382c2cca7fd2cf69ac353e258153dab80b279daf9f0ef54a727981a7dff3c module/command-primitives-runtime 1d6ebcdc843b7072a7fc4475eadadb90a1111d49c1da1e4326775800873cf9f1 module/command-status -ab86235fcfff7c7cf0021fafeca6e92afe2c257ccf6a4a38441e0d41532998ff module/config-contracts +064ac3096df6265c46610082286d233b1806fc73fe02acd12daf784bd8eab243 module/config-contracts 0d99f5cb8c4978ed760e5fb4e476543759fbd8fd5bf73cc50a50c1d550203826 module/config-mutation -29271d7ed7b05e5caf95cba73a70a506cf2e2d25131ec08da8d8ab544a1a6f61 module/config-runtime -e90b0b9ae9153ad79242ef2ef7306b3ab4473c949d498b241a12c87227c25467 module/conversation-runtime -e725cc808a84fcf150668834c6cfc4126098da5e51e5a7a1cf65fe221aa2e8b4 module/core -001093661e24e8162d9a6b4ba6c0b6016a77f3a75cee322ac16ddab5818d50ec module/dedupe-runtime +5153d66e931ca8c9c5a6102460da637fcb9e6c7e1db94d67ef7e5970b6a8d42d module/config-runtime +a87d663700604d7ca14a2b3f7e047c67633182cbe60f1f18bb78782240a4886b module/conversation-runtime +f00686d371dd3d79626c72fb019ed1285e4335a894d54b27d159d8ca935c4092 module/core +085f25443004efc3cfd62b65b4aaea99b61d3720a7d7050ec9428144a8feab34 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap 21d86413166ef815581d606f678b6a216a1cc73ffe470b841f5bc4a131bff6df module/diagnostic-runtime -8882a5a1f5ab632fa7b26cca76fc584a36fa097f377ac5e25436d417c8471daf module/directory-runtime -9e5be3a87631bc4604d0a20eb365b3f2030903da16ddc8ac1a641313b260ce19 module/discord +6c697283dd82402fd84b97b5226086575a23ec36ba5bf915b650fb9ee21df9ad module/directory-runtime +df7cfb7f26fb3c341774c82b8640e66d3ab61700e48ff2c3a1f1fa39a373e03c module/discord 39fe343ed2119de714757c365eef2ccec89c2c82a0876c60a4bef8ce469f8c8a module/error-runtime ce4f1602bf5b5de968ca97cede4f498b6ae709a0a59393b5ce9255cf0e6a9d5e module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -aef9254aebfcd85721819e964284f6e52d1f39a1ab20d65b7f42487a63dcd3c9 module/gateway-runtime +74315bc4642a583d3dc21d09e2e179d414384f691672d1816f65321d2e466fc7 module/gateway-runtime 1b1c6bd5bfc0cfb0c5bb9bd97f8ac1928750cb87232a3415dd066cc21d7b48b7 module/group-access -c42a3d501b14b606fe104d98f780f0d0d9c0d972709afedb427bf925c7a12a2a module/health -6a92065dc642dc1552a6d2ead33d9f29956de59b19ecbe8d048f55864bac6428 module/hook-runtime +edcc13362193d3bb0b16fc48a3d96122e6efa236cfdec3760e57e2c6d85ee8b8 module/health +69d49c9cd6cf270df7dc1c6babb0c53c7ed1e60e9e2da102eecbda0226b2278e module/hook-runtime a953bd0c23c562e29fc512bc2354ed45c497728169d8201cb2cdb75d9e056073 module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -fe61193e1bcb4e214acfa92e02e35238485c681958a84ab2982cf34937fb75ef module/inbound-reply-dispatch -049c5e2e70f863164c4d1c49747dface6791fde278c976f318bd55b5a33394d8 module/infra-runtime +68fe9d4c03055dd40198f05b04ec17fd41b8022ff6cf042342d8a26b83e4a6d6 module/inbound-reply-dispatch +f0247897efdea3bff60314e68a0ad41aff9d6835b43a4c443044eb5fade503f8 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once -d70942696003e682318195c52842c66859f6f82ba5c2eadbaffe2ed73cc14d64 module/interactive-runtime +9c6e3796569c6a27762946281a5c1a792158505651ae5e9434e5d18ebd9ee947 module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store e907fd3a98185f2c261f2aafcaa5a19ee1d7b459d519a498397d629f84c68312 module/lazy-runtime cc2d0e1c1e7b9491eb254f314ed2c081a33256936781017eb5d8f9f11808f35b module/logging-core f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix 4d18b3bcec2c4291085e6001ed444198fe74f12708956e1c40c964d84e53bec8 module/media-local-roots f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime -5e5c0a9db4c3745f448bf8cb3ad45320d3b826c75a02227c6dfc9f9323ab8ff5 module/media-runtime +38ae5fdcae1c1fb795f1dc63b5b822020894ea1ce0264beb6bd25b995719cb50 module/media-runtime 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store 3bdcc734f6ea5f59320708cb2b87de9c5422a8b70745774ea0cc8a01cf656924 module/media-understanding -67ffe0251f234c9641ebedc61bfd2866b81e59c907757054517056476a6b8c92 module/media-understanding-runtime -eeb5522f22d133e3ecba1cb0625567f69f4311a7437abd1d04d2588cceae97c7 module/meeting-runtime +4e64c47aed06c23796a07f099b7ab52ee68feeb7c934c749c5cdcd7cb9ef6e7b module/media-understanding-runtime +57a3e01b3b8266113724381e5469e7ffe4ae9ab5ffcdbfdad9f36e611d4d1fd2 module/meeting-runtime 3312468e2e8f3423b765fac6bb17944b800ea2c84acffeb64342c99040b2f482 module/memory-core-host-engine-foundation -736ea5244232d7ab9bd7f56522110b6bf23d66328f938f34ae949d1c1146d1cd module/memory-host-core +1c649692f26a7a5789e1164135a215c2a5bf558607d1d47a58c1813a2203e647 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -a2e353787e32fc3782829b08446470405e0976e10c76e86ffb7b11dcc80460ac module/model-session-runtime -c1abc36d25082a8bf1182df28d263f5324be6f95531da298895c2c0f5161fb54 module/models-provider-runtime -49adf538a3a8dd6c0f731e7b830cae50205d7159693687f70d455ac8cd247b38 module/native-command-config-runtime +8a43f27e4b4cc71aa92d0fdde83c9374ad155f74bb2ec7db1d48559065a8772d module/model-session-runtime +022e85dd9f3b0b99b8483615ddeed8cd1c09b311528c8703c114147b674bfe15 module/models-provider-runtime +a3eaaf99e2ac5e83f0e14b83ae54a2d99488a6597df0c4421a0d58cdc1bd3fd7 module/native-command-config-runtime f6ffada942145ca2fcec4df90740b4578afa09e81d46cc09122a65d6e345fe02 module/native-command-registry a6b5532576fa4cfd609d0966927eec12806424f2e1efce9fe47d903dfeb8e4dd module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe b31f5d86904097993a55377fed7973cd298b0e1f49cade7e37a1e28f6e724108 module/plugin-config-runtime -ad5690877b439ac6c0b9dda3cf9717381806eb6dd73ac8f1feac7ea940a417aa module/plugin-entry -43eec9105fcc5705fa9b79a99091e439ba838750b3680f8e0162fe089c078f33 module/plugin-runtime -a7fa196e6eefce5fcf3cdfde6a0eb4a8a45f08af47aa7944eda541b6e6d85c4f module/provider-auth -c749ce6c84288cd3f1ed9f739281fade28220e739232daa83df4e0f94187279e module/provider-catalog-runtime +d24701eae0aab1ef746adc15e67e8ac7fbec4068992587411723fcfdc331add3 module/plugin-entry +acc0ff82ee5275e687f2b724f6f71fb6e156a0fc13ce5a035fea9c8428ad624a module/plugin-runtime +f525ba503cc72b64e90abfa28d9b701c2e930f8480b592e27452e96a8087d550 module/provider-auth +eac04d127c8993bd33d96dea16efcfaf264f19ee54eda61a6a1e4f94a880b529 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture -6bcb504093173b6e6197b0df7ded4522345e744c67d58d78d0f43d32a2ab7f80 module/question-gateway-runtime -e558b0ddc597bd5bc2bbcf2a404485676c66685583ecce7df552217a080c4219 module/reply-chunking -4363add8bc5efd6bc55b14c498a9679544d191aa0889ed5fe745c37c9238458e module/reply-dispatch-runtime +a6c6462326817540b9d654f0254bf561a340ba058851e958fd0367aaa9198b9a module/question-gateway-runtime +50e250d810a9936ae468710698a61e8e569f728f2f3f55fb57ea1e401b3a37be module/reply-chunking +a1a577bdafe4b772924d9bfb28fb27a904cfe86838454aba08d2429766639597 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history -d266216e54afb7e8a205bea13e82e0495b53d3b039cc2b795df3a286fc9515d9 module/reply-payload -89f36ce57892c19ee44123d14af87e4a1fc9843b970fc7c26328af6ca461e3f2 module/reply-runtime +321f9ecc06267499c7700c17f96d58bfd1e56d09fd765bb8ce4b6664427720ff module/reply-payload +e342be15c01980dc3d7a13e872951669837bdba8b67c19a15ee6ac7278c43778 module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk ad6c5c5b16e22f8b06994f69ecfafbc9a9622b07c14670f3c4e8ee81cf3b8c4d module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command @@ -118,34 +118,34 @@ ad6c5c5b16e22f8b06994f69ecfafbc9a9622b07c14670f3c4e8ee81cf3b8c4d module/routing 0570a20fac6020880900a0cf9a889d8f28f47b89d0e1d17862265b557236d440 module/runtime-config-snapshot bfe087e249a3c0adda4d24727220a65bcc159f53d6a4d6d8cd9b252627ef8e98 module/runtime-env 49e9b6a8195c89704eaa80656f176444af7cacbf639b759f41f2c78ae6bfcfd9 module/runtime-group-policy -baee8323f480c34d7ddf6fc70c8e650ab9d399bbcd8045f94d4a4006d5c90e2a module/runtime-store +589e8c0b4ff5c1a0d062d0bf75fb9d8a3a1aa3637b72c6b3839165abac13cee7 module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input b1b0229280d7cc4a880e4db75130714eb6adc9d6aea31240dc9a8ff6fda55219 module/secret-input-runtime 0cbc3908bd9e1d4527585023a1692eba6c0bfe82f107f49a926a8d1c79aee2a0 module/secret-ref-runtime -0ce7d494173690c245cb5dbd64119140d632cb7c0f520b0a8e235de2197b7c20 module/security-runtime -ca0db2dbca3eaae8d3a8648a0ee270af8c0d84852191c8f60670affc5ffdd0fe module/session-catalog -ae3caea5548c99cf2e16aa2bd7d4dd0562b7dd7c7d6ba89f5b23a993e0bcf224 module/session-discussion -5b186bdc779ee4fbd45c188078a2c306781ae0c746d41affaf43200b3941234e module/session-store-runtime -29a929d8bd0845de8bcb4d33cc7b8af7e5e9366a94a9c23c6d5013e3e0534d45 module/setup -533a6b5af4c984e3ec9bc57bd7685b2cbe421231195fa73a7cb1ef4d47e3a1e7 module/setup-runtime +efac9b2e9828a4d2f45794bfea8a9c0ce92578164f6012a9d2d83418d0bd7ba8 module/security-runtime +fdc9c7594598907ed77d3e183a9b5a9ecede8195b734ea0d0234bd62219318db module/session-catalog +fcd56f2eabae27000bf3aba999b55d0b889eaa5cb3b913675801d8a627f013af module/session-discussion +8d71e05167eb3301536e9f7e8fc14047791c28a4e32c014746db5b3dd0477242 module/session-store-runtime +124f36c843153cb31b85bbe061a145ee768251b82fc31ffdd0b9c7cab472f9b2 module/setup +a2b1f5da3a82d501171a88f759c9dd756286df8884703168da97d8cd4aa2b1c9 module/setup-runtime d0cb4c5abb7484352088f556c1ba7c7b147d7b57977b8246e5cb7187937768b7 module/setup-tools -f69a8cbdbd73557bff2c7c82c780bd2eb55b9949d2a7fb08c698bec4977eb6fa module/skill-commands-runtime -1868b2613210b60543d85370b87972c4f75cde3b216d2277cad5403c4b464c10 module/speech-settings -8409dc78249517ef77c97284f503938b7f485977cbb39fa8921b9cc0861a053b module/ssrf-policy -4062722a2282ca8535e8f208463f742c06e39f0fd2a38dee764d4875fff91b2d module/ssrf-runtime +41cb221e60e0285e00c926af872b222ed089f745c70d3c513476f96f6736de8b module/skill-commands-runtime +b33b9d7ef308e000fff0ad6052f19eeca94c9fe29d049cd4389059883a417592 module/speech-settings +614e35857fd3891aefd1a49955140e0e78c5bf9c4e3950268b9e28f4eeed6f9d module/ssrf-policy +b84d7ab7506041897a22e3cc5ba7f62e90fdcdbea91e38a71030b79cca2d77ed module/ssrf-runtime 3855f0a23281d21063762f5b3be2b7485c8bdb3b4cd1692de4f2a17d4eeae496 module/state-paths -cf07ea6f699ed1eb4bc8db1eed5dee2a018a2c583bd438134dfa1d24ee5b3e35 module/status-helpers +897b6f5b6748a6598f1953e395ba4092b539b0ec030d6e2ef9a6ff1206bd3585 module/status-helpers f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime -9a02a0744c0a73e575c8980cd0e4256866b6d436636061c7774e2e712636466c module/telegram-account +0b691f15873b74a12e2422ebfcb6f4dc567e8eb20fadf49ec72d3350e885c169 module/telegram-account aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking fcdaf13ecea269f1a471ba4fe14958f3c8e198eaa20f354546a648408ed74064 module/text-runtime -88cc8ab15e17398976dbacbb63de4ee1e5a567957d44446de6ef56cc141943d8 module/tool-plugin +901e27e3e508a95c0178795cca105d2d98f79c85f6b059a74a54807edca0219d module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results -2b21b9fcdee0805a7d5385053bb10a57faa67986b784b7123e0b5b16d46d93e4 module/tool-send +1aa65cefa239d5943f19c2cf2a19c37857b0b659452877fc457dbf627a28fff0 module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -316f631dc73e9afc30166562f18284f167a23b41ed5fd3aabd7862e2f09ee6ac module/webhook-ingress +822d5f574177366b2780040c1fc067fbd6279ed5b70703ec4e2a86294fd8869c module/webhook-ingress e3a199a9ce0b85d203e9e8a29b500db29c6b7af307e3145d0a311e29d598925b module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index a20056558598..b9584194d140 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -60,7 +60,7 @@ Use [`sessions_search`](/concepts/session-search) for exact full-text recall acr The owner-gated `sessions` tool exposes bounded self-service surfaces: -- `action: "patch"` changes the current session by default, or another visible session selected by `sessionKey`. It can set the label, sidebar icon, pin/archive state, model, and thinking level. +- `action: "patch"` changes the current session by default, or another visible session selected by `sessionKey`. It can set the label, pin/archive state, model, and thinking level. - `action: "reset"` resets another visible session selected by `sessionKey`. - `action: "delete"` first archives and then deletes the exact same generation of another visible session selected by `sessionKey`. By default its transcript is retained as a deleted archive; pass `deleteTranscript: false` to leave the transcript state untouched. Resetting or deleting the session currently running the tool is rejected. - `group_list`, `group_set`, `group_rename`, and `group_delete` manage the global ordered session-group catalog. `group_set` replaces the ordered name list rather than patching one entry. diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 9e0484a8bc8c..fc63e9bc9806 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -211,7 +211,6 @@ describe("lazy protocol validators", () => { label: "Label", category: "Category", boardFace: "dashboard", - icon: "name:spark", statusNote: "Working", attention: "hand", ttlMinutes: 30, diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 066dcbd49c1d..d9fd67bf99c3 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -5,7 +5,7 @@ export { readMissingScopeError, readMissingScopeErrorDetails, } from "./gateway-error-details.js"; -export * from "./session-icon.js"; +export * from "./session-agent-status.js"; export * from "./terminal-validators.js"; export { validateApprovalGetResult, diff --git a/packages/gateway-protocol/src/schema/sessions-patch.ts b/packages/gateway-protocol/src/schema/sessions-patch.ts index 49e65dc9f5e3..dc62cb15bbdf 100644 --- a/packages/gateway-protocol/src/schema/sessions-patch.ts +++ b/packages/gateway-protocol/src/schema/sessions-patch.ts @@ -1,6 +1,6 @@ import type { Static } from "typebox"; import { Type } from "typebox"; -import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../session-icon.js"; +import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../session-agent-status.js"; import { closedObject } from "./closed-object.js"; import { ErrorShapeSchema } from "./frames.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; @@ -13,11 +13,6 @@ const SessionsPatchMutationProperties = { /** 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:....", - }), - ), statusNote: Type.Optional( Type.Union([Type.String({ maxLength: 120 }), Type.Null()], { description: "Short expiring sidebar status note; null clears it and any declared attention.", diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index 119e4fe9cffb..b58e2554e8c4 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -57,7 +57,6 @@ export const SessionRowSchema = Type.Object( archivedBy: Type.Optional(SessionCreatedActorSchema), pinned: Type.Optional(Type.Boolean()), pinnedAt: Type.Optional(Type.Number()), - icon: Type.Optional(Type.String()), unread: Type.Optional(Type.Boolean()), lastReadAt: Type.Optional(Type.Number()), lastActivityAt: Type.Optional(Type.Number()), diff --git a/packages/gateway-protocol/src/session-agent-status.ts b/packages/gateway-protocol/src/session-agent-status.ts new file mode 100644 index 000000000000..233b7ac87488 --- /dev/null +++ b/packages/gateway-protocol/src/session-agent-status.ts @@ -0,0 +1,16 @@ +export const SESSION_AGENT_ATTENTION_ICON_IDS = [ + "hand", + "key", + "alert", + "flag", + "lock", + "hourglass", +] as const; + +export type SessionAgentAttentionIconId = (typeof SESSION_AGENT_ATTENTION_ICON_IDS)[number]; + +export type SessionAgentStatus = { + note: string; + expiresAt: number; + attention?: SessionAgentAttentionIconId; +}; diff --git a/packages/gateway-protocol/src/session-icon.test.ts b/packages/gateway-protocol/src/session-icon.test.ts deleted file mode 100644 index c6549af5fbd7..000000000000 --- a/packages/gateway-protocol/src/session-icon.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeSessionIconInput, parseSessionIcon } from "./session-icon.js"; - -describe("session icons", () => { - it("parses named and single-grapheme emoji forms", () => { - expect(parseSessionIcon("name:lobster")).toEqual({ kind: "named", name: "lobster" }); - expect(parseSessionIcon("🦞")).toEqual({ kind: "emoji", emoji: "🦞" }); - expect(parseSessionIcon("πŸ‘©πŸ½β€πŸ’»")).toEqual({ kind: "emoji", emoji: "πŸ‘©πŸ½β€πŸ’»" }); - expect(parseSessionIcon("name:Nope")).toBeNull(); - expect(parseSessionIcon("πŸ¦žπŸš€")).toBeNull(); - expect(parseSessionIcon("A")).toBeNull(); - }); - - it("trims inputs and canonicalizes safe SVG markup", () => { - expect( - normalizeSessionIconInput( - " svg: ", - ), - ).toEqual({ - ok: true, - value: - 'svg:', - }); - }); - - it.each([ - "svg:", - 'svg:', - 'svg:', - 'svg:', - "svg:", - "svg:", - 'svg:', - 'svg:', - ])("rejects hostile SVG: %s", (value) => { - expect(normalizeSessionIconInput(value).ok).toBe(false); - }); - - it("rejects SVG icons over 4096 bytes", () => { - const value = `svg:${"x".repeat(4096)}`; - expect(normalizeSessionIconInput(value)).toMatchObject({ ok: false }); - }); - - it("rejects SVG whose canonical entity re-encoding exceeds the byte cap", () => { - // 900 double quotes inside a single-quoted attribute fit the raw cap but - // reserialize as " (6 bytes each), overflowing the stored form. - const svg = `svg:`; - expect(new TextEncoder().encode(svg).byteLength).toBeLessThanOrEqual(4096); - expect(normalizeSessionIconInput(svg).ok).toBe(false); - }); -}); diff --git a/packages/gateway-protocol/src/session-icon.ts b/packages/gateway-protocol/src/session-icon.ts deleted file mode 100644 index e8ddcf3e802f..000000000000 --- a/packages/gateway-protocol/src/session-icon.ts +++ /dev/null @@ -1,372 +0,0 @@ -export type SessionIcon = - | { kind: "named"; name: string } - | { kind: "emoji"; emoji: string } - | { kind: "svg"; svg: string }; - -export const SESSION_AGENT_ATTENTION_ICON_IDS = [ - "hand", - "key", - "alert", - "flag", - "lock", - "hourglass", -] as const; - -export type SessionAgentAttentionIconId = (typeof SESSION_AGENT_ATTENTION_ICON_IDS)[number]; - -export type SessionAgentStatus = { - note: string; - expiresAt: number; - attention?: SessionAgentAttentionIconId; -}; - -export type SessionIconNormalizationResult = - | { ok: true; value: string } - | { ok: false; reason: string }; - -const NAMED_ICON_RE = /^[a-z0-9-]{1,32}$/; -// The wire contract intentionally requires Extended_Pictographic; regional -// indicator flags and keycaps do not qualify on their own. -const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u; -const SVG_PREFIX = "svg:"; -const SVG_MAX_BYTES = 4096; -const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); - -const SVG_ELEMENTS = new Set([ - "svg", - "g", - "path", - "circle", - "ellipse", - "rect", - "line", - "polyline", - "polygon", - "title", -]); - -const SVG_ATTRIBUTES = new Set([ - "viewBox", - "xmlns", - "d", - "fill", - "stroke", - "stroke-width", - "stroke-linecap", - "stroke-linejoin", - "cx", - "cy", - "r", - "rx", - "ry", - "x", - "y", - "x1", - "y1", - "x2", - "y2", - "width", - "height", - "points", - "opacity", - "fill-rule", - "transform", -]); - -const SVG_PAINT_RE = /^(?:none|currentColor|#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8})$/; -const SVG_NUMBER_SOURCE = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?"; -const SVG_NUMBER_RE = new RegExp(`^${SVG_NUMBER_SOURCE}$`); -const SVG_TRANSFORM_RE = new RegExp(`^([a-z]+)\\s*\\(([^)]*)\\)`); -const SVG_SUSPICIOUS_VALUE_RE = /(?:javascript|url\s*\(|data:|expression)/i; - -const XML_ENTITIES: Readonly> = { - "&": "&", - "<": "<", - ">": ">", - """: '"', - "'": "'", -}; - -function parseXmlEntities(value: string): string | null { - let result = ""; - let offset = 0; - while (offset < value.length) { - const ampersand = value.indexOf("&", offset); - if (ampersand < 0) { - return result + value.slice(offset); - } - result += value.slice(offset, ampersand); - const semicolon = value.indexOf(";", ampersand + 1); - if (semicolon < 0) { - return null; - } - const entity = value.slice(ampersand, semicolon + 1); - const decoded = XML_ENTITIES[entity]; - if (decoded === undefined) { - return null; - } - result += decoded; - offset = semicolon + 1; - } - return result; -} - -function escapeXmlText(value: string): string { - return value.replace(/[&<>]/g, (character) => { - if (character === "&") { - return "&"; - } - return character === "<" ? "<" : ">"; - }); -} - -function escapeXmlAttribute(value: string): string { - return escapeXmlText(value).replace(/"/g, """); -} - -function parseTransformNumbers(value: string): number | null { - const trimmed = value.trim(); - if (!trimmed) { - return 0; - } - const parts = trimmed.split(/[\s,]+/); - if (parts.some((part) => !SVG_NUMBER_RE.test(part))) { - return null; - } - return parts.length; -} - -function isValidTransform(value: string): boolean { - let remaining = value.trim(); - while (remaining) { - const match = SVG_TRANSFORM_RE.exec(remaining); - if (!match) { - return false; - } - const name = match[1]; - const count = parseTransformNumbers(match[2] ?? ""); - const validCount = - name === "translate" || name === "scale" - ? count === 1 || count === 2 - : name === "rotate" - ? count === 1 || count === 3 - : name === "matrix" - ? count === 6 - : false; - if (!validCount) { - return false; - } - remaining = remaining.slice(match[0].length).trimStart(); - if (remaining.startsWith(",")) { - remaining = remaining.slice(1).trimStart(); - } - } - return true; -} - -function isNameCharacter(character: string): boolean { - return /[A-Za-z0-9-]/.test(character); -} - -function sanitizeSvg(svg: string): string | null { - let offset = 0; - let rootSeen = false; - let rootClosed = false; - const stack: string[] = []; - const output: string[] = []; - - const skipWhitespace = () => { - while (offset < svg.length && /\s/.test(svg[offset] ?? "")) { - offset += 1; - } - }; - const readName = () => { - const start = offset; - while (offset < svg.length && isNameCharacter(svg[offset] ?? "")) { - offset += 1; - } - return svg.slice(start, offset); - }; - - while (offset < svg.length) { - if (svg[offset] !== "<") { - const nextTag = svg.indexOf("<", offset); - const end = nextTag < 0 ? svg.length : nextTag; - const rawText = svg.slice(offset, end); - const text = parseXmlEntities(rawText); - if (text === null || stack.length === 0 || rootClosed) { - if (rawText.trim()) { - return null; - } - } else if (text.trim()) { - if (stack.at(-1) !== "title") { - return null; - } - output.push(escapeXmlText(text)); - } - offset = end; - continue; - } - - offset += 1; - if (svg[offset] === "/") { - offset += 1; - const name = readName(); - skipWhitespace(); - if (!name || svg[offset] !== ">" || stack.at(-1) !== name) { - return null; - } - offset += 1; - stack.pop(); - output.push(``); - if (stack.length === 0) { - rootClosed = true; - } - continue; - } - - const name = readName(); - if (!SVG_ELEMENTS.has(name) || rootClosed || stack.at(-1) === "title") { - return null; - } - if (stack.length === 0) { - if (rootSeen || name !== "svg") { - return null; - } - rootSeen = true; - } else if (name === "svg") { - return null; - } - - const attributes: Array<{ name: string; value: string }> = []; - const attributeNames = new Set(); - let selfClosing = false; - let tagClosed = false; - while (offset < svg.length) { - skipWhitespace(); - if (svg.startsWith("/>", offset)) { - selfClosing = true; - tagClosed = true; - offset += 2; - break; - } - if (svg[offset] === ">") { - tagClosed = true; - offset += 1; - break; - } - const attributeName = readName(); - if ( - !SVG_ATTRIBUTES.has(attributeName) || - attributeNames.has(attributeName) || - attributeName.toLowerCase().startsWith("on") - ) { - return null; - } - attributeNames.add(attributeName); - skipWhitespace(); - if (svg[offset] !== "=") { - return null; - } - offset += 1; - skipWhitespace(); - const quote = svg[offset]; - if (quote !== '"' && quote !== "'") { - return null; - } - offset += 1; - const valueStart = offset; - while (offset < svg.length && svg[offset] !== quote) { - if (svg[offset] === "<") { - return null; - } - offset += 1; - } - if (svg[offset] !== quote) { - return null; - } - const rawValue = svg.slice(valueStart, offset); - offset += 1; - const value = parseXmlEntities(rawValue); - if (value === null || SVG_SUSPICIOUS_VALUE_RE.test(value)) { - return null; - } - const isRoot = stack.length === 0 && name === "svg"; - if ( - (attributeName === "xmlns" && (!isRoot || value !== "http://www.w3.org/2000/svg")) || - ((attributeName === "fill" || attributeName === "stroke") && !SVG_PAINT_RE.test(value)) || - (attributeName === "transform" && !isValidTransform(value)) - ) { - return null; - } - attributes.push({ name: attributeName, value }); - } - - if (!tagClosed || (name === "svg" && selfClosing)) { - return null; - } - const serializedAttributes = attributes - .map((attribute) => ` ${attribute.name}="${escapeXmlAttribute(attribute.value)}"`) - .join(""); - output.push(`<${name}${serializedAttributes}${selfClosing ? "/>" : ">"}`); - if (!selfClosing) { - stack.push(name); - } - } - - return rootSeen && rootClosed && stack.length === 0 ? output.join("") : null; -} - -function isEmoji(value: string): boolean { - return ( - value.length <= 16 && - EXTENDED_PICTOGRAPHIC_RE.test(value) && - Array.from(graphemeSegmenter.segment(value)).length === 1 - ); -} - -/** Parse a stored session icon form without sanitizing SVG markup. */ -export function parseSessionIcon(value: string): SessionIcon | null { - if (value.startsWith("name:")) { - const name = value.slice("name:".length); - return NAMED_ICON_RE.test(name) ? { kind: "named", name } : null; - } - if (value.startsWith(SVG_PREFIX)) { - const svg = value.slice(SVG_PREFIX.length); - return /^)/.test(svg) && svg.endsWith("") ? { kind: "svg", svg } : null; - } - return isEmoji(value) ? { kind: "emoji", emoji: value } : null; -} - -/** Validate and canonicalize a session icon before it enters durable state. */ -export function normalizeSessionIconInput(value: string): SessionIconNormalizationResult { - const trimmed = value.trim(); - if (!trimmed) { - return { ok: false, reason: "session icon is empty" }; - } - if (trimmed.startsWith(SVG_PREFIX)) { - if (new TextEncoder().encode(trimmed).byteLength > SVG_MAX_BYTES) { - return { ok: false, reason: `session SVG icon exceeds ${SVG_MAX_BYTES} bytes` }; - } - const parsed = parseSessionIcon(trimmed); - if (!parsed || parsed.kind !== "svg") { - return { ok: false, reason: "invalid session SVG icon shape" }; - } - const sanitized = sanitizeSvg(parsed.svg); - if (!sanitized) { - return { ok: false, reason: "session SVG icon contains disallowed markup" }; - } - const canonical = `${SVG_PREFIX}${sanitized}`; - // Entity re-encoding (e.g. " -> ") can grow the canonical form past - // the raw-input cap; the stored bytes are what the bound protects. - if (new TextEncoder().encode(canonical).byteLength > SVG_MAX_BYTES) { - return { ok: false, reason: `session SVG icon exceeds ${SVG_MAX_BYTES} bytes` }; - } - return { ok: true, value: canonical }; - } - const parsed = parseSessionIcon(trimmed); - if (!parsed) { - return { ok: false, reason: "session icon must be one emoji, name:, or svg:" }; - } - return { ok: true, value: trimmed }; -} diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 267dc021371e..94fe91c228c5 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -1,3 +1,4 @@ +import { Type } from "typebox"; import { lazyCompile as compile } from "./protocol-validator.js"; import * as S from "./schema-modules.js"; import type { @@ -218,7 +219,18 @@ export const validateSessionsViewerPresenceSetParams = compile( S.SessionsViewerPresenceSetParamsSchema, ); export const validateSessionsAbortParams = compile(S.SessionsAbortParamsSchema); -export const validateSessionsPatchParams = compile(S.SessionsPatchParamsSchema); +// Keep the current generated/client contract icon-free while accepting the +// retired field from beta v4 clients at the raw Gateway validation boundary. +const SessionsPatchV4CompatibilityParamsSchema = Type.Object( + { + ...S.SessionsPatchParamsSchema.properties, + icon: Type.Optional(Type.Union([S.NonEmptyString, Type.Null()])), + }, + { additionalProperties: false }, +); +export const validateSessionsPatchParams = compile( + SessionsPatchV4CompatibilityParamsSchema, +); export const validateSessionsPatchManyParams = compile(S.SessionsPatchManyParamsSchema); export const validateSessionsPluginPatchParams = compile(S.SessionsPluginPatchParamsSchema); export const validateSessionsResetParams = compile(S.SessionsResetParamsSchema); diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 989e451d0e6f..c880e20ca3dc 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1306,7 +1306,6 @@ async function createChatPickerScenario( status: "running", childSessions: ["agent:main:subagent:tax-receipts"], pinned: true, - icon: "name:spark", }), sessionRow("agent:main:production-export", "Production export", baseTime - 75_000, { category: "Research", @@ -1337,7 +1336,6 @@ async function createChatPickerScenario( execCwd: "/Users/peter/Projects", execNode: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", pinned: true, - icon: "πŸ› οΈ", }), sessionRow("agent:main:whatsapp:group:family", "Family", baseTime - 90_000, { kind: "group", diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index 21917dc5c4cd..4df47a5719d2 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -611,23 +611,6 @@ describe("sessions tool", () => { expect(callGateway).toHaveBeenCalledTimes(4); }); - it("patches and clears a sidebar icon", async () => { - const callGateway = vi.fn(async () => ({ ok: true })); - const tool = createSessionsTool({ - agentSessionKey: "agent:main:main", - config: {}, - callGateway: callGateway as never, - }); - - await tool.execute("patch-icon", { action: "patch", icon: " name:lobster " }); - await tool.execute("clear-icon", { action: "patch", icon: "" }); - - expect(callGateway.mock.calls).toEqual([ - ["sessions.patch", { key: "agent:main:main", icon: "name:lobster" }], - ["sessions.patch", { key: "agent:main:main", icon: null }], - ]); - }); - it("returns a bounded acknowledgement instead of the patched session entry", async () => { const callGateway = vi.fn(async () => ({ ok: true, @@ -651,18 +634,16 @@ describe("sessions tool", () => { const result = await tool.execute("patch-sidebar", { action: "patch", label: "Movies", - icon: "name:film", }); expect(callGateway).toHaveBeenCalledWith("sessions.patch", { key: "agent:main:main", label: "Movies", - icon: "name:film", }); expect(result.details).toEqual({ status: "updated", sessionKey: "agent:main:main", - updated: ["label", "icon"], + updated: ["label"], }); const text = (result.content[0] as { text?: string } | undefined)?.text ?? ""; expect(text).not.toContain('"entry"'); diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index 784a50a09696..10ef50957f34 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -2,7 +2,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; import type { SessionsPatchResult } from "../../../packages/gateway-protocol/src/index.js"; -import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../../../packages/gateway-protocol/src/session-icon.js"; +import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../../../packages/gateway-protocol/src/session-agent-status.js"; import { getRuntimeConfig } from "../../config/config.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import { resolveStorePath } from "../../config/sessions/paths.js"; @@ -91,12 +91,6 @@ const SessionsToolSchema = Type.Object( label: Type.Optional( Type.String({ description: "Sidebar title override. Empty string clears it." }), ), - icon: Type.Optional( - Type.String({ - description: - "Sidebar icon: an emoji, name:, or svg: you draw yourself (tiny, sanitized). Empty string removes it.", - }), - ), statusNote: Type.Optional( Type.String({ maxLength: 120, @@ -246,7 +240,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool label: "Sessions", name: "sessions", description: - "Session settings, reset, delete, and groups: patch label/icon/status, pin, archive/restore, model/thinking override; reset/delete visible sessions; group_list/group_set/group_rename/group_delete.", + "Session settings, reset, delete, and groups: patch label/status, pin, archive/restore, model/thinking override; reset/delete visible sessions; group_list/group_set/group_rename/group_delete.", parameters: SessionsToolSchema, execute: async (_toolCallId, rawArgs) => { const params = rawArgs as Record; @@ -323,7 +317,6 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool const patch = { key, ...(params.label !== undefined ? { label: readClearableString(params, "label") } : {}), - ...(params.icon !== undefined ? { icon: readClearableString(params, "icon") } : {}), ...(params.statusNote !== undefined ? { statusNote: readClearableString(params, "statusNote") } : {}), diff --git a/src/commands/doctor-session-canonical-keys.test.ts b/src/commands/doctor-session-canonical-keys.test.ts index 106bded77475..8587b3f19760 100644 --- a/src/commands/doctor-session-canonical-keys.test.ts +++ b/src/commands/doctor-session-canonical-keys.test.ts @@ -808,7 +808,6 @@ describe("doctor canonical session-key repair", () => { category: "investigation", chatType: "group", endedAt: 24, - icon: "archive", label: "Recovered metadata", displayName: "Projected display name", lastActivityAt: 29, @@ -825,6 +824,7 @@ describe("doctor canonical session-key repair", () => { startedAt: 23, status: "failed", }); + expect(repaired).not.toHaveProperty("icon"); expect(deliveryContextFromSession(repaired)).toEqual({ accountId: "work", channel: "matrix", diff --git a/src/config/sessions/session-accessor.sqlite-canonical-repair.ts b/src/config/sessions/session-accessor.sqlite-canonical-repair.ts index 98f6d4d95658..ba13b8a06d4b 100644 --- a/src/config/sessions/session-accessor.sqlite-canonical-repair.ts +++ b/src/config/sessions/session-accessor.sqlite-canonical-repair.ts @@ -275,7 +275,6 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry { ...(row.label ? { label: row.label } : {}), ...(row.display_name ? { displayName: row.display_name } : {}), ...(row.category ? { category: row.category } : {}), - ...(row.icon ? { icon: row.icon } : {}), ...(row.pinned_at !== null ? { pinnedAt: row.pinned_at } : {}), ...(row.archived_at !== null ? { archivedAt: row.archived_at } : {}), ...(row.last_read_at !== null ? { lastReadAt: row.last_read_at } : {}), diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index afcb3ca2a54a..331ba6a5d927 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -682,7 +682,8 @@ export function writeSessionEntry( label: sessionNode.label, display_name: sessionNode.display_name, category: sessionNode.category, - icon: sessionNode.icon, + // Clear any retired custom icon without requiring a schema-version migration. + icon: null, pinned_at: sessionNode.pinned_at, archived_at: sessionNode.archived_at, last_read_at: sessionNode.last_read_at, diff --git a/src/config/sessions/session-accessor.sqlite-session-row.ts b/src/config/sessions/session-accessor.sqlite-session-row.ts index ec5e2a5a2ada..a5de425d5582 100644 --- a/src/config/sessions/session-accessor.sqlite-session-row.ts +++ b/src/config/sessions/session-accessor.sqlite-session-row.ts @@ -115,7 +115,8 @@ export function bindSqliteSessionNode(params: { label: normalizeSqliteText(params.entry.label), display_name: normalizeSqliteText(params.entry.displayName), category: normalizeSqliteText(params.entry.category), - icon: normalizeSqliteText(params.entry.icon), + // The retired custom-icon column remains nullable until a future schema-version migration. + icon: null, pinned_at: finiteSqliteNumber(params.entry.pinnedAt), archived_at: finiteSqliteNumber(params.entry.archivedAt), last_read_at: finiteSqliteNumber(params.entry.lastReadAt), diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index 248506efc12e..b9afed443013 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -39,6 +39,7 @@ function normalizeOptionalTimestamp(value: unknown): number | undefined { /** Removes retired runtime locator fields before a session entry is persisted or returned. */ export function projectCanonicalSessionEntryShape(value: Record): SessionEntry { const { + icon: _retiredIcon, sessionFile: _retiredSessionFile, transcriptPath: _retiredTranscriptPath, pendingFinalDeliveryCreatedAt, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 6eb10fc77f92..53f19b19f712 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -9,7 +9,7 @@ import { normalizeOptionalString, type FastMode } from "@openclaw/normalization- import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js"; import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; -import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; +import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-agent-status.js"; 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"; @@ -344,8 +344,6 @@ type SessionEntryCore = SessionRestartRecoveryState & archivedBy?: SessionCreatedActor; /** Timestamp (ms) when the session was pinned for quick access. */ pinnedAt?: number; - /** Custom sidebar icon in the format accepted by the gateway protocol session-icon helper. */ - icon?: string; /** Timestamp (ms) when an operator client last marked the session read. */ lastReadAt?: number; /** Agent-declared sidebar presence; projection drops it after expiresAt. */ diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 34e1988f1c00..f6358fa00b80 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -326,7 +326,6 @@ describe("method scope resolution", () => { key: "agent:main:ios-1", label: "Trip planning", boardFace: "dashboard", - icon: "name:spark", pinned: true, archived: false, }), diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts index 9950d76b54ae..f31439cd5576 100644 --- a/src/gateway/server-methods/sessions-mutations.ts +++ b/src/gateway/server-methods/sessions-mutations.ts @@ -48,14 +48,18 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateSessionsPatchParams, "sessions.patch", respond)) { return; } - const key = requireSessionKey(params.key, respond); + // Beta v4 clients may still send the retired icon field. Drop it at the + // Gateway boundary so it cannot re-enter session state or patch hooks. + const canonicalParams = { ...params } as typeof params & { icon?: unknown }; + delete canonicalParams.icon; + const key = requireSessionKey(canonicalParams.key, respond); if (!key) { return; } const executed = await executeSessionPatch({ client, context, - patch: { ...params, key }, + patch: { ...canonicalParams, key }, sessionMutationAuthorization, }); if (!executed.ok) { diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 157fae88660f..b581bfd0c02c 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -1025,26 +1025,6 @@ test("sessions.changed mutation events include session management metadata", asy pinnedAt: null, }); - const icon = await invokeSessionsPatch({ - key: "discord:group:dev", - icon: "name:spark", - }); - expectChangedBroadcast(icon.broadcastToConnIds, { - sessionKey: "agent:main:discord:group:dev", - reason: "patch", - icon: "name:spark", - }); - - const iconCleared = await invokeSessionsPatch({ - key: "discord:group:dev", - icon: null, - }); - expectChangedBroadcast(iconCleared.broadcastToConnIds, { - sessionKey: "agent:main:discord:group:dev", - reason: "patch", - icon: null, - }); - const unread = await invokeSessionsPatch({ key: "discord:group:dev", unread: true, diff --git a/src/gateway/server.sessions.reset-cleanup.test.ts b/src/gateway/server.sessions.reset-cleanup.test.ts index f1bd521efc79..a91c3506a0d0 100644 --- a/src/gateway/server.sessions.reset-cleanup.test.ts +++ b/src/gateway/server.sessions.reset-cleanup.test.ts @@ -1046,7 +1046,6 @@ test("sessions.reset preserves explicit responseUsage preference across session main: sessionStoreEntry("sess-main", { responseUsage: "tokens", pinnedAt: 123, - icon: "name:spark", }), }, }); @@ -1054,11 +1053,10 @@ test("sessions.reset preserves explicit responseUsage preference across session const reset = await directSessionReq<{ ok: true; key: string; - entry: { sessionId: string; responseUsage?: string; pinnedAt?: number; icon?: string }; + entry: { sessionId: string; responseUsage?: string; pinnedAt?: number }; }>("sessions.reset", { key: "main" }); expect(reset.ok).toBe(true); expect(reset.payload?.entry.responseUsage).toBe("tokens"); expect(reset.payload?.entry.pinnedAt).toBe(123); - expect(reset.payload?.entry.icon).toBe("name:spark"); }); diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 7d03b2b103ae..96abcdcbf660 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -58,6 +58,29 @@ async function loadTranscriptRows(params: { }); } +test("sessions.patch accepts and discards the retired beta icon field", async () => { + const { storePath } = await createSessionStoreDir(); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const patched = await directSessionHandlerReq<{ + entry: Record; + }>("sessions.patch", { + key: "agent:main:main", + icon: "πŸ§ͺ", + }); + + expect(patched.ok).toBe(true); + expect(patched.payload?.entry).not.toHaveProperty("icon"); + expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).not.toHaveProperty("icon"); +}); + test("lists and patches session store via sessions.* RPC", async () => { const { storePath } = await createSessionStoreDir(); const now = Date.now(); @@ -337,22 +360,12 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(pinned.ok).toBe(true); expect(pinned.payload?.entry.pinnedAt).toEqual(expect.any(Number)); - const iconPatched = await directSessionReq<{ - entry: { icon?: string }; - }>("sessions.patch", { - key: "agent:main:subagent:one", - icon: "name:spark", - }); - expect(iconPatched.ok).toBe(true); - expect(iconPatched.payload?.entry.icon).toBe("name:spark"); - const pinnedList = await directSessionReq<{ - sessions: Array<{ key: string; pinned?: boolean; icon?: string }>; + sessions: Array<{ key: string; pinned?: boolean }>; }>("sessions.list", {}); expect(pinnedList.payload?.sessions[0]).toMatchObject({ key: "agent:main:subagent:one", pinned: true, - icon: "name:spark", }); const archived = await directSessionReq<{ diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index c157a84ac7df..fa1541e40b42 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -42,7 +42,6 @@ export function buildGatewaySessionEventFields(params: { archivedBy: sessionRow.archivedBy ?? null, pinned: sessionRow.pinned ?? false, pinnedAt: sessionRow.pinnedAt ?? null, - icon: sessionRow.icon ?? null, unread: sessionRow.unread ?? false, lastReadAt: sessionRow.lastReadAt, agentStatus: sessionRow.agentStatus ?? null, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 2f9e5844da86..928cb672d35a 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1559,7 +1559,6 @@ export async function performGatewaySessionReset(params: { : currentEntry?.execCwd, responseUsage: currentEntry?.responseUsage, pinnedAt: currentEntry?.pinnedAt, - icon: currentEntry?.icon, // Resets should keep the user's explicit selection, but clear any // temporary fallback model that was pinned during the previous run. ...resetPreservedSelection, diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 4ecdfc3110ec..67c445f6b611 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -442,7 +442,6 @@ export function buildGatewaySessionRow(params: { archivedBy: projectSessionActor(entry?.archivedBy, rowContext?.userProfileIdentityById), pinned: entry?.pinnedAt !== undefined, pinnedAt: entry?.pinnedAt, - icon: entry?.icon, unread: deriveSessionUnread(entry), lastReadAt: entry?.lastReadAt, agentStatus, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 39b6d7edc219..193a0715fa98 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -400,7 +400,7 @@ describe("gateway session utils", () => { const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); const store: Record = { recent: { sessionId: "recent", updatedAt: 30 }, - pinned: { sessionId: "pinned", updatedAt: 10, pinnedAt: 40, icon: "name:spark" }, + pinned: { sessionId: "pinned", updatedAt: 10, pinnedAt: 40 }, archived: { sessionId: "archived", updatedAt: 20, archivedAt: 50 }, } satisfies Record; @@ -409,7 +409,6 @@ describe("gateway session utils", () => { expect(active.sessions[0]).toMatchObject({ pinned: true, pinnedAt: 40, - icon: "name:spark", archived: false, }); diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index e7b2b4e3da52..23309401120e 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -108,7 +108,6 @@ export type GatewaySessionRow = { archivedBy?: SessionEntry["archivedBy"]; pinned?: boolean; pinnedAt?: number; - icon?: string; unread?: boolean; lastReadAt?: number; agentStatus?: SessionEntry["agentStatus"]; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index d4f0998e9c93..6f5991af99b5 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -636,50 +636,6 @@ describe("gateway sessions patch", () => { expect(entry.category).toBe("Research"); }); - test("canonicalizes and clears session icons", async () => { - const icon = expectPatchOk( - await runPatch({ - store: mainStoreEntry({}), - patch: { - key: MAIN_SESSION_KEY, - icon: " svg: ", - }, - }), - ); - expect(icon.icon).toBe( - 'svg:', - ); - - const cleared = expectPatchOk( - await runPatch({ - store: mainStoreEntry({ icon: "🦞" }), - patch: { key: MAIN_SESSION_KEY, icon: null }, - }), - ); - expect(cleared.icon).toBeUndefined(); - }); - - test.each([ - ["script", "svg:"], - ["event handler", 'svg:'], - [ - "xlink href", - 'svg:', - ], - ["URL paint", 'svg:'], - ["DOCTYPE", "svg:"], - ["oversized payload", `svg:${"x".repeat(4096)}`], - ["double root", "svg:"], - ])("rejects hostile session SVG icons: %s", async (_label, icon) => { - expectPatchError( - await runPatch({ - store: mainStoreEntry({}), - patch: { key: MAIN_SESSION_KEY, icon }, - }), - "invalid icon", - ); - }); - test("rejects empty category", async () => { expectPatchError( await runPatch({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 97b6649cfd7d..4616513e9d38 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -8,7 +8,6 @@ import { ErrorCodes, type ErrorShape, errorShape, - normalizeSessionIconInput, type SessionCreatedActor, type SessionsPatchParams, } from "../../packages/gateway-protocol/src/index.js"; @@ -293,19 +292,6 @@ export async function projectSessionsPatchEntry(params: { next.boardFace = patch.boardFace; } - if ("icon" in patch) { - const raw = patch.icon; - if (raw === null) { - delete next.icon; - } else if (raw !== undefined) { - const normalized = normalizeSessionIconInput(raw); - if (!normalized.ok) { - return invalid(`invalid icon: ${normalized.reason}`); - } - next.icon = normalized.value; - } - } - if ("statusNote" in patch || "attention" in patch || "ttlMinutes" in patch) { const rawNote = patch.statusNote; const rawAttention = patch.attention; diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index e439eb0e3490..6f18ca94f588 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -311,6 +311,11 @@ describe("plugin session extension SessionEntry projection", () => { description: "retired transcript locator", sessionEntrySlotKey: "transcriptPath", }); + api.registerSessionExtension({ + namespace: "custom-icon", + description: "retired custom icon", + sessionEntrySlotKey: "icon", + }); api.registerSessionExtension({ namespace: "pending-final-text", description: "retired pending-final field", @@ -343,6 +348,10 @@ describe("plugin session extension SessionEntry projection", () => { pluginId: "slot-collision", message: "sessionEntrySlotKey is reserved by SessionEntry: transcriptPath", }, + { + pluginId: "slot-collision", + message: "sessionEntrySlotKey is reserved by SessionEntry: icon", + }, { pluginId: "slot-collision", message: "sessionEntrySlotKey is reserved by SessionEntry: pendingFinalDeliveryText", diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 0d881570a014..a8193ae7dcb1 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -20,7 +20,6 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "archivedAt", "archivedBy", "pinnedAt", - "icon", "lastReadAt", "agentStatus", "observerDigest", @@ -184,6 +183,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set([ + "icon", "channel", "origin", "route", diff --git a/src/sessions/session-agent-status.ts b/src/sessions/session-agent-status.ts index 46430a4e2582..7a3c5f8fbee3 100644 --- a/src/sessions/session-agent-status.ts +++ b/src/sessions/session-agent-status.ts @@ -3,7 +3,7 @@ import { SESSION_AGENT_ATTENTION_ICON_IDS, type SessionAgentAttentionIconId, type SessionAgentStatus, -} from "../../packages/gateway-protocol/src/session-icon.js"; +} from "../../packages/gateway-protocol/src/session-agent-status.js"; import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; const SESSION_AGENT_STATUS_NOTE_MAX_CHARS = 120; diff --git a/src/shared/session-method-scopes.ts b/src/shared/session-method-scopes.ts index bc6ff3fbffcc..c0af967b874a 100644 --- a/src/shared/session-method-scopes.ts +++ b/src/shared/session-method-scopes.ts @@ -7,13 +7,16 @@ const SESSIONS_PATCH_WRITE_SCOPE_MUTATIONS: ReadonlySet = new Set([ "label", "category", "boardFace", - "icon", "pinned", "archived", "unread", "model", ]); +// Beta v4 clients may still send this ignored field to sessions.patch. It is +// not a sessions.patchMany mutation and must not gain admin scope while retiring. +const SESSIONS_PATCH_RETIRED_COMPATIBILITY_FIELDS: ReadonlySet = new Set(["icon"]); + const SESSIONS_PATCH_WRITE_SCOPE_ENVELOPE_FIELDS: ReadonlySet = new Set([ "key", "agentId", @@ -37,7 +40,8 @@ function resolveSessionsPatchRequiredScope(params: unknown): SessionMutationOper return Object.keys(params).every( (key) => SESSIONS_PATCH_WRITE_SCOPE_ENVELOPE_FIELDS.has(key) || - SESSIONS_PATCH_WRITE_SCOPE_MUTATIONS.has(key), + SESSIONS_PATCH_WRITE_SCOPE_MUTATIONS.has(key) || + SESSIONS_PATCH_RETIRED_COMPATIBILITY_FIELDS.has(key), ) ? "operator.write" : "operator.admin"; diff --git a/src/state/openclaw-agent-db-session-nodes-migration.ts b/src/state/openclaw-agent-db-session-nodes-migration.ts index a3f1884523ad..c93596d55014 100644 --- a/src/state/openclaw-agent-db-session-nodes-migration.ts +++ b/src/state/openclaw-agent-db-session-nodes-migration.ts @@ -100,7 +100,7 @@ function backfillSessionNodes(db: DatabaseSync): void { ${jsonText("$.label")}, ${jsonText("$.displayName")}, ${jsonText("$.category")}, - ${jsonText("$.icon")}, + NULL, ${jsonNumber("$.pinnedAt")}, ${jsonNumber("$.archivedAt")}, ${jsonNumber("$.lastReadAt")}, diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index 1a231ec0a813..4e6a12de14a4 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -1382,7 +1382,7 @@ describe("openclaw agent database", () => { label: "Rich label", display_name: "Rich display", category: "work", - icon: "hammer", + icon: null, pinned_at: 12, archived_at: 13, last_read_at: 14, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index cb6f460e986f..3ce737ae03eb 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -10,7 +10,7 @@ import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/lo import type { SessionRow } from "../../../packages/gateway-protocol/src/schema/sessions-row.js"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { PresenceEntry as ProtocolPresenceEntry } from "../../../packages/gateway-protocol/src/schema/snapshot.js"; -import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; +import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-agent-status.js"; import type { SessionGoal } from "../../../src/config/sessions/types.js"; import type { CronJobBase } from "../../../src/cron/types-shared.js"; import type { CronPayload as CoreCronPayload } from "../../../src/cron/types.js"; diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index 7ce326e30445..b8373d9816a8 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -146,7 +146,6 @@ export function buildSidebarSessionNavigationState(input: { archived: row.archived === true, visibility: row.visibility, draftOwnedBySelf: isSidebarDraftOwnedBySelf(row, context?.gateway.snapshot.selfUser?.id), - icon: row.icon, category: normalizeOptionalString(row.category), boardFace: row.boardFace, channel: channelInfo.channel, diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index e6364d6d39b5..295ae287ad0e 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -2,7 +2,7 @@ import type { SessionCatalogPullRequestSummary } from "../../../packages/gateway import type { SessionVisibility } from "../../../packages/gateway-protocol/src/schema/sessions-sharing.js"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { SessionCreatedActor } from "../../../packages/gateway-protocol/src/schema/sessions.js"; -import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-icon.js"; +import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-agent-status.js"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { SessionRunStatus } from "../api/types.ts"; import type { RouteId } from "../app-route-paths.ts"; @@ -73,7 +73,6 @@ export type SidebarRecentSession = { archived?: boolean; visibility?: SessionVisibility; draftOwnedBySelf?: boolean; - icon?: string; category?: string; boardFace?: BoardFace; channel?: string; @@ -187,7 +186,6 @@ export type SidebarSessionPatch = { unread?: boolean; label?: string | null; category?: string | null; - icon?: string | null; }; export const SIDEBAR_AGENT_SESSION_LIST_LIMIT = 60; diff --git a/ui/src/components/session-attention-controller.ts b/ui/src/components/session-attention-controller.ts index 97b8c7b10cf8..e6674f44d3ed 100644 --- a/ui/src/components/session-attention-controller.ts +++ b/ui/src/components/session-attention-controller.ts @@ -1,5 +1,5 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; -import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; +import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-agent-status.js"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { GatewaySessionRow } from "../api/types.ts"; import type { RouteId } from "../app-route-paths.ts"; diff --git a/ui/src/components/session-attention-icon-registry.ts b/ui/src/components/session-attention-icon-registry.ts new file mode 100644 index 000000000000..baa3c36c6d5b --- /dev/null +++ b/ui/src/components/session-attention-icon-registry.ts @@ -0,0 +1,16 @@ +import type { TemplateResult } from "lit"; +import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-agent-status.js"; +import { icons } from "./icons.ts"; + +const SESSION_ATTENTION_ICON_REGISTRY = { + hand: icons.hand, + key: icons.key, + alert: icons.alertTriangle, + flag: icons.flag, + lock: icons.lock, + hourglass: icons.hourglass, +} as const satisfies Record; + +export function resolveSessionAttentionIcon(icon: SessionAgentAttentionIconId): TemplateResult { + return SESSION_ATTENTION_ICON_REGISTRY[icon]; +} diff --git a/ui/src/components/session-attention-presentation.ts b/ui/src/components/session-attention-presentation.ts index 7545e5e28a37..b0bf33686a0c 100644 --- a/ui/src/components/session-attention-presentation.ts +++ b/ui/src/components/session-attention-presentation.ts @@ -2,7 +2,7 @@ import { html, nothing } from "lit"; import { t } from "../i18n/index.ts"; import type { SidebarRecentSession, SidebarSessionAttention } from "./app-sidebar-session-types.ts"; import { icons } from "./icons.ts"; -import { resolveSessionAttentionIcon } from "./session-icon-registry.ts"; +import { resolveSessionAttentionIcon } from "./session-attention-icon-registry.ts"; export function renderSessionAttentionIcon(attention: SidebarSessionAttention) { if (attention.kind === "none") { diff --git a/ui/src/components/session-icon-registry.ts b/ui/src/components/session-icon-registry.ts deleted file mode 100644 index 495e477d1129..000000000000 --- a/ui/src/components/session-icon-registry.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { html, type TemplateResult } from "lit"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -// Module import, not the protocol barrel: the barrel pulls TypeBox schemas -// into the Control UI startup bundle and blows the perf budget. -import { - normalizeSessionIconInput, - parseSessionIcon, - type SessionAgentAttentionIconId, -} from "../../../packages/gateway-protocol/src/session-icon.js"; -import { icons } from "./icons.ts"; - -const SESSION_ICON_REGISTRY = { - bot: icons.bot, - claw: icons.claw, - spark: icons.spark, - bug: icons.bug, - book: icons.book, - bookmark: icons.bookmark, - zap: icons.zap, - brain: icons.brain, - camera: icons.camera, - globe: icons.globe, - sun: icons.sun, - moon: icons.moon, - hand: icons.hand, - key: icons.key, - alert: icons.alertTriangle, - flag: icons.flag, - lock: icons.lock, - hourglass: icons.hourglass, -} as const satisfies Record; - -type CuratedSessionIconId = keyof typeof SESSION_ICON_REGISTRY; -export const CURATED_SESSION_ICON_IDS = Object.keys( - SESSION_ICON_REGISTRY, -) as CuratedSessionIconId[]; - -function renderNamedIcon(icon: TemplateResult): TemplateResult { - return html`${icon}`; -} - -export function resolveSessionIcon(icon: string | undefined): TemplateResult { - if (!icon) { - return renderNamedIcon(icons.messageSquare); - } - const parsed = parseSessionIcon(icon); - if (parsed?.kind === "named") { - const namedIcon = Object.hasOwn(SESSION_ICON_REGISTRY, parsed.name) - ? SESSION_ICON_REGISTRY[parsed.name as CuratedSessionIconId] - : undefined; - return renderNamedIcon(namedIcon ?? icons.messageSquare); - } - if (parsed?.kind === "emoji") { - return html`${parsed.emoji}`; - } - if (parsed?.kind === "svg") { - const normalized = normalizeSessionIconInput(icon); - const sanitized = normalized.ok ? parseSessionIcon(normalized.value) : null; - if (sanitized?.kind === "svg") { - return html`${unsafeHTML(sanitized.svg)}`; - } - } - return renderNamedIcon(icons.messageSquare); -} - -export function resolveSessionAttentionIcon(icon: SessionAgentAttentionIconId): TemplateResult { - return SESSION_ICON_REGISTRY[icon]; -} diff --git a/ui/src/components/session-leading-indicator.ts b/ui/src/components/session-leading-indicator.ts index 31a23198062c..a1c31d8a55ec 100644 --- a/ui/src/components/session-leading-indicator.ts +++ b/ui/src/components/session-leading-indicator.ts @@ -12,7 +12,6 @@ import { renderSessionUnreadBadge, type SessionGlyphContent, } from "./session-glyph.ts"; -import { resolveSessionIcon } from "./session-icon-registry.ts"; import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts"; import { renderSessionOwnerChip, type SessionCreatedActor } from "./session-owner-chip.ts"; @@ -129,7 +128,7 @@ export function renderSessionLeadingState( running, leadingIndicator: renderSessionGlyph({ content: html`${icons.messageSquare}`, running, badge: renderGlyphBadge(session, pullRequestState), @@ -174,7 +173,7 @@ export function renderSessionLeadingState( running, leadingIndicator: renderSessionGlyph({ content: html`${icons.messageSquare}`, running: false, }), diff --git a/ui/src/components/session-menu.test.ts b/ui/src/components/session-menu.test.ts index e212363349c1..d7866c6e444d 100644 --- a/ui/src/components/session-menu.test.ts +++ b/ui/src/components/session-menu.test.ts @@ -11,7 +11,6 @@ type SessionMenuData = { unread: boolean; archived: boolean; category: string | null; - icon?: string; }; type SessionMenuElement = HTMLElement & { anchor: { x: number; y: number }; @@ -32,7 +31,6 @@ afterEach(() => { async function mountMenu( options: { session?: Partial; - canOpenChat?: boolean; work?: SessionMenuWork | null; workboard?: { captured: boolean; busy: boolean } | null; archiveAllowed?: boolean; @@ -73,7 +71,6 @@ async function mountMenu( (session.archived || (options.archiveAllowed ?? true))} .cloudWorkerStopAllowed=${options.cloudWorkerStopAllowed ?? false} .groups=${options.groups ?? []} - .canOpenChat=${options.canOpenChat ?? true} .work=${options.work ?? null} .workboard=${options.workboard === undefined ? { captured: false, busy: false } @@ -113,12 +110,6 @@ function menuItem(menu: ParentNode, label: string): SessionMenuItem { return item; } -async function openIconPicker(menu: SessionMenuElement) { - menuItem(menu, "Change icon").click(); - await menu.updateComplete; - await Promise.resolve(); -} - describe("session menu", () => { it("disables only denied mutation actions and ignores forced selection", async () => { const onAction = vi.fn<(action: SessionMenuAction) => void>(); @@ -129,11 +120,9 @@ describe("session menu", () => { "toggle-pin": "This action requires operator.write access.", }, }); - const openChat = menuItem(menu, "Open chat"); const pin = menuItem(menu, "Pin session"); const deleteItem = menuItem(menu, "Delete…"); - expect(openChat.disabled).toBe(false); expect(pin.disabled).toBe(true); expect(pin.getAttribute("title")).toBe("This action requires operator.write access."); expect(deleteItem.disabled).toBe(true); @@ -157,9 +146,7 @@ describe("session menu", () => { const menu = await mountMenu(); expect(menuItemLabels(menu)).toEqual([ - "Open chat", "Pin session", - "Change icon", "Mark as unread", "Rename…", "Fork", @@ -212,10 +199,9 @@ describe("session menu", () => { expect(menuItemLabels(menu)).not.toContain("Archive 2"); }); - it("omits Open chat and Workboard when unavailable", async () => { - const menu = await mountMenu({ canOpenChat: false, workboard: null }); + it("omits Workboard when unavailable", async () => { + const menu = await mountMenu({ workboard: null }); - expect(menuItemLabels(menu)).not.toContain("Open chat"); expect(menuItemLabels(menu)).not.toContain("Add to Workboard"); }); @@ -260,88 +246,6 @@ describe("session menu", () => { expect(calls).toEqual(["close", "toggle-pin"]); }); - it("dispatches curated, emoji, and remove icon choices", async () => { - const onAction = vi.fn<(action: SessionMenuAction) => void>(); - let menu = await mountMenu({ onAction }); - await openIconPicker(menu); - - menu - .querySelector('.session-menu__icon-choice[aria-label="spark"]') - ?.click(); - expect(onAction).toHaveBeenLastCalledWith({ kind: "set-icon", icon: "name:spark" }); - - menu = await mountMenu({ onAction }); - await openIconPicker(menu); - const input = menu.querySelector(".session-menu__emoji-field input"); - if (input) { - input.value = "🦞"; - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - } - expect(onAction).toHaveBeenLastCalledWith({ kind: "set-icon", icon: "🦞" }); - - menu = await mountMenu({ session: { icon: "name:spark" }, onAction }); - await openIconPicker(menu); - menu.querySelector(".session-menu__remove-icon")?.click(); - expect(onAction).toHaveBeenLastCalledWith({ kind: "set-icon", icon: null }); - }); - - it("opens an accessible icon picker with keyboard grid navigation", async () => { - const menu = await mountMenu(); - const dropdown = menu.querySelector("wa-dropdown"); - - await openIconPicker(menu); - - expect(menu.querySelector("wa-dropdown")).toBe(dropdown); - expect(menu.querySelector(".session-menu__icon-picker")?.getAttribute("role")).toBe("dialog"); - const choices = Array.from( - menu.querySelectorAll(".session-menu__icon-choice"), - ); - expect(document.activeElement).toBe(choices[0]); - choices[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); - expect(document.activeElement).toBe(choices[1]); - }); - - it("keeps Tab ownership inside the nested icon-picker dialog", async () => { - const trigger = document.createElement("button"); - document.body.append(trigger); - containers.push(trigger); - const menu = await mountMenu({ trigger }); - - await openIconPicker(menu); - - const choice = menu.querySelector(".session-menu__icon-choice"); - expect(document.activeElement).toBe(choice); - choice?.dispatchEvent( - new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }), - ); - - expect(document.activeElement).toBe(choice); - }); - - it("preserves an intentionally focused icon-picker field after the dropdown opens", async () => { - const menu = await mountMenu(); - await openIconPicker(menu); - const input = menu.querySelector(".session-menu__emoji-field input"); - input?.focus(); - - menu.querySelector("wa-dropdown")?.dispatchEvent(new CustomEvent("wa-after-show")); - - expect(document.activeElement).toBe(input); - }); - - it("keeps one dropdown and restores its originating item after leaving the icon picker", async () => { - const menu = await mountMenu(); - const dropdown = menu.querySelector("wa-dropdown"); - await openIconPicker(menu); - - menu.querySelector(".session-menu__icon-picker-back")?.click(); - await menu.updateComplete; - await Promise.resolve(); - - expect(menu.querySelector("wa-dropdown")).toBe(dropdown); - expect(document.activeElement).toBe(menuItem(menu, "Change icon")); - }); - it("opens group actions and dispatches group, removal, and creation choices", async () => { const onAction = vi.fn<(action: SessionMenuAction) => void>(); const menu = await mountMenu({ @@ -537,7 +441,7 @@ describe("session menu", () => { document.body.append(trigger); containers.push(trigger); const menu = await mountMenu({ trigger }); - const item = menuItem(menu, "Open chat"); + const item = menuItem(menu, "Pin session"); item.focus(); const keydown = new KeyboardEvent("keydown", { diff --git a/ui/src/components/session-menu.ts b/ui/src/components/session-menu.ts index bdbf96ff2d10..fb9cde744534 100644 --- a/ui/src/components/session-menu.ts +++ b/ui/src/components/session-menu.ts @@ -1,12 +1,7 @@ import { html, nothing } from "lit"; -import { property, state } from "lit/decorators.js"; +import { property } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; import { ref } from "lit/directives/ref.js"; -// Module import, not the protocol barrel β€” keeps TypeBox out of startup JS. -import { - normalizeSessionIconInput, - parseSessionIcon, -} from "../../../packages/gateway-protocol/src/session-icon.js"; import { t } from "../i18n/index.ts"; import { EDITOR_IDS, EDITOR_LABELS, type EditorId } from "../lib/editor-links.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; @@ -14,7 +9,6 @@ import { DropdownMenuController } from "./dropdown-menu-controller.ts"; import { icons } from "./icons.ts"; import { activateMenuShortcut, menuShortcutHint } from "./menu-shortcuts.ts"; import { promoteToPopoverTopLayer } from "./menu-surface.ts"; -import { CURATED_SESSION_ICON_IDS, resolveSessionIcon } from "./session-icon-registry.ts"; import { syncDropdownItemRadio } from "./web-awesome.ts"; type SessionMenuData = { @@ -23,7 +17,6 @@ type SessionMenuData = { unread: boolean; archived: boolean; category: string | null; - icon?: string; }; /** @@ -38,11 +31,9 @@ export type SessionMenuWork = { }; export type SessionMenuAction = - | { kind: "open-chat" } | { kind: "open-pr"; url: string } | { kind: "open-in"; editor: EditorId; path: string } | { kind: "toggle-pin" } - | { kind: "set-icon"; icon: string | null } | { kind: "toggle-unread" } | { kind: "rename" } | { kind: "fork" } @@ -61,7 +52,6 @@ const EMPTY_SESSION: SessionMenuData = { unread: false, archived: false, category: null, - icon: undefined, }; class SessionMenu extends OpenClawLightDomElement { @@ -82,12 +72,10 @@ class SessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) deleteAllowed = false; @property({ attribute: false }) cloudWorkerStopAllowed = false; @property({ attribute: false }) groups: readonly string[] = []; - @property({ attribute: false }) canOpenChat = false; @property({ attribute: false }) work: SessionMenuWork | null = null; @property({ attribute: false }) workboard: { captured: boolean; busy: boolean } | null = null; @property({ attribute: false }) onAction: (action: SessionMenuAction) => void = () => {}; @property({ attribute: false }) onClose: () => void = () => {}; - @state() private iconPickerOpen = false; readonly menuLifecycle = new DropdownMenuController(this, { getTrigger: () => this.trigger, onClose: () => this.onClose(), @@ -126,7 +114,6 @@ class SessionMenu extends OpenClawLightDomElement { return; } const simpleActions: Partial> = { - "open-chat": { kind: "open-chat" }, "toggle-pin": { kind: "toggle-pin" }, "toggle-unread": { kind: "toggle-unread" }, rename: { kind: "rename" }, @@ -142,13 +129,6 @@ class SessionMenu extends OpenClawLightDomElement { this.runAction(simpleAction); return; } - if (value === "change-icon") { - this.iconPickerOpen = true; - void this.updateComplete.then(() => { - this.querySelector(".session-menu__icon-choice:not(:disabled)")?.focus(); - }); - return; - } if (value === "open-pr" && this.work?.pullRequestUrl) { this.runAction({ kind: "open-pr", url: this.work.pullRequestUrl }); return; @@ -267,126 +247,6 @@ class SessionMenu extends OpenClawLightDomElement { `; } - private readonly handleIconPickerKeydown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - return; - } - event.stopPropagation(); - const target = event.target; - if (!(target instanceof HTMLButtonElement) || !target.matches(".session-menu__icon-choice")) { - return; - } - const choices = Array.from( - this.querySelectorAll(".session-menu__icon-choice:not(:disabled)"), - ); - const index = choices.indexOf(target); - const columns = 6; - const nextIndex = - event.key === "ArrowLeft" - ? index - 1 - : event.key === "ArrowRight" - ? index + 1 - : event.key === "ArrowUp" - ? index - columns - : event.key === "ArrowDown" - ? index + columns - : index; - if (nextIndex >= 0 && nextIndex < choices.length && nextIndex !== index) { - event.preventDefault(); - choices[nextIndex]?.focus(); - } - }; - - private returnFromIconPicker() { - this.iconPickerOpen = false; - void this.updateComplete.then(() => { - this.querySelector('wa-dropdown-item[value="change-icon"]')?.focus({ - preventScroll: true, - }); - }); - } - - private renderIconPicker() { - const currentIcon = this.session.icon; - return html` - - `; - } - override render() { const menuWidth = 240; const menuMaxHeight = 460; @@ -417,215 +277,175 @@ class SessionMenu extends OpenClawLightDomElement { aria-label=${menuLabel} style="position: fixed; left: ${clampedX}px; top: ${clampedY}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;" > - ${this.iconPickerOpen - ? this.renderIconPicker() - : html`${!batch && this.lastActive - ? html`
- ${t("sessionsView.lastActive", { time: this.lastActive })} -
` - : nothing} - ${!batch && this.canOpenChat - ? html` - - - ${t("sessionsView.openChat")} - ${menuShortcutHint("o")} - - ` - : nothing} - ${batch ? nothing : this.renderWorkItems()} - ${batch - ? nothing - : html` - - - ${session.pinned - ? t("sessionsView.unpinSession") - : t("sessionsView.pinSession")} - ${menuShortcutHint("p")} - - - - ${t("sessionsView.changeIcon")} - - `} + ${!batch && this.lastActive + ? html`
+ ${t("sessionsView.lastActive", { time: this.lastActive })} +
` + : nothing} + ${batch ? nothing : this.renderWorkItems()} + ${batch + ? nothing + : html` ${session.pinned ? icons.pinOff : icons.pin} ${batch - ? session.unread - ? t("sessionsView.markReadCount", { count }) - : t("sessionsView.markUnreadCount", { count }) - : session.unread - ? t("sessionsView.markRead") - : t("sessionsView.markUnread")}${session.pinned + ? t("sessionsView.unpinSession") + : t("sessionsView.pinSession")} - ${menuShortcutHint("u")} + ${menuShortcutHint("p")} - ${batch - ? nothing - : html` - - - ${t("sessionsView.renameSessionMenu")} - ${menuShortcutHint("r")} - - - - ${t("sessionsView.forkSession")} - ${menuShortcutHint("f")} - - `} - ${!batch && this.workboard - ? html` - - - ${this.workboard.captured - ? t("sessionsView.openWorkboardCard") - : t("sessionsView.addToWorkboard")} - ${menuShortcutHint("w")} - - ` - : nothing} + `} + + + ${batch + ? session.unread + ? t("sessionsView.markReadCount", { count }) + : t("sessionsView.markUnreadCount", { count }) + : session.unread + ? t("sessionsView.markRead") + : t("sessionsView.markUnread")} + ${menuShortcutHint("u")} + + ${batch + ? nothing + : html` - - ${batch - ? t("sessionsView.moveToGroupMenuCount", { count }) - : t("sessionsView.moveToGroupMenu")} - ${this.renderGroupSubmenu()} + + ${t("sessionsView.renameSessionMenu")} + ${menuShortcutHint("r")} - - ${!batch && this.cloudWorkerStopAllowed - ? html` - - - ${t("sessionsView.stopCloudWorker")} - - ` - : nothing} + + ${t("sessionsView.forkSession")} + ${menuShortcutHint("f")} + + `} + ${!batch && this.workboard + ? html` + ${this.workboard.captured ? icons.check : icons.plus} ${batch - ? session.archived - ? t("sessionsView.restoreSessionCount", { count }) - : t("sessionsView.archiveSessionCount", { count }) - : session.archived - ? t("sessionsView.restoreSession") - : t("sessionsView.archiveSession")}${this.workboard.captured + ? t("sessionsView.openWorkboardCard") + : t("sessionsView.addToWorkboard")} - ${menuShortcutHint("a")} + ${menuShortcutHint("w")} + ` + : nothing} + + + ${batch + ? t("sessionsView.moveToGroupMenuCount", { count }) + : t("sessionsView.moveToGroupMenu")} + ${this.renderGroupSubmenu()} + + + ${!batch && this.cloudWorkerStopAllowed + ? html` - - ${batch - ? t("sessionsView.deleteSessionCount", { count }) - : t("sessionsView.deleteSessionMenu")} - ${menuShortcutHint("d")} - `} + + ${t("sessionsView.stopCloudWorker")} + + ` + : nothing} + + + ${batch + ? session.archived + ? t("sessionsView.restoreSessionCount", { count }) + : t("sessionsView.archiveSessionCount", { count }) + : session.archived + ? t("sessionsView.restoreSession") + : t("sessionsView.archiveSession")} + ${menuShortcutHint("a")} + + + + ${batch + ? t("sessionsView.deleteSessionCount", { count }) + : t("sessionsView.deleteSessionMenu")} + ${menuShortcutHint("d")} + `, ); } diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index 726db50dfbc3..ff3c95637f21 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -85,7 +85,6 @@ function sessionMenuActionDisabledReasons( ...(patchReason ? { "toggle-pin": patchReason, - "set-icon": patchReason, rename: patchReason, } : {}), @@ -260,7 +259,6 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo { @@ -295,9 +292,6 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo return; } switch (action.kind) { - case "open-chat": - host.selectSession(session.key); - break; case "open-pr": openExternalUrlSafe(action.url); break; @@ -307,9 +301,6 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo case "toggle-pin": void host.sessionOrganizer.patchSession(session, { pinned: !session.pinned }); break; - case "set-icon": - void host.sessionOrganizer.patchSession(session, { icon: action.icon }); - break; case "toggle-unread": void host.sessionOrganizer.patchSession(session, { unread: !session.unread }); break; diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts index 8cd83acbb024..8e175452c785 100644 --- a/ui/src/e2e/session-management.sidebar.e2e.test.ts +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -350,26 +350,11 @@ suite.define(() => { const menu = page.getByRole("menu", { name: "Actions for Research notes" }); await menu.waitFor({ state: "visible" }); - await page - .locator("openclaw-session-menu") - .getByRole("menuitem", { name: "Change icon" }) - .click(); - const iconPicker = page.getByRole("dialog", { name: "Change icon" }); - await iconPicker.waitFor({ state: "visible" }); - await expect - .poll(() => iconPicker.evaluate((element) => element.contains(document.activeElement))) - .toBe(true); - await page.keyboard.press("Tab"); - await expect - .poll(() => iconPicker.evaluate((element) => element.contains(document.activeElement))) - .toBe(true); - await iconPicker.getByRole("button", { name: "Back" }).click(); - await menu.waitFor({ state: "visible" }); await expect .poll(() => page .locator("openclaw-session-menu") - .getByRole("menuitem", { name: "Change icon" }) + .getByRole("menuitem", { name: "Pin session" }) .evaluate((element) => element === document.activeElement), ) .toBe(true); @@ -391,7 +376,7 @@ suite.define(() => { .poll(() => page .locator("openclaw-session-menu") - .getByRole("menuitem", { name: "Open chat" }) + .getByRole("menuitem", { name: "Pin session" }) .evaluate((element) => element === document.activeElement), ) .toBe(true); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index b0f54377e5c3..cb519191516a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -893,17 +893,12 @@ export const en: TranslationMap = { renameSessionMenu: "Rename…", pinSession: "Pin session", unpinSession: "Unpin session", - changeIcon: "Change icon", - customEmoji: "Custom emoji", - invalidEmojiIcon: "Enter exactly one emoji.", - removeIcon: "Remove icon", markUnread: "Mark as unread", markRead: "Mark as read", markUnreadCount: "Mark {count} as unread", markReadCount: "Mark {count} as read", forkSession: "Fork", forkedSession: "Forked session", - openChat: "Open chat", openPullRequest: "Open PR", openInEditorMenu: "Open in", archiveSession: "Archive session", diff --git a/ui/src/lib/sessions/patch.ts b/ui/src/lib/sessions/patch.ts index 9dfd2fcd04f2..e97b652c9769 100644 --- a/ui/src/lib/sessions/patch.ts +++ b/ui/src/lib/sessions/patch.ts @@ -11,7 +11,6 @@ export type SessionPatch = { label?: string | null; category?: string | null; boardFace?: "chat" | "dashboard"; - icon?: string | null; model?: string | null; thinkingLevel?: string | null; fastMode?: FastMode | null; diff --git a/ui/src/lib/sessions/reconcile.test.ts b/ui/src/lib/sessions/reconcile.test.ts index d68b1429c118..cd3556076ad7 100644 --- a/ui/src/lib/sessions/reconcile.test.ts +++ b/ui/src/lib/sessions/reconcile.test.ts @@ -83,23 +83,6 @@ test("sessions.changed preserves the creator facet when ownership is unchanged", }); describe("reconcileSessionChanged", () => { - it("drops a cleared icon from the merged row", () => { - const key = "agent:main:main"; - const result = buildResult([ - { key, kind: "global", updatedAt: 1, sessionId: "s1", icon: "name:spark" }, - ]); - const next = reconcileSessionChanged(result, { - sessionKey: key, - key, - kind: "global", - updatedAt: 2, - sessionId: "s1", - icon: null, - }); - expect(next.applied).toBe(true); - expect(next.row?.icon).toBeUndefined(); - }); - it("drops a cleared category from the merged row", () => { const key = "agent:main:discord:channel:1"; const result = buildResult([ diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts index 7627ed5b67f8..24da3324daa8 100644 --- a/ui/src/lib/sessions/reconcile.ts +++ b/ui/src/lib/sessions/reconcile.ts @@ -403,9 +403,6 @@ export function reconcileSessionChanged( if (rowFields.pinnedAt === null) { delete row.pinnedAt; } - if (rowFields.icon === null) { - delete row.icon; - } if (rowFields.label === null) { delete row.label; } diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 90faf25439c7..ad45e9efc828 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -478,7 +478,6 @@ class SessionsPage extends OpenClawLightDomElement { ...(patchReason ? { "toggle-pin": patchReason, - "set-icon": patchReason, "toggle-unread": patchReason, rename: patchReason, "move-to-group": patchReason, @@ -1420,7 +1419,6 @@ class SessionsPage extends OpenClawLightDomElement { this.closeSessionMenu()} .onAction=${(action: SessionMenuAction) => { switch (action.kind) { - case "open-chat": - context.navigate("chat", { - ...sessionNavigationTarget({ - context, - face: "chat", - sessionKey: row.key, - agentId: this.sessionPathAgentId(row.key, context), - }).options, - hash: "", - }); - break; case "open-pr": openExternalUrlSafe(action.url); break; @@ -1466,9 +1452,6 @@ class SessionsPage extends OpenClawLightDomElement { case "toggle-pin": void this.patchSession(row.key, { pinned: row.pinned !== true }); break; - case "set-icon": - void this.patchSession(row.key, { icon: action.icon }); - break; case "toggle-unread": void this.patchSession(row.key, { unread: row.unread !== true }); break; diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index fac9a7c2f1f9..8d4e15e6ee42 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -131,7 +131,6 @@ export type SessionsProps = { patch: { label?: string | null; category?: string | null; - icon?: string | null; archived?: boolean; pinned?: boolean; unread?: boolean; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 7619dc688e6d..164bc76e651c 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2748,122 +2748,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) { border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); } -.session-menu__icon-picker { - width: 184px; - padding: 8px; -} - -.session-menu__icon-picker-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 7px; - color: var(--text); - font-size: 12px; - font-weight: 600; -} - -.session-menu__icon-picker-back { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 4px; - border: 0; - border-radius: var(--radius-sm); - background: transparent; - color: var(--muted); -} - -.session-menu__icon-picker-back:hover, -.session-menu__icon-picker-back:focus-visible { - background: color-mix(in srgb, var(--bg-hover) 84%, transparent); - color: var(--text); -} - -.session-menu__icon-picker-back svg { - width: 15px; - height: 15px; -} - -.session-menu__icon-grid { - display: grid; - grid-template-columns: repeat(6, 24px); - gap: 5px; -} - -.session-menu__icon-choice { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 4px; - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: transparent; - color: var(--muted); -} - -.session-menu__icon-choice:hover, -.session-menu__icon-choice[aria-checked="true"] { - border-color: color-mix(in srgb, var(--accent) 22%, transparent); - background: color-mix(in srgb, var(--accent-subtle) 72%, transparent); - color: var(--text-strong); -} - -.session-menu__icon-choice svg { - width: 16px; - height: 16px; - fill: none; - stroke: currentColor; - stroke-width: 1.5px; -} - -.session-menu__emoji-field { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - color: var(--muted); - font-size: 11px; -} - -.session-menu__emoji-field input { - width: 58px; - min-width: 0; - margin-left: auto; - padding: 3px 6px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg); - color: var(--text); - font: inherit; -} - -.session-menu__remove-icon { - width: 100%; - margin-top: 7px; - padding: 4px 6px; - border: 0; - border-radius: var(--radius-sm); - background: transparent; - color: var(--muted); - font-size: 11px; - text-align: left; -} - -.session-menu__remove-icon:hover:not(:disabled) { - background: color-mix(in srgb, var(--bg-hover) 84%, transparent); - color: var(--text); -} - -.session-menu__remove-icon:disabled, -.session-menu__icon-choice:disabled { - opacity: 0.42; -} - /* Nav rail entries are anchors (for middle-click/new-tab) but read as app chrome: override the UA link pointer back to the default arrow. */ .nav-item { @@ -3030,26 +2914,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) { height: 16px; } -.sidebar-pinned-session__icon .session-icon__named, -.sidebar-pinned-session__icon .session-icon__svg { - display: inline-flex; - width: 16px; - height: 16px; -} - -.sidebar-pinned-session__icon .session-icon__named svg { - stroke: currentColor; - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; -} - -.sidebar-pinned-session__icon .session-icon__emoji { - font-size: 15px; - line-height: 1; -} - .sidebar-zone-entry .sidebar-recent-session:hover .sidebar-pinned-session__icon, .sidebar-zone-entry .sidebar-recent-session--active .sidebar-pinned-session__icon { opacity: 1; diff --git a/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts b/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts index 9e8fdba56556..9e66e4ec75d3 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts @@ -32,7 +32,6 @@ describe("AppSidebar session indicators", () => { label: "Pinned child", updatedAt: 2, pinned: true, - icon: "🦞", hasActiveRun: true, status: "running", unread: true, diff --git a/ui/src/test-helpers/app-sidebar-cases/sessions.ts b/ui/src/test-helpers/app-sidebar-cases/sessions.ts index d916bf728cf1..596b59ce1ca0 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sessions.ts @@ -462,25 +462,6 @@ describe("AppSidebar session mutation feedback", () => { expect(navigate).not.toHaveBeenCalled(); }); - it("patches a session icon from the picker", async () => { - const { harness, sidebar } = await mountMutationHarness(); - const menu = await openSessionMenu(sidebar, "agent:main:a"); - menu.querySelector('wa-dropdown-item[value="change-icon"]')?.click(); - await menu.updateComplete; - - menu - .querySelector('.session-menu__icon-choice[aria-label="spark"]') - ?.click(); - - await waitForFast(() => - expect(harness.patch).toHaveBeenCalledWith( - "agent:main:a", - { icon: "name:spark" }, - { agentId: "main" }, - ), - ); - }); - it("reconciles and stops an idle active cloud worker through its session", async () => { const request = vi.fn(() => Promise.resolve({ ok: true })); const { gateway, harness, sidebar } = await mountMutationHarness({ diff --git a/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts b/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts index f9359f1ede3b..05e63cc602a7 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sidebar-zone.ts @@ -127,33 +127,27 @@ describe("AppSidebar interleaved zone", () => { expect(sidebar.querySelector('[data-session-key="agent:main:extra"]')).toBeNull(); }); - it("renders pinned emoji and named icons with unknown-name fallback", async () => { - const keys = ["agent:main:main", "agent:main:emoji", "agent:main:named", "agent:main:unknown"]; + it("renders the generic icon for pinned sessions", async () => { + const keys = ["agent:main:main", "agent:main:pinned"]; const sessions = createSessionsHarness("main", keys); const result = sessions.sessions.state.result; expect(result).not.toBeNull(); if (!result) { return; } - const iconsByKey = new Map([ - ["agent:main:emoji", "🦞"], - ["agent:main:named", "name:spark"], - ["agent:main:unknown", "name:constructor"], - ]); for (const row of result.sessions) { - const icon = iconsByKey.get(row.key); - if (icon) { - Object.assign(row, { pinned: true, icon }); + if (row.key === "agent:main:pinned") { + Object.assign(row, { pinned: true }); } } const gateway = createGateway({} as GatewayBrowserClient); const { sidebar } = await mountSidebar(gateway, sessions.sessions); - const iconFor = (key: string) => - sidebar.querySelector(`[data-session-key="${key}"] .sidebar-pinned-session__icon`); - expect(iconFor("agent:main:emoji")?.textContent).toContain("🦞"); - expect(iconFor("agent:main:named")?.querySelector('path[d^="M9.937"]')).not.toBeNull(); - expect(iconFor("agent:main:unknown")?.querySelector('path[d^="M21 15"]')).not.toBeNull(); + expect( + sidebar.querySelector( + '[data-session-key="agent:main:pinned"] .sidebar-pinned-session__icon svg', + ), + ).not.toBeNull(); }); it("keeps a pinned icon leading while activity trails the row", async () => { @@ -166,7 +160,7 @@ describe("AppSidebar interleaved zone", () => { } for (const row of result.sessions) { if (row.key === "agent:main:page") { - Object.assign(row, { pinned: true, icon: "🦞", hasActiveRun: true, unread: true }); + Object.assign(row, { pinned: true, hasActiveRun: true, unread: true }); } } const gateway = createGateway({} as GatewayBrowserClient); @@ -174,7 +168,7 @@ describe("AppSidebar interleaved zone", () => { const row = sidebar.querySelector('[data-session-key="agent:main:page"]'); const glyph = row?.querySelector(".sidebar-session-indicator .session-glyph"); - expect(glyph?.querySelector(".sidebar-pinned-session__icon")?.textContent).toContain("🦞"); + expect(glyph?.querySelector(".sidebar-pinned-session__icon svg")).not.toBeNull(); expect(glyph?.classList.contains("session-glyph--running")).toBe(false); expect(glyph?.querySelector(".session-glyph__ring")).toBeNull(); expect(glyph?.querySelector(".session-glyph__badge--unread")).toBeNull(); @@ -194,7 +188,6 @@ describe("AppSidebar interleaved zone", () => { if (row.key === "agent:main:page") { Object.assign(row, { pinned: true, - icon: "🦞", unread: true, status: "failed", lastRunError: "boom",