mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 " (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>> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
function parseXmlEntities(value: string): string | null {
|
||||
let result = "";
|
||||
let offset = 0;
|
||||
while (offset < value.length) {
|
||||
const ampersand = value.indexOf("&", offset);
|
||||
if (ampersand < 0) {
|
||||
return result + value.slice(offset);
|
||||
}
|
||||
result += value.slice(offset, ampersand);
|
||||
const semicolon = value.indexOf(";", ampersand + 1);
|
||||
if (semicolon < 0) {
|
||||
return null;
|
||||
}
|
||||
const entity = value.slice(ampersand, semicolon + 1);
|
||||
const decoded = XML_ENTITIES[entity];
|
||||
if (decoded === undefined) {
|
||||
return null;
|
||||
}
|
||||
result += decoded;
|
||||
offset = semicolon + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeXmlText(value: string): string {
|
||||
return value.replace(/[&<>]/g, (character) => {
|
||||
if (character === "&") {
|
||||
return "&";
|
||||
}
|
||||
return character === "<" ? "<" : ">";
|
||||
});
|
||||
}
|
||||
|
||||
function escapeXmlAttribute(value: string): string {
|
||||
return escapeXmlText(value).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function parseTransformNumbers(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return 0;
|
||||
}
|
||||
const parts = trimmed.split(/[\s,]+/);
|
||||
if (parts.some((part) => !SVG_NUMBER_RE.test(part))) {
|
||||
return null;
|
||||
}
|
||||
return parts.length;
|
||||
}
|
||||
|
||||
function isValidTransform(value: string): boolean {
|
||||
let remaining = value.trim();
|
||||
while (remaining) {
|
||||
const match = SVG_TRANSFORM_RE.exec(remaining);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const name = match[1];
|
||||
const count = parseTransformNumbers(match[2] ?? "");
|
||||
const validCount =
|
||||
name === "translate" || name === "scale"
|
||||
? count === 1 || count === 2
|
||||
: name === "rotate"
|
||||
? count === 1 || count === 3
|
||||
: name === "matrix"
|
||||
? count === 6
|
||||
: false;
|
||||
if (!validCount) {
|
||||
return false;
|
||||
}
|
||||
remaining = remaining.slice(match[0].length).trimStart();
|
||||
if (remaining.startsWith(",")) {
|
||||
remaining = remaining.slice(1).trimStart();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNameCharacter(character: string): boolean {
|
||||
return /[A-Za-z0-9-]/.test(character);
|
||||
}
|
||||
|
||||
function sanitizeSvg(svg: string): string | null {
|
||||
let offset = 0;
|
||||
let rootSeen = false;
|
||||
let rootClosed = false;
|
||||
const stack: string[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
const skipWhitespace = () => {
|
||||
while (offset < svg.length && /\s/.test(svg[offset] ?? "")) {
|
||||
offset += 1;
|
||||
}
|
||||
};
|
||||
const readName = () => {
|
||||
const start = offset;
|
||||
while (offset < svg.length && isNameCharacter(svg[offset] ?? "")) {
|
||||
offset += 1;
|
||||
}
|
||||
return svg.slice(start, offset);
|
||||
};
|
||||
|
||||
while (offset < svg.length) {
|
||||
if (svg[offset] !== "<") {
|
||||
const nextTag = svg.indexOf("<", offset);
|
||||
const end = nextTag < 0 ? svg.length : nextTag;
|
||||
const rawText = svg.slice(offset, end);
|
||||
const text = parseXmlEntities(rawText);
|
||||
if (text === null || stack.length === 0 || rootClosed) {
|
||||
if (rawText.trim()) {
|
||||
return null;
|
||||
}
|
||||
} else if (text.trim()) {
|
||||
if (stack.at(-1) !== "title") {
|
||||
return null;
|
||||
}
|
||||
output.push(escapeXmlText(text));
|
||||
}
|
||||
offset = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
offset += 1;
|
||||
if (svg[offset] === "/") {
|
||||
offset += 1;
|
||||
const name = readName();
|
||||
skipWhitespace();
|
||||
if (!name || svg[offset] !== ">" || stack.at(-1) !== name) {
|
||||
return null;
|
||||
}
|
||||
offset += 1;
|
||||
stack.pop();
|
||||
output.push(`</${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. " -> ") 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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ export type GatewaySessionRow = {
|
||||
archivedBy?: SessionEntry["archivedBy"];
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
icon?: string;
|
||||
unread?: boolean;
|
||||
lastReadAt?: number;
|
||||
agentStatus?: SessionEntry["agentStatus"];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -131,7 +131,6 @@ export type SessionsProps = {
|
||||
patch: {
|
||||
label?: string | null;
|
||||
category?: string | null;
|
||||
icon?: string | null;
|
||||
archived?: boolean;
|
||||
pinned?: boolean;
|
||||
unread?: boolean;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user