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
This commit is contained in:
Patrick Erichsen
2026-08-09 21:51:56 -07:00
committed by GitHub
parent 864414c5fd
commit d4c3b9e783
60 changed files with 356 additions and 1417 deletions
@@ -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"
+79 -79
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -211,7 +211,6 @@ describe("lazy protocol validators", () => {
label: "Label",
category: "Category",
boardFace: "dashboard",
icon: "name:spark",
statusNote: "Working",
attention: "hand",
ttlMinutes: 30,
+1 -1
View File
@@ -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,
@@ -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:<id>, or svg:<svg ...>...</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.",
@@ -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()),
@@ -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;
};
@@ -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:<svg viewBox='0 0 24 24'><g transform='translate(1 2)'><path d='M1 2' fill='currentColor'/></g></svg> ",
),
).toEqual({
ok: true,
value:
'svg:<svg viewBox="0 0 24 24"><g transform="translate(1 2)"><path d="M1 2" fill="currentColor"/></g></svg>',
});
});
it.each([
"svg:<svg><script>alert(1)</script></svg>",
'svg:<svg onload="alert(1)"></svg>',
'svg:<svg xmlns:xlink="http://www.w3.org/1999/xlink"><path xlink:href="#x"/></svg>',
'svg:<svg><path fill="url(#paint)"/></svg>',
"svg:<!DOCTYPE svg><svg></svg>",
"svg:<svg></svg><svg></svg>",
'svg:<svg><use href="#x"/></svg>',
'svg:<svg><path style="fill:red"/></svg>',
])("rejects hostile SVG: %s", (value) => {
expect(normalizeSessionIconInput(value).ok).toBe(false);
});
it("rejects SVG icons over 4096 bytes", () => {
const value = `svg:<svg><title>${"x".repeat(4096)}</title></svg>`;
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 &quot; (6 bytes each), overflowing the stored form.
const svg = `svg:<svg viewBox='0 0 16 16'><path d='${'"'.repeat(900)}'/></svg>`;
expect(new TextEncoder().encode(svg).byteLength).toBeLessThanOrEqual(4096);
expect(normalizeSessionIconInput(svg).ok).toBe(false);
});
});
@@ -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<Record<string, string>> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&apos;": "'",
};
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 "&amp;";
}
return character === "<" ? "&lt;" : "&gt;";
});
}
function escapeXmlAttribute(value: string): string {
return escapeXmlText(value).replace(/"/g, "&quot;");
}
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(`</${name}>`);
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<string>();
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 /^<svg(?:\s|>)/.test(svg) && svg.endsWith("</svg>") ? { 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. " -> &quot;) 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:<id>, or svg:<svg>" };
}
return { ok: true, value: trimmed };
}
@@ -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<S.SessionsPatchParams>(
SessionsPatchV4CompatibilityParamsSchema,
);
export const validateSessionsPatchManyParams = compile(S.SessionsPatchManyParamsSchema);
export const validateSessionsPluginPatchParams = compile(S.SessionsPluginPatchParamsSchema);
export const validateSessionsResetParams = compile(S.SessionsResetParamsSchema);
-2
View File
@@ -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",
+1 -20
View File
@@ -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"');
+2 -9
View File
@@ -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:<curated-id>, or svg:<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<string, unknown>;
@@ -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") }
: {}),
@@ -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",
@@ -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 } : {}),
@@ -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,
@@ -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),
+1
View File
@@ -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<string, unknown>): SessionEntry {
const {
icon: _retiredIcon,
sessionFile: _retiredSessionFile,
transcriptPath: _retiredTranscriptPath,
pendingFinalDeliveryCreatedAt,
+1 -3
View File
@@ -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. */
-1
View File
@@ -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,
}),
@@ -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) {
@@ -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,
@@ -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");
});
+24 -11
View File
@@ -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<string, unknown>;
}>("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<{
-1
View File
@@ -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,
-1
View File
@@ -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,
-1
View File
@@ -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,
+1 -2
View File
@@ -400,7 +400,7 @@ describe("gateway session utils", () => {
const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" });
const store: Record<string, SessionEntry> = {
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<string, SessionEntry>;
@@ -409,7 +409,6 @@ describe("gateway session utils", () => {
expect(active.sessions[0]).toMatchObject({
pinned: true,
pinnedAt: 40,
icon: "name:spark",
archived: false,
});
-1
View File
@@ -108,7 +108,6 @@ export type GatewaySessionRow = {
archivedBy?: SessionEntry["archivedBy"];
pinned?: boolean;
pinnedAt?: number;
icon?: string;
unread?: boolean;
lastReadAt?: number;
agentStatus?: SessionEntry["agentStatus"];
-44
View File
@@ -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:<svg viewBox='0 0 24 24'><path d='M1 2' fill='currentColor'/></svg> ",
},
}),
);
expect(icon.icon).toBe(
'svg:<svg viewBox="0 0 24 24"><path d="M1 2" fill="currentColor"/></svg>',
);
const cleared = expectPatchOk(
await runPatch({
store: mainStoreEntry({ icon: "🦞" }),
patch: { key: MAIN_SESSION_KEY, icon: null },
}),
);
expect(cleared.icon).toBeUndefined();
});
test.each([
["script", "svg:<svg><script>alert(1)</script></svg>"],
["event handler", 'svg:<svg onload="alert(1)"></svg>'],
[
"xlink href",
'svg:<svg xmlns:xlink="http://www.w3.org/1999/xlink"><path xlink:href="#x"/></svg>',
],
["URL paint", 'svg:<svg><path fill="url(#paint)"/></svg>'],
["DOCTYPE", "svg:<!DOCTYPE svg><svg></svg>"],
["oversized payload", `svg:<svg><title>${"x".repeat(4096)}</title></svg>`],
["double root", "svg:<svg></svg><svg></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({
-14
View File
@@ -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;
@@ -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",
+1 -1
View File
@@ -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<SessionEntryReservedSlotSetValu
SESSION_ENTRY_RESERVED_SLOT_KEY_LIST,
);
const RETIRED_SESSION_SLOT_KEYS = new Set<string>([
"icon",
"channel",
"origin",
"route",
+1 -1
View File
@@ -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;
+6 -2
View File
@@ -7,13 +7,16 @@ const SESSIONS_PATCH_WRITE_SCOPE_MUTATIONS: ReadonlySet<string> = 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<string> = new Set(["icon"]);
const SESSIONS_PATCH_WRITE_SCOPE_ENVELOPE_FIELDS: ReadonlySet<string> = 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";
@@ -100,7 +100,7 @@ function backfillSessionNodes(db: DatabaseSync): void {
${jsonText("$.label")},
${jsonText("$.displayName")},
${jsonText("$.category")},
${jsonText("$.icon")},
NULL,
${jsonNumber("$.pinnedAt")},
${jsonNumber("$.archivedAt")},
${jsonNumber("$.lastReadAt")},
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
@@ -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,
@@ -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;
@@ -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";
@@ -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<SessionAgentAttentionIconId, TemplateResult>;
export function resolveSessionAttentionIcon(icon: SessionAgentAttentionIconId): TemplateResult {
return SESSION_ATTENTION_ICON_REGISTRY[icon];
}
@@ -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") {
@@ -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<string, TemplateResult>;
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`<span class="session-icon__named">${icon}</span>`;
}
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`<span class="session-icon__emoji">${parsed.emoji}</span>`;
}
if (parsed?.kind === "svg") {
const normalized = normalizeSessionIconInput(icon);
const sanitized = normalized.ok ? parseSessionIcon(normalized.value) : null;
if (sanitized?.kind === "svg") {
return html`<span class="session-icon__svg">${unsafeHTML(sanitized.svg)}</span>`;
}
}
return renderNamedIcon(icons.messageSquare);
}
export function resolveSessionAttentionIcon(icon: SessionAgentAttentionIconId): TemplateResult {
return SESSION_ICON_REGISTRY[icon];
}
@@ -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`<span class="sidebar-pinned-session__icon" aria-hidden="true"
>${resolveSessionIcon(session.icon)}</span
>${icons.messageSquare}</span
>`,
running,
badge: renderGlyphBadge(session, pullRequestState),
@@ -174,7 +173,7 @@ export function renderSessionLeadingState(
running,
leadingIndicator: renderSessionGlyph({
content: html`<span class="sidebar-pinned-session__icon" aria-hidden="true"
>${resolveSessionIcon(session.icon)}</span
>${icons.messageSquare}</span
>`,
running: false,
}),
+3 -99
View File
@@ -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<SessionMenuData>;
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<HTMLButtonElement>('.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<HTMLInputElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLInputElement>(".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<HTMLButtonElement>(".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", {
+146 -326
View File
@@ -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<Record<string, SessionMenuAction>> = {
"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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLElement>('wa-dropdown-item[value="change-icon"]')?.focus({
preventScroll: true,
});
});
}
private renderIconPicker() {
const currentIcon = this.session.icon;
return html`
<div
class="session-menu__icon-picker"
role="dialog"
aria-label=${t("sessionsView.changeIcon")}
@keydown=${this.handleIconPickerKeydown}
>
<div class="session-menu__icon-picker-header">
<button
type="button"
class="session-menu__icon-picker-back"
aria-label=${t("common.back")}
@click=${() => this.returnFromIconPicker()}
>
${icons.arrowLeft}
</button>
<span>${t("sessionsView.changeIcon")}</span>
</div>
<div
class="session-menu__icon-grid"
role="radiogroup"
aria-label=${t("sessionsView.changeIcon")}
>
${CURATED_SESSION_ICON_IDS.map((id) => {
const value = `name:${id}`;
const selected = currentIcon === value;
return html`<button
type="button"
class="session-menu__icon-choice"
role="radio"
aria-label=${id}
aria-checked=${String(selected)}
title=${id}
?disabled=${this.actionDisabled("set-icon")}
@click=${() => this.runAction({ kind: "set-icon", icon: value })}
>
${resolveSessionIcon(value)}
</button>`;
})}
</div>
<label class="session-menu__emoji-field">
<span>${t("sessionsView.customEmoji")}</span>
<input
type="text"
inputmode="text"
maxlength="16"
aria-label=${t("sessionsView.customEmoji")}
placeholder="🦞"
?disabled=${this.actionDisabled("set-icon")}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
event.stopPropagation();
const input = event.currentTarget as HTMLInputElement;
const normalized = normalizeSessionIconInput(input.value);
if (!normalized.ok || parseSessionIcon(normalized.value)?.kind !== "emoji") {
input.setCustomValidity(t("sessionsView.invalidEmojiIcon"));
input.reportValidity();
return;
}
input.setCustomValidity("");
this.runAction({ kind: "set-icon", icon: normalized.value });
}}
/>
</label>
<button
type="button"
class="session-menu__remove-icon"
?disabled=${this.actionDisabled("set-icon", !currentIcon)}
@click=${() => this.runAction({ kind: "set-icon", icon: null })}
>
${t("sessionsView.removeIcon")}
</button>
</div>
`;
}
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;"
></button>
${this.iconPickerOpen
? this.renderIconPicker()
: html`${!batch && this.lastActive
? html`<div class="session-menu__info">
${t("sessionsView.lastActive", { time: this.lastActive })}
</div>`
: nothing}
${!batch && this.canOpenChat
? html`
<wa-dropdown-item
class="session-menu__item"
value="open-chat"
data-shortcut="o"
aria-keyshortcuts="O"
?disabled=${this.disabled}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.messageSquare}</span
>
<span class="session-menu__text">${t("sessionsView.openChat")}</span>
${menuShortcutHint("o")}
</wa-dropdown-item>
`
: nothing}
${batch ? nothing : this.renderWorkItems()}
${batch
? nothing
: html`
<wa-dropdown-item
class="session-menu__item"
value="toggle-pin"
data-shortcut="p"
aria-keyshortcuts="P"
?disabled=${this.actionDisabled("toggle-pin", session.archived)}
title=${this.actionTitle("toggle-pin")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.pinned ? icons.pinOff : icons.pin}</span
>
<span class="session-menu__text"
>${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}</span
>
${menuShortcutHint("p")}
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
value="change-icon"
?disabled=${this.actionDisabled("set-icon")}
title=${this.actionTitle("set-icon")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.spark}</span
>
<span class="session-menu__text">${t("sessionsView.changeIcon")}</span>
</wa-dropdown-item>
`}
${!batch && this.lastActive
? html`<div class="session-menu__info">
${t("sessionsView.lastActive", { time: this.lastActive })}
</div>`
: nothing}
${batch ? nothing : this.renderWorkItems()}
${batch
? nothing
: html`
<wa-dropdown-item
class="session-menu__item"
value="toggle-unread"
data-shortcut="u"
aria-keyshortcuts="U"
?disabled=${this.actionDisabled("toggle-unread")}
title=${this.actionTitle("toggle-unread")}
value="toggle-pin"
data-shortcut="p"
aria-keyshortcuts="P"
?disabled=${this.actionDisabled("toggle-pin", session.archived)}
title=${this.actionTitle("toggle-pin")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.unread ? icons.eye : icons.circle}</span
>${session.pinned ? icons.pinOff : icons.pin}</span
>
<span class="session-menu__text"
>${batch
? session.unread
? t("sessionsView.markReadCount", { count })
: t("sessionsView.markUnreadCount", { count })
: session.unread
? t("sessionsView.markRead")
: t("sessionsView.markUnread")}</span
>${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}</span
>
${menuShortcutHint("u")}
${menuShortcutHint("p")}
</wa-dropdown-item>
${batch
? nothing
: html`
<wa-dropdown-item
class="session-menu__item"
value="rename"
data-shortcut="r"
aria-keyshortcuts="R"
?disabled=${this.actionDisabled("rename")}
title=${this.actionTitle("rename")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.edit}</span
>
<span class="session-menu__text">${t("sessionsView.renameSessionMenu")}</span>
${menuShortcutHint("r")}
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
value="fork"
data-shortcut="f"
aria-keyshortcuts="F"
?disabled=${this.actionDisabled("fork", this.forkDisabled)}
title=${this.actionTitle("fork")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.copy}</span
>
<span class="session-menu__text">${t("sessionsView.forkSession")}</span>
${menuShortcutHint("f")}
</wa-dropdown-item>
`}
${!batch && this.workboard
? html`
<wa-dropdown-item
class="session-menu__item"
value="workboard"
data-shortcut="w"
aria-keyshortcuts="W"
?disabled=${this.disabled || this.workboard.busy}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${this.workboard.captured ? icons.check : icons.plus}</span
>
<span class="session-menu__text"
>${this.workboard.captured
? t("sessionsView.openWorkboardCard")
: t("sessionsView.addToWorkboard")}</span
>
${menuShortcutHint("w")}
</wa-dropdown-item>
`
: nothing}
`}
<wa-dropdown-item
class="session-menu__item"
value="toggle-unread"
data-shortcut="u"
aria-keyshortcuts="U"
?disabled=${this.actionDisabled("toggle-unread")}
title=${this.actionTitle("toggle-unread")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.unread ? icons.eye : icons.circle}</span
>
<span class="session-menu__text"
>${batch
? session.unread
? t("sessionsView.markReadCount", { count })
: t("sessionsView.markUnreadCount", { count })
: session.unread
? t("sessionsView.markRead")
: t("sessionsView.markUnread")}</span
>
${menuShortcutHint("u")}
</wa-dropdown-item>
${batch
? nothing
: html`
<wa-dropdown-item
class="session-menu__item"
?disabled=${this.actionDisabled("move-to-group")}
title=${this.actionTitle("move-to-group")}
value="rename"
data-shortcut="r"
aria-keyshortcuts="R"
?disabled=${this.actionDisabled("rename")}
title=${this.actionTitle("rename")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.folder}</span
>
<span class="session-menu__text"
>${batch
? t("sessionsView.moveToGroupMenuCount", { count })
: t("sessionsView.moveToGroupMenu")}</span
>
${this.renderGroupSubmenu()}
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
<span class="session-menu__text">${t("sessionsView.renameSessionMenu")}</span>
${menuShortcutHint("r")}
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
${!batch && this.cloudWorkerStopAllowed
? html`
<wa-dropdown-item
class="session-menu__item session-menu__item--destructive"
value="stop-cloud-worker"
variant="danger"
?disabled=${this.actionDisabled("stop-cloud-worker")}
title=${this.actionTitle("stop-cloud-worker")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.stop}</span
>
<span class="session-menu__text">${t("sessionsView.stopCloudWorker")}</span>
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item
class="session-menu__item"
value="toggle-archived"
data-shortcut="a"
aria-keyshortcuts="A"
?disabled=${this.actionDisabled(
"toggle-archived",
!batch && !session.archived && !this.archiveAllowed,
)}
title=${this.actionTitle("toggle-archived")}
value="fork"
data-shortcut="f"
aria-keyshortcuts="F"
?disabled=${this.actionDisabled("fork", this.forkDisabled)}
title=${this.actionTitle("fork")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.copy}</span>
<span class="session-menu__text">${t("sessionsView.forkSession")}</span>
${menuShortcutHint("f")}
</wa-dropdown-item>
`}
${!batch && this.workboard
? html`
<wa-dropdown-item
class="session-menu__item"
value="workboard"
data-shortcut="w"
aria-keyshortcuts="W"
?disabled=${this.disabled || this.workboard.busy}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.archived ? icons.archiveRestore : icons.archive}</span
>${this.workboard.captured ? icons.check : icons.plus}</span
>
<span class="session-menu__text"
>${batch
? session.archived
? t("sessionsView.restoreSessionCount", { count })
: t("sessionsView.archiveSessionCount", { count })
: session.archived
? t("sessionsView.restoreSession")
: t("sessionsView.archiveSession")}</span
>${this.workboard.captured
? t("sessionsView.openWorkboardCard")
: t("sessionsView.addToWorkboard")}</span
>
${menuShortcutHint("a")}
${menuShortcutHint("w")}
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item
class="session-menu__item"
?disabled=${this.actionDisabled("move-to-group")}
title=${this.actionTitle("move-to-group")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
<span class="session-menu__text"
>${batch
? t("sessionsView.moveToGroupMenuCount", { count })
: t("sessionsView.moveToGroupMenu")}</span
>
${this.renderGroupSubmenu()}
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
${!batch && this.cloudWorkerStopAllowed
? html`
<wa-dropdown-item
class="session-menu__item session-menu__item--destructive"
value="delete"
value="stop-cloud-worker"
variant="danger"
data-shortcut="d"
aria-keyshortcuts="D"
?disabled=${this.actionDisabled("delete", !this.deleteAllowed)}
title=${this.actionTitle("delete")}
?disabled=${this.actionDisabled("stop-cloud-worker")}
title=${this.actionTitle("stop-cloud-worker")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.trash}</span
>
<span class="session-menu__text"
>${batch
? t("sessionsView.deleteSessionCount", { count })
: t("sessionsView.deleteSessionMenu")}</span
>
${menuShortcutHint("d")}
</wa-dropdown-item>`}
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.stop}</span>
<span class="session-menu__text">${t("sessionsView.stopCloudWorker")}</span>
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item
class="session-menu__item"
value="toggle-archived"
data-shortcut="a"
aria-keyshortcuts="A"
?disabled=${this.actionDisabled(
"toggle-archived",
!batch && !session.archived && !this.archiveAllowed,
)}
title=${this.actionTitle("toggle-archived")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.archived ? icons.archiveRestore : icons.archive}</span
>
<span class="session-menu__text"
>${batch
? session.archived
? t("sessionsView.restoreSessionCount", { count })
: t("sessionsView.archiveSessionCount", { count })
: session.archived
? t("sessionsView.restoreSession")
: t("sessionsView.archiveSession")}</span
>
${menuShortcutHint("a")}
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item session-menu__item--destructive"
value="delete"
variant="danger"
data-shortcut="d"
aria-keyshortcuts="D"
?disabled=${this.actionDisabled("delete", !this.deleteAllowed)}
title=${this.actionTitle("delete")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
<span class="session-menu__text"
>${batch
? t("sessionsView.deleteSessionCount", { count })
: t("sessionsView.deleteSessionMenu")}</span
>
${menuShortcutHint("d")}
</wa-dropdown-item>
</wa-dropdown>`,
);
}
@@ -85,7 +85,6 @@ function sessionMenuActionDisabledReasons(
...(patchReason
? {
"toggle-pin": patchReason,
"set-icon": patchReason,
rename: patchReason,
}
: {}),
@@ -260,7 +259,6 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
<openclaw-session-menu
.session=${{
label: session.label,
icon: session.icon,
pinned: session.pinned,
unread: batchRows ? allUnread : session.unread,
archived: allArchived,
@@ -281,7 +279,6 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
.deleteAllowed=${deleteAllowed}
.cloudWorkerStopAllowed=${cloudWorkerStopAllowed}
.groups=${host.knownSessionGroups()}
.canOpenChat=${true}
.work=${batchRows ? null : controller.sessionMenuWork}
.workboard=${null}
.onClose=${() => {
@@ -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;
@@ -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);
-5
View File
@@ -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",
-1
View File
@@ -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;
-17
View File
@@ -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([
-3
View File
@@ -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;
}
-17
View File
@@ -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 {
<openclaw-session-menu
.session=${{
label: normalizeOptionalString(row.label) ?? row.key,
icon: row.icon,
pinned: row.pinned === true,
unread: row.unread === true,
archived: row.archived === true,
@@ -1435,7 +1433,6 @@ class SessionsPage extends OpenClawLightDomElement {
.deleteAllowed=${deleteAllowed}
.cloudWorkerStopAllowed=${cloudWorkerStopAllowed}
.groups=${this.knownCategories()}
.canOpenChat=${row.kind !== "global"}
.work=${this.sessionMenuWork}
.workboard=${canCapture && row.kind !== "global"
? {
@@ -1446,17 +1443,6 @@ class SessionsPage extends OpenClawLightDomElement {
.onClose=${() => 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;
-1
View File
@@ -131,7 +131,6 @@ export type SessionsProps = {
patch: {
label?: string | null;
category?: string | null;
icon?: string | null;
archived?: boolean;
pinned?: boolean;
unread?: boolean;
-136
View File
@@ -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;
@@ -32,7 +32,6 @@ describe("AppSidebar session indicators", () => {
label: "Pinned child",
updatedAt: 2,
pinned: true,
icon: "🦞",
hasActiveRun: true,
status: "running",
unread: true,
@@ -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<HTMLElement>('wa-dropdown-item[value="change-icon"]')?.click();
await menu.updateComplete;
menu
.querySelector<HTMLButtonElement>('.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({
@@ -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",