feat(gateway): task-suggestion acceptance modes with compact split-button card (#121173)

* feat(gateway): task-suggestion acceptance modes with compact split-button card

- taskSuggestions.accept gains additive mode (worktree default | local | cloud | session) + cloudProfileId; operator.admin unchanged; worktree requests stay {taskId}-only for older gateways
- local reuses the plain-cwd create; cloud mirrors the Control UI create-empty -> sessions.dispatch -> send sequence with pre-claim profile validation and full rollback; session delivers the prompt into the source session (steer single active run, plain turn when idle, ambiguous refused) and can only cancel the chip on failure, never touch the source session
- Control UI card redesigned per owner reference: repo-basename eyebrow, clamped summary, instructions expander, split button with per-profile cloud items or a disabled "No cloud environment configured" hint
- TUI gains the same modes as selector rows with capability gating and double-Enter arming

Release-note context: operators now choose where an accepted follow-up runs — isolated worktree (default), the project checkout, a configured cloud worker, or as the next instruction in the current session — from a compact suggestion card.

* fix(gateway): negotiate task-suggestion acceptance modes and enforce steerable-run invariant

- Gate non-worktree task-suggestion actions on the negotiated Gateway capability.
- Require exactly one steerable run ID before delivering into an active source session.
This commit is contained in:
Peter Steinberger
2026-08-09 12:21:21 -07:00
committed by GitHub
parent 141304a02e
commit d3c84674e8
32 changed files with 1630 additions and 295 deletions
@@ -9378,15 +9378,23 @@ public struct TaskSuggestion: Codable, Sendable {
public struct TaskSuggestionsAcceptParams: Codable, Sendable {
public let taskid: String
public let mode: String?
public let cloudprofileid: String?
public init(
taskid: String)
taskid: String,
mode: String? = nil,
cloudprofileid: String? = nil)
{
self.taskid = taskid
self.mode = mode
self.cloudprofileid = cloudprofileid
}
private enum CodingKeys: String, CodingKey {
case taskid = "taskId"
case mode
case cloudprofileid = "cloudProfileId"
}
}
+37 -37
View File
@@ -3,22 +3,22 @@ d20eb6a6a77f72566266b88d191b30155757a1cc3d3886d3b6c437176bbac771 module/account
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
006e581db74e461dcf4d7fd299b09d285f32c91ac12a56de1d21307d272e5a0a module/account-resolution
3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives
246c3e690220fa805138de4c75d947b82c1ff41a27dd5d64b5260949654295e1 module/agent-harness
7598c465d4c8fab2efe6c635b252ff0574325e5665dead98190aab68a27cd1b3 module/agent-harness-runtime
6c29184e0f440254aa8c88486ad8b82d9be5228391a3c523cfb7521f44001773 module/agent-harness
79ec23a3293c3faaf96f9bb6d5eea289d503fc82a4d8123614d529bdd34a456d module/agent-harness-runtime
595ef30046b8ba4bfd427e5a33b65ca28ab1a699ce7e90d6afa2ba2b187e22c0 module/agent-media-payload
d575b452a1efad4372f9228c30bbf0aacf602f343ebc95badab9a13b9a2f7db3 module/agent-runtime
03861f14e62d022d5f5fac3440e94d223ae2a87c0c99eacdc4762ac41720c937 module/agent-runtime
72fa7e17dd1694a24f373dece9d3035c3fc25e1e113954328e16df03d9be7b55 module/agent-scope-runtime
8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from
ac6c71c3b7c3d1f6d3219d7514a1d1e0c44198358e6e9a4eae06e318d52a8433 module/allowlist-config-edit
136ddb70973082ec051eee5a5c2a78da5359f6d0e2dc058be5f7049ad5d2608e module/approval-auth-runtime
cdf0f3cab2f9e19bf9a7b9125872b4e9986bcd0530d17a3bdc76ec00aa9d8388 module/approval-client-runtime
235abceca3e9392ab39e5e63231337ae9dc35bbb773fb79e0e5a0b56be403455 module/approval-delivery-runtime
b49c730d28ede4fe15732a30a5cb6f38fd0ac3246bcec4a0784f0b2f2999ba5a module/approval-gateway-runtime
b784be7a3514d186ff62c8d1688fc38d6c0dbad00ecca438e5429d95c0b3b6c9 module/approval-gateway-runtime
9750dd4c52ac4b14142be0d39205da33faf584fdb48bb4d2942972e608147ccd module/approval-handler-adapter-runtime
f1d44213e7e5d1c8d23499b4f634395a6827ddb390296a9c63f53acb1c108b02 module/approval-handler-runtime
fa1d9baff79016a97bdab8f44d6fd6f534fd69d2bf7c3de34a744b278a97a654 module/approval-handler-runtime
a0904844a521a3391cbb183e8657b69f7550b6245f88a7bcd7b5c12b2e3fae24 module/approval-native-runtime
343b855a38a460a8c3b65b005698ed5c311e9f6b93e7d42a142c5cddb3a08a6f module/approval-reply-runtime
fabf4fb018bd3c8a38e6490e28bacae0e1fe9795ea3c334c845c6e23ad95142e module/approval-runtime
9657575dca903644b9281b14c6522ecf90e95878368ade8a8ad19850314eaef4 module/approval-runtime
01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive
d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param
1b87d321f91ac51be8010cfd45163e1b1f13e4f68de60539fa8518f54258b4b5 module/channel-actions
@@ -26,19 +26,19 @@ d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema
63d4f5bc22d6e7779fcfa1f73e70df689189fcc876af26e05a4e3334b95675a9 module/channel-contract
317c23fc52ee6981549e0d0ad41475a705b60114dd78f1ec7091a509d6cbbec2 module/channel-core
21df5a60780877b5a35388138966b9ecebd3dcdac279117dbc92fd90950e441a module/channel-core
f6ddf9086bc224b4b1a516cd7e056467a529d19435930b1c8ddff7052d890ffa module/channel-dm-policy
dab6f2650be53f8932a4acf406690731f5cd734805dfe306e34cf3bc1914dd4a module/channel-entry-contract
b341b4024b587d0b78efad389b4b688d09e0337d2f43305116b2d6f5c2c4789a module/channel-entry-contract
b14ed3e3235fab5725a5eac86bb7910174a21ae1d7f2b4433891867396dcc566 module/channel-feedback
628a34efa05f777e1b414428e2992023db40ebd0eb6d48e3450cc06fe3e29935 module/channel-inbound
9489208a12adfaef03005900a049847664b003bb4a485662827f9703311e7cd6 module/channel-inbound
f58349b93db16be763c7ba4c00cbc13b1b64911c8eefa5a1bfba1e95809e81cf module/channel-inbound-debounce
cb7f865c9953b5f0925c918b2bed29fb6629fbd643f4f69bd2bf345970864cbf module/channel-ingress-runtime
af775123f5526907732b7bebd6e2258ffe4ec733b2435007361bf7aba5b4eaf4 module/channel-lifecycle
0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging
7ea11f024148b93698541b6407b270876746b1b579c312a793920293da567e50 module/channel-message
44c965b7e16a07d81f94a8447c9386669d11f20ec7efe23a7ec6558287de726d module/channel-outbound
3328f581a37b5361dd20c92de9960717a62af49cda86866099e7184c5878435a module/channel-pairing
dd59d1fcfbc67c05cc6e61ff7fc4829c71439cfbebd32122a941282dacd4b2f2 module/channel-plugin-common
f5793573fe2d002c07c315090b01f7a244d703784c1a444ebd3977e51aaa95e8 module/channel-message
3db0b282a857a6f692942fe337d43331f18b51c0b1bcb00c697bef10a96f8b79 module/channel-outbound
e38886dbf313f08ba3ae647bd8a8449c3e69cedf8198bb541711dd81631627dc module/channel-pairing
bae268499c1cecf06a94bc62e5e33e90c333fc60c85392be3c38b9782f07ef00 module/channel-plugin-common
113d135501f2777f308c4c3a59dc29f722f8c385ddfe5628994f14ed37246442 module/channel-policy
c3bdfac92ace16eccca1bd44c95a3e73e39d7f996f1714cdfdf102647305df55 module/channel-reply-pipeline
482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context
@@ -51,8 +51,8 @@ b89224a72be296c82eeb1933b179ec0479627e80e1c61717b6cc7a778dbfa5b2 module/channel
67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config
1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
fcb5f1e6b515abf23e7efa999e8e73de0b69ec093bab5ae64464ce3c1faf679e module/command-auth
0781330f08e2e68f24650355778506dc2011a7b263c14a26573933f19bf4a1c1 module/command-auth-native
a17f34e9a20e4314e35df1ceeb95a2ac26721dbd0ea7d161070821a5484501bb module/command-auth
55f5fa40cd5350e28c13435ca329580a266a7e266d20db4e3fb88d22b40adca9 module/command-auth-native
4db2a3623b116976a2e4cb366163194a8073c8d56a9c74105127bee59c7d07f4 module/command-detection
0f6cf0b06cd65f2bfded9d1054873166fa6ad22c71f4f1cc5afb7a49711cc365 module/command-primitives-runtime
03eb97e7b47a79ad4b40b37a2bac418c14f85e90969f70fd6fc5c80bb27b8c49 module/command-status
@@ -60,23 +60,23 @@ b56349e8d8034152be55161d4bb4c9bf69d0f68f7ea07431f28a4ee7a707d576 module/config-
9d2c42377ef981ea6732f96bd3a9c1a16fa2852936516a3fca5adc2b6b0b736e module/config-mutation
6c70300fd4bf84808f742687eaad92339fc942abd5cfa2378807a0756decdbd1 module/config-runtime
d5a157da395c2b548347b7fae054af0a90afbcae71c7171d0be5c66d92271328 module/conversation-runtime
afc89398aa03ff29b8b8f6a07b5a45c6bb3fcf990123d913b1d244a96534ab81 module/core
65bd4b4cb27829c7f128aafca177cfeec183a469b9e03100b1114cee5587c6ae module/dedupe-runtime
130097615b2034ba3a11a1351b36b33fafe902ba3e3611578e3d5c567eff7390 module/core
ab23fff8cbeb96db8b5f6794f8d1141a7233297aeb9734a236c2dfb47cb97e56 module/dedupe-runtime
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
4fe9beab67598950c144d86add5fd3b1ce475aaf37644d2f9798703052d98210 module/diagnostic-runtime
734898717c8669f1c3a35dca93db121508368edac7dafcd1ccce71f432680979 module/directory-runtime
2ee5dba036aaefad76e440f15f5f5dd4fa329e19d3b5f63eb2797da84697cb19 module/discord
f55c941e465fc7dcd629005274f747ed52600b537e7ce0e87882697b8e2858c0 module/discord
2b01f2e5a52713158665372b358fb6059903f68b6efbedff700741d9feca7696 module/error-runtime
03eeafd10471b94a2651e0d42380a32f5ffd4f33c47e4b0161205093163e9407 module/extension-shared
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
64c88d5090e478fe9a16fb6845f8bef2b804d4590f812b6f2cf1ec2c3c857306 module/gateway-runtime
b5dfcf2977a334afb5a138ba9af196902ae8baf54a843c57496ed826e410178b module/gateway-runtime
575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access
f3595668fc4c20b2b34df67d3fa4d5432c16fa3be5fec9e63d8f14512ab6732f module/health
60d126e420e212415f25b6e90c2aca7513a1275e14b2bad27b06aeb6ab12eaa5 module/hook-runtime
da9d83537008db2c9339f3a4235ed2b7ee3ac986cacdc8bf8fdfaf1a025bd6a9 module/inbound-envelope
4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery
b2fb4eba715bbe12c02797e9da4713f6a114b85d6a683a141e342bf65f54f34c module/inbound-reply-dispatch
e5fdb21e7d557fb6830f9b7e9860bdd9b11f9ceb8c34e59b85e12a5ec5b022a3 module/infra-runtime
6faeea0ea0ca0dd41839452c1dc98f2361319fbef85830749c15a446251e40c0 module/inbound-reply-dispatch
539921d6c5596381beb17505394c67be44edb4a5ac533d555f07c43c4c991e8f module/infra-runtime
ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once
31449afd7ea7f0c8dc7dee439a3693c3d1092af67c6bed67dd34e7b7b50bc74e module/interactive-runtime
408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store
@@ -89,28 +89,28 @@ f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-m
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
b3fdb9f96d2724d5824063f434f3e482b605e4c06cad9770a94c78079dc36454 module/media-understanding
544a6d47a391e64574b146f649e8065b70a57955088b85cbdbdacb872889475a module/media-understanding-runtime
87d881f7d074bd0b47073f5d8b4e3f52d2ac2e2ecdcf13b3173bd1d74bb7e161 module/meeting-runtime
a683db04e6ef6dedc6c50b6a1071cd881a92f1b5bdf236e5a3bbbde65227ce80 module/meeting-runtime
a6aac1a3f85d3ee7dc9fbc7a1c6ef7dd00a9c2d3bdb6dc8d57a9704a821f806d module/memory-core-host-engine-foundation
e54a4fa88113a3ea31f52cfa26723705496214f8bf935c6f3771e6626af2c128 module/memory-host-core
a7295add0e30a7d9b322b59ce4c78579c3b81fe362ab6666f3323e1ac73e1b22 module/memory-host-core
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
8647dda41c0c3ab67191c397aa405de914fd8f277f55f550a307524e3c20d98d module/model-session-runtime
866a46928c9cb126eeccb353d131f779f99b635ab88de12d4d1cb86112c30662 module/models-provider-runtime
99e43d2c7db0bcf2648c156644175d1c8238650db4734221d3d56b54da45da0b module/models-provider-runtime
504c61546d566814cda2d8126b458efc9ad70b75f1406ab9d6635ac10225200c module/native-command-config-runtime
d808e6681668e70b2dbb2ac590b455072f98d54970593e25ef6ad5c2322d3a51 module/native-command-registry
ca6ee4fa75f976d590210b9ff6dc66374bb829a05d4f70972eef9137f5548b88 module/param-readers
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
3c73ab232d86b49ebb6e5f302da2a2a4b5c7f62bff5e4cc0d3505edc5ba4f5cd module/plugin-config-runtime
7de93d534a88300d5c701428b22d488e92e60d2282a2726e93b3f95e7b6b6a3e module/plugin-entry
03839a6d77ba6a314510cf18b4cf3d3a01ffc1ef8fa52d625a0a56addc4cc26e module/plugin-runtime
7a9e4a6a956776c40b4ea27e34834241c8181d2a1875532e2b178c823fac19f1 module/provider-auth
633ec2b88f7da1e5e6f9847d943ee04ad30510470cfd824643b62aab6179c7a5 module/provider-catalog-runtime
9c97ce162fb9c3f9039ca05d587da55243bcb64ebeb0fa224c0927bfbba6ad6b module/plugin-entry
160fa11a242c65bb95fc498eb540791f34fe369774fa35924c719a090add2f80 module/plugin-runtime
554e8adfe5e3b935470940e7dd15a6e178d56ff95441e6a6231f7643884dc6d9 module/provider-auth
348867eefd7442d9cbf79aef81e2fc08e4986c871fca1f6cb8025d68d37952d4 module/provider-catalog-runtime
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
4949fe3958d3b92b8d2e13ec0af9e65c88e1c7d3e39e70c306fd78c93b094e87 module/question-gateway-runtime
8f01e5ed6bccbb1e13cda4dd0a0b18f39af08ad36d7f0080b12217565f2e576c module/question-gateway-runtime
a479cd5c96a34c6f0a2ed4d4239aad63b56cb0e47a970848a141ad7fb67b9e11 module/reply-chunking
113538e1916037a0184103122fd1db73170686e0583acbcae6c2c1887cce95d5 module/reply-dispatch-runtime
c0e97a2e6490cd87e9777e1c197a12da368e2cfaac713f0264bdf5c93ee0f67c module/reply-dispatch-runtime
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
d3bf7e4a7fcaebc1cf3173f4fbb60203e6ec46257cddcca7096800df946708fc module/reply-payload
df1a7c6ab3ee5c487d5388871481fe499a044fa61d7bf7c8a95edbb4747293d9 module/reply-runtime
c8bb40393858a20698255aa82a05f89234320eb8f2bda3987b6e7f2d574f3c67 module/reply-runtime
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
02c0e5cae6772159a1c5ff43209de542ac7976dc30541d406f9dc6c37141a69d module/routing
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
@@ -118,20 +118,20 @@ bd15eb9689fd7070dc942cb4bd0db6c2c1d4fb1a971ce9d0a6349a5679309535 module/runtime
ccb6aad96b4ea156738f45bac04b6eda5826e17b36eb8b5e26567f400b3be217 module/runtime-config-snapshot
2e3c692a9f911ab227e34a9a3870f8e139f90ceaff631301b55ce712dec53736 module/runtime-env
7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy
0e1e9b7ffec401e73272fcf009472ace65f158cbaafc66c5a40f4c9cec8688d8 module/runtime-store
0843d2663cca6758528b26e66cc650cdf9882c4a41e8733ee593914e61a5f2f7 module/runtime-store
d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file
8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input
1806dba733bb6d88b4e997d3dc989761f0d7dde7638d96e1218705fd27f0921b module/secret-input-runtime
8e0e6d67db89eeee760a33ac984e7141bde171080365dddf534858b6abb3d56d module/secret-ref-runtime
c810981c42d32923e84c42d20137c9dc393aabc0ea079786e01d19bd59fa5277 module/security-runtime
94ad1c93b1d65d10cfbd3692423688d67e5b451bc6090190da0eb6a21d510ddb module/session-catalog
11788a4fc8f7f6a5956f1968db768b6e85bf9c35246cf7069484921ad46874cf module/session-discussion
dce5dfdf49e4355f1cf9432fc3faa3a95756812839a83c12b061525be4b7a286 module/session-catalog
e391a41f71a490877f000fdc67386086de79bcb3f7a2382ec44ee0b1edb0a89a module/session-discussion
fd2dcb08a59df7bb68c8f36f272d134f6904d12b9e192bc8d7a4858d3039b595 module/session-store-runtime
73f4a776c027d974f010250360822694486a8033bf1b51e96a0ff2440b05dbfe module/setup
f1ec91331ad72c3fd9edb2cb11b4c6a35acd55ef3e4f541c5907149eed21b15f module/setup-runtime
44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools
00e8794c6e7aabbdeec14189d885d4021f5118fcb08f7e34b85fafc473e62bed module/skill-commands-runtime
c249b3354e9345e0300cfd8a64d7ba5e9d87da6729f04875dc78a837332a6402 module/speech-settings
0b8fd0e49dc31c1f8be63e215ff5904f01adf523d88897a32253e9113b72058d module/speech-settings
f01b661de86de0d0b1d3bff395330092f3fe1114f4516968ca5fca79cf7eac50 module/ssrf-policy
0296f2c837f8116aa3a7e8b02d3de8b756d44e0a004842dbd24a5aa627f74a54 module/ssrf-runtime
eace34246d7a827b00a67bde258f8401bab35f527dcad29e6f982e4ae39cf013 module/state-paths
@@ -141,11 +141,11 @@ c6ea76a9fa7f56771cbfc54617aa9ceec3d87241bab38a094622ea9351f3cc87 module/telegra
aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path
87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking
2ce8b180da90b5b1665bc65dd9cae9af7eb4e0988cc2a64685e8e6f9cb9a87c3 module/text-runtime
1fb03e380ca0c99eb825fa96e1a4c8b6b01a3aa3243d8518c0890c04fec87c0b module/tool-plugin
93829a873d39e2d75e33117c5622038209ea0837589ade208599b7d34d463c5a module/tool-plugin
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
3c97f778d2844ba1bfd3e77fbccd3bfed94bc102053c091b00a0d3d2aaff6a99 module/tool-send
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
eb52639840e98b894c310775e3559027a2eaf48c29f8b6ffcd346ae48114c088 module/webhook-ingress
1a693b9aec4fb07268d32ab35b719093254cb4de8b544d6b7d0fff2290037db5 module/webhook-ingress
216e54c25ec0985fc483899d4de3d2582053a4fc3a2d1c98a8b5e7cdd2f136e1 module/webhook-request-guards
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod
+1 -1
View File
@@ -47,7 +47,7 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un
| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) |
| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` |
`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The suggestion's project directory must be a git checkout; invalid suggestions, including a non-git directory or blank prompt, are rejected when the tool records them. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting either creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`.
`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The suggestion's project directory must be a git checkout; invalid suggestions, including a non-git directory or blank prompt, are rejected when the tool records them. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting a suggestion can start it in a fresh managed worktree (the default), start it locally in a new session in the suggested checkout, send it to a cloud worker profile when one is configured, or deliver it into the source session. OpenClaw sends the full prompt to the selected destination while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`.
The tools are offered only when the initiating operator surface can receive and action Gateway task-suggestion events. Channel sessions and local/embedded TUI sessions do not receive them; channel transports need a portable typed task action before they can safely expose this flow. Suggestions are process-local and disappear when the Gateway restarts. Both tools remain in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically when the surface supports them.
@@ -10,6 +10,7 @@ export const GATEWAY_SERVER_CAPS = {
BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc",
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
TASK_SUGGESTIONS_ACCEPT_MODES: "taskSuggestions.acceptModes",
} as const;
/**
@@ -10,6 +10,15 @@ const TaskTldrSchema = Type.String({ minLength: 1, maxLength: 1_024, pattern: "\
const TaskCwdSchema = Type.String({ minLength: 1, maxLength: 4_096 });
const TaskSessionKeySchema = Type.String({ minLength: 1, maxLength: 512 });
const TaskAgentIdSchema = Type.String({ minLength: 1, maxLength: 128 });
const TaskSuggestionAcceptanceModeSchema = Type.Enum(
{
WORKTREE: "worktree",
LOCAL: "local",
CLOUD: "cloud",
SESSION: "session",
} as const,
{ type: "string" },
);
/** One model-proposed follow-up task waiting for operator action. */
export const TaskSuggestionSchema = closedObject({
@@ -54,8 +63,12 @@ export const TaskSuggestionResolutionSchema = Type.Union([
Type.Literal("expired"),
]);
/** Atomically claims a pending suggestion and starts its server-owned worktree session. */
export const TaskSuggestionsAcceptParamsSchema = closedObject({ taskId: TaskIdSchema });
/** Atomically claims a pending suggestion and starts it in the requested execution mode. */
export const TaskSuggestionsAcceptParamsSchema = closedObject({
taskId: TaskIdSchema,
mode: Type.Optional(TaskSuggestionAcceptanceModeSchema),
cloudProfileId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
});
export const TaskSuggestionsAcceptResultSchema = closedObject({
taskId: TaskIdSchema,
+1 -1
View File
@@ -114,7 +114,7 @@ function listWorkerEnvironments(context: GatewayRequestContext): WorkerEnvironme
return [];
}
}
function listWorkerProfiles(context: GatewayRequestContext) {
export function listWorkerProfiles(context: GatewayRequestContext) {
if (!context.workerEnvironmentService || !context.workerPlacementDispatchService) {
return [];
}
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js";
import { managedWorktrees } from "../../agents/worktrees/service.js";
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import {
abandonTaskSuggestionAcceptance,
beginTaskSuggestionAcceptance,
@@ -7,8 +12,13 @@ import {
} from "../task-suggestion-registry.js";
import { sessionCreateHandlers } from "./sessions-create.js";
import { sessionDeleteHandlers } from "./sessions-delete.js";
import { sessionDispatchHandlers } from "./sessions-dispatch.js";
import { taskSuggestionsHandlers } from "./task-suggestions.js";
import type { RespondFn } from "./types.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
const mocks = vi.hoisted(() => ({ handleChatSend: vi.fn() }));
vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.handleChatSend }));
type Method =
| "taskSuggestions.list"
@@ -17,16 +27,28 @@ type Method =
| "taskSuggestions.dismiss";
const GIT_CWD = process.cwd();
const SOURCE_SESSION_KEY = "agent:main:source";
async function call(method: Method, params: Record<string, unknown>, broadcast = vi.fn()) {
async function call(
method: Method,
params: Record<string, unknown>,
broadcast = vi.fn(),
overrides: {
client?: GatewayClient | null;
context?: Partial<GatewayRequestContext>;
} = {},
) {
const calls: Parameters<RespondFn>[] = [];
const respond: RespondFn = (...args) => {
calls.push(args);
};
await taskSuggestionsHandlers[method]?.({
req: { type: "req", id: "request-1", method, params },
params,
respond,
context: { broadcast, getRuntimeConfig: () => ({}) },
client: overrides.client ?? null,
isWebchatConnect: () => true,
context: { broadcast, getRuntimeConfig: () => ({}), ...overrides.context },
} as never);
return { response: calls[0], broadcast };
}
@@ -47,12 +69,59 @@ async function dismissPendingTaskSuggestions(): Promise<void> {
}
}
beforeEach(dismissPendingTaskSuggestions);
beforeEach(async () => {
await dismissPendingTaskSuggestions();
mocks.handleChatSend.mockReset();
mocks.handleChatSend.mockImplementation(async ({ respond }: { respond: RespondFn }) => {
respond(true, { runId: "suggested-task-run", status: "started" }, undefined);
});
});
afterEach(async () => {
await dismissPendingTaskSuggestions();
vi.restoreAllMocks();
closeOpenClawAgentDatabasesForTest();
});
function operatorClient(): GatewayClient {
return {
connect: {
minProtocol: 1,
maxProtocol: 1,
client: {
id: "openclaw-control-ui",
version: "test",
platform: "test",
mode: "webchat",
},
role: "operator",
scopes: ["operator.admin"],
caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS],
},
};
}
function configuredCloudContext(
profiles: Record<string, { provider: string }> = { primary: { provider: "test" } },
): Partial<GatewayRequestContext> {
return {
workerEnvironmentService: {} as never,
workerPlacementDispatchService: {} as never,
getRuntimeConfig: () => ({ cloudWorkers: { profiles } }),
};
}
async function createSourceSuggestion() {
const created = await call("taskSuggestions.create", {
title: "Fix the source session",
prompt: "Apply the focused fix in this session.",
tldr: "The current session already owns the relevant context.",
cwd: GIT_CWD,
sessionKey: SOURCE_SESSION_KEY,
agentId: "main",
});
return (requirePayload(created) as { taskId: string }).taskId;
}
describe("task suggestion gateway methods", () => {
it("creates, lists, and resolves an ephemeral suggestion", async () => {
const created = await call("taskSuggestions.create", {
@@ -258,6 +327,366 @@ describe("task suggestion gateway methods", () => {
expect(createSession).toHaveBeenCalledTimes(1);
});
it("starts local acceptance in the suggestion cwd without a worktree", async () => {
const taskId = await createSourceSuggestion();
const createSession = vi
.spyOn(sessionCreateHandlers, "sessions.create")
.mockImplementation(async ({ params, respond }) => {
expect(params).toMatchObject({
agentId: "main",
parentSessionKey: SOURCE_SESSION_KEY,
label: "Fix the source session",
task: "Apply the focused fix in this session.",
cwd: GIT_CWD,
});
expect(params).not.toHaveProperty("worktree");
respond(true, { key: (params as { key: string }).key, runStarted: true }, undefined);
});
const accepted = await call("taskSuggestions.accept", { taskId, mode: "local" });
expect(accepted.response?.[0]).toBe(true);
expect(createSession).toHaveBeenCalledTimes(1);
});
it("rolls back a local session when its initial task does not start", async () => {
const taskId = await createSourceSuggestion();
let sessionKey = "";
vi.spyOn(sessionCreateHandlers, "sessions.create").mockImplementation(
async ({ params, respond }) => {
sessionKey = (params as { key: string }).key;
expect(params).not.toHaveProperty("worktree");
expect(params).toMatchObject({ cwd: GIT_CWD });
respond(true, { key: sessionKey, runStarted: false }, undefined);
},
);
const deleteSession = vi
.spyOn(sessionDeleteHandlers, "sessions.delete")
.mockImplementation(async ({ respond }) => {
respond(true, { deleted: true }, undefined);
});
const accepted = await call("taskSuggestions.accept", { taskId, mode: "local" });
const listed = await call("taskSuggestions.list", {});
expect(accepted.response?.[0]).toBe(false);
expect(deleteSession).toHaveBeenCalledTimes(1);
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
});
it("creates, dispatches, then sends a cloud acceptance", async () => {
const taskId = await createSourceSuggestion();
const sequence: string[] = [];
let sessionKey = "";
vi.spyOn(sessionCreateHandlers, "sessions.create").mockImplementation(
async ({ params, respond }) => {
sequence.push("create");
sessionKey = (params as { key: string }).key;
expect(params).toEqual({
key: sessionKey,
agentId: "main",
parentSessionKey: SOURCE_SESSION_KEY,
label: "Fix the source session",
worktree: true,
cwd: GIT_CWD,
});
respond(true, { key: sessionKey, runStarted: false }, undefined);
},
);
vi.spyOn(sessionDispatchHandlers, "sessions.dispatch").mockImplementation(
async ({ params, respond }) => {
sequence.push("dispatch");
expect(params).toEqual({ key: sessionKey, agentId: "main", profileId: "primary" });
respond(true, { ok: true, key: sessionKey }, undefined);
},
);
mocks.handleChatSend.mockImplementationOnce(async ({ params, respond }) => {
sequence.push("send");
expect(params).toEqual({
sessionKey,
agentId: "main",
message: "Apply the focused fix in this session.",
idempotencyKey: `task-suggestion:${taskId}`,
});
respond(true, { runId: "cloud-run", status: "started" }, undefined);
});
const accepted = await call(
"taskSuggestions.accept",
{ taskId, mode: "cloud", cloudProfileId: "primary" },
vi.fn(),
{ client: operatorClient(), context: configuredCloudContext() },
);
expect(accepted.response?.[1]).toEqual({ taskId, key: sessionKey });
expect(sequence).toEqual(["create", "dispatch", "send"]);
});
it.each([
{
label: "no configured profiles",
context: configuredCloudContext({}),
cloudProfileId: "primary",
message: "no cloud worker profiles configured",
},
{
label: "an unknown profile",
context: configuredCloudContext(),
cloudProfileId: "missing",
message: "unknown cloud worker profile: missing",
},
{
label: "a missing profile id",
context: configuredCloudContext(),
cloudProfileId: undefined,
message: "cloudProfileId is required for cloud mode",
},
])("rejects cloud acceptance with $label before handler calls", async (testCase) => {
const taskId = await createSourceSuggestion();
const createSession = vi.spyOn(sessionCreateHandlers, "sessions.create");
const dispatchSession = vi.spyOn(sessionDispatchHandlers, "sessions.dispatch");
const accepted = await call(
"taskSuggestions.accept",
{
taskId,
mode: "cloud",
...(testCase.cloudProfileId ? { cloudProfileId: testCase.cloudProfileId } : {}),
},
vi.fn(),
{ context: testCase.context },
);
expect(accepted.response?.[0]).toBe(false);
expect(accepted.response?.[2]).toMatchObject({
code: "INVALID_REQUEST",
message: testCase.message,
});
expect(createSession).not.toHaveBeenCalled();
expect(dispatchSession).not.toHaveBeenCalled();
expect(mocks.handleChatSend).not.toHaveBeenCalled();
});
it("fully rolls back a cloud draft when dispatch fails", async () => {
const taskId = await createSourceSuggestion();
let sessionKey = "";
vi.spyOn(sessionCreateHandlers, "sessions.create").mockImplementation(
async ({ params, respond }) => {
sessionKey = (params as { key: string }).key;
respond(true, { key: sessionKey }, undefined);
},
);
vi.spyOn(sessionDispatchHandlers, "sessions.dispatch").mockImplementation(
async ({ respond }) => {
respond(false, undefined, { code: "UNAVAILABLE", message: "dispatch unavailable" });
},
);
const deleteSession = vi
.spyOn(sessionDeleteHandlers, "sessions.delete")
.mockImplementation(async ({ params, respond }) => {
expect(params).toMatchObject({ key: sessionKey, agentId: "main" });
respond(true, { deleted: true }, undefined);
});
const accepted = await call(
"taskSuggestions.accept",
{ taskId, mode: "cloud", cloudProfileId: "primary" },
vi.fn(),
{ context: configuredCloudContext() },
);
const listed = await call("taskSuggestions.list", {});
expect(accepted.response?.[0]).toBe(false);
expect(accepted.response?.[2]).toMatchObject({ message: "dispatch unavailable" });
expect(deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.handleChatSend).not.toHaveBeenCalled();
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
});
it("sends an idle session acceptance as a new turn and replays its source key", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
await upsertSessionEntry(
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
{ sessionId: "source-session", updatedAt: 1 },
);
const taskId = await createSourceSuggestion();
const client = operatorClient();
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
client,
context: { chatAbortControllers: new Map() },
});
const replay = await call("taskSuggestions.accept", { taskId, mode: "session" });
expect(accepted.response?.[1]).toEqual({ taskId, key: SOURCE_SESSION_KEY });
expect(replay.response?.[1]).toEqual({ taskId, key: SOURCE_SESSION_KEY });
expect(mocks.handleChatSend).toHaveBeenCalledTimes(1);
expect(mocks.handleChatSend).toHaveBeenCalledWith(
expect.objectContaining({
client,
params: {
sessionKey: SOURCE_SESSION_KEY,
agentId: "main",
sessionId: "source-session",
message: "Apply the focused fix in this session.",
idempotencyKey: `task-suggestion:${taskId}`,
},
}),
);
});
});
it("steers a session acceptance into its one exact active run", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
await upsertSessionEntry(
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
{ sessionId: "source-session", updatedAt: 1 },
);
const taskId = await createSourceSuggestion();
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
client: operatorClient(),
context: {
chatAbortControllers: new Map([
[
"run-one",
{ sessionKey: SOURCE_SESSION_KEY, sessionId: "source-session", agentId: "main" },
],
]) as never,
},
});
expect(accepted.response?.[0]).toBe(true);
expect(mocks.handleChatSend).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ queueMode: "steer", expectedRunId: "run-one" }),
}),
);
});
});
it.each([
{ label: "multiple run IDs", runIds: ["run-one", "run-two"], projected: false },
{ label: "no exact run ID", runIds: [], projected: true },
])("rejects an active session with $label and restores the suggestion", async (testCase) => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
await upsertSessionEntry(
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
{ sessionId: "source-session", updatedAt: 1 },
);
const taskId = await createSourceSuggestion();
const deleteSession = vi.spyOn(sessionDeleteHandlers, "sessions.delete");
if (testCase.projected) {
registerAgentRunContext("projected-task-suggestion-run", {
projectSessionActive: true,
sessionId: "source-session",
sessionKey: SOURCE_SESSION_KEY,
});
}
const activeRuns = new Map(
testCase.runIds.map((runId) => [
runId,
{
sessionKey: SOURCE_SESSION_KEY,
sessionId: "source-session",
agentId: "main",
runId,
},
]),
);
try {
const accepted = await call(
"taskSuggestions.accept",
{ taskId, mode: "session" },
vi.fn(),
{
client: operatorClient(),
context: { chatAbortControllers: activeRuns as never },
},
);
const listed = await call("taskSuggestions.list", {});
expect(accepted.response?.[0]).toBe(false);
expect(accepted.response?.[2]).toMatchObject({
code: "INVALID_REQUEST",
details: { code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS" },
});
if (testCase.projected) {
expect(accepted.response?.[2]?.message).toBe(
"active session run has no exact dispatch identity; refresh and retry",
);
}
expect(mocks.handleChatSend).not.toHaveBeenCalled();
expect(deleteSession).not.toHaveBeenCalled();
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
} finally {
if (testCase.projected) {
clearAgentRunContext("projected-task-suggestion-run");
}
}
});
});
it("rejects a missing source session and restores the suggestion", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const taskId = await createSourceSuggestion();
const deleteSession = vi.spyOn(sessionDeleteHandlers, "sessions.delete");
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
client: operatorClient(),
context: { chatAbortControllers: new Map() },
});
const listed = await call("taskSuggestions.list", {});
expect(accepted.response?.[0]).toBe(false);
expect(accepted.response?.[2]).toMatchObject({
code: "INVALID_REQUEST",
message: "source session no longer exists; start it in a worktree instead",
});
expect(deleteSession).not.toHaveBeenCalled();
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
});
});
it("restores a session-mode suggestion after delivery failure without deleting its source", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
await upsertSessionEntry(
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
{ sessionId: "source-session", updatedAt: 1 },
);
const taskId = await createSourceSuggestion();
const deleteSession = vi.spyOn(sessionDeleteHandlers, "sessions.delete");
mocks.handleChatSend.mockImplementationOnce(async ({ respond }: { respond: RespondFn }) => {
respond(false, undefined, { code: "UNAVAILABLE", message: "delivery unavailable" });
});
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
client: operatorClient(),
context: { chatAbortControllers: new Map() },
});
const listed = await call("taskSuggestions.list", {});
expect(accepted.response?.[0]).toBe(false);
expect(accepted.response?.[2]).toMatchObject({ message: "delivery unavailable" });
expect(deleteSession).not.toHaveBeenCalled();
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
});
});
it("rejects an invalid acceptance mode before claiming the suggestion", async () => {
const taskId = await createSourceSuggestion();
const createSession = vi.spyOn(sessionCreateHandlers, "sessions.create");
const accepted = await call("taskSuggestions.accept", { taskId, mode: "remote" });
const listed = await call("taskSuggestions.list", {});
expect(accepted.response?.[0]).toBe(false);
expect(accepted.response?.[2]).toMatchObject({ code: "INVALID_REQUEST" });
expect(accepted.response?.[2]?.message).toContain("mode");
expect(createSession).not.toHaveBeenCalled();
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
});
it("rolls back an empty session and keeps a failed seed suggestion pending", async () => {
const created = await call("taskSuggestions.create", {
title: "Add coverage",
+288 -50
View File
@@ -5,6 +5,7 @@ import {
errorShape,
formatValidationErrors,
type TaskSuggestion,
type TaskSuggestionsAcceptParams,
type TaskSuggestionsAcceptResult,
validateTaskSuggestionsAcceptParams,
validateTaskSuggestionsCreateParams,
@@ -14,6 +15,7 @@ import {
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { insideGitCheckout } from "../../agents/worktrees/git.js";
import { managedWorktrees } from "../../agents/worktrees/service.js";
import { resolveSessionWorkStartError } from "../../config/sessions.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { buildDashboardSessionKey } from "../session-create-service.js";
@@ -27,8 +29,12 @@ import {
dismissTaskSuggestion,
listTaskSuggestions,
} from "../task-suggestion-registry.js";
import { handleChatSend } from "./chat-send-handler.js";
import { listWorkerProfiles } from "./environments.js";
import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js";
import { sessionCreateHandlers } from "./sessions-create.js";
import { sessionDeleteHandlers } from "./sessions-delete.js";
import { sessionDispatchHandlers } from "./sessions-dispatch.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
function invalidParams(method: string, errors: Parameters<typeof formatValidationErrors>[0]) {
@@ -42,6 +48,8 @@ type TaskSuggestionAcceptanceResult =
| { ok: true; result: TaskSuggestionsAcceptResult }
| { ok: false; error: NonNullable<Parameters<RespondFn>[2]> };
type TaskSuggestionAcceptMode = NonNullable<TaskSuggestionsAcceptParams["mode"]>;
const activeAcceptances = new Map<string, Promise<TaskSuggestionAcceptanceResult>>();
function abandonSuggestedTaskAcceptance(
@@ -143,18 +151,98 @@ async function failSuggestedTaskSession(params: {
};
}
function finishSuggestedTaskAcceptance(params: {
taskId: string;
sessionKey: string;
options: GatewayRequestHandlerOptions;
}): TaskSuggestionAcceptanceResult {
completeTaskSuggestionAcceptance(params.taskId, params.sessionKey);
params.options.context.broadcast(
"task.suggestion",
{ action: "resolved", taskId: params.taskId, resolution: "accepted" },
{ dropIfSlow: true },
);
return { ok: true, result: { taskId: params.taskId, key: params.sessionKey } };
}
function failSuggestedTaskDelivery(params: {
taskId: string;
options: GatewayRequestHandlerOptions;
error: NonNullable<Parameters<RespondFn>[2]>;
}): TaskSuggestionAcceptanceResult {
// Session-mode delivery owns only the registry claim. Never roll back the
// operator-owned source session or its worktree when message delivery fails.
const restored = cancelTaskSuggestionAcceptance(params.taskId);
if (restored) {
params.options.context.broadcast(
"task.suggestion",
{ action: "created", suggestion: restored },
{ dropIfSlow: true },
);
}
return { ok: false, error: params.error };
}
function resolveSuggestionAgentId(
suggestion: TaskSuggestion,
options: GatewayRequestHandlerOptions,
): string {
return normalizeAgentId(
suggestion.agentId ??
parseAgentSessionKey(suggestion.sessionKey)?.agentId ??
resolveDefaultAgentId(options.context.getRuntimeConfig()),
);
}
async function sendSuggestedTaskPrompt(params: {
taskId: string;
suggestion: TaskSuggestion;
options: GatewayRequestHandlerOptions;
sessionKey: string;
agentId: string;
sessionId?: string;
activeRunId?: string;
}): Promise<Parameters<RespondFn> | undefined> {
let response: Parameters<RespondFn> | undefined;
const chatParams = {
sessionKey: params.sessionKey,
agentId: params.agentId,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
message: params.suggestion.prompt,
...(params.activeRunId
? { queueMode: "steer" as const, expectedRunId: params.activeRunId }
: {}),
idempotencyKey: `task-suggestion:${params.taskId}`,
};
await handleChatSend({
...params.options,
req: { ...params.options.req, method: "chat.send", params: chatParams },
params: chatParams,
respond: (...args) => {
response = args;
},
});
return response;
}
async function createSuggestedTaskSession(params: {
taskId: string;
suggestion: TaskSuggestion;
options: GatewayRequestHandlerOptions;
mode: Exclude<TaskSuggestionAcceptMode, "session">;
cloudProfileId?: string;
}): Promise<TaskSuggestionAcceptanceResult> {
let sessionResponse: Parameters<RespondFn> | undefined;
const agentId = normalizeAgentId(
params.suggestion.agentId ??
parseAgentSessionKey(params.suggestion.sessionKey)?.agentId ??
resolveDefaultAgentId(params.options.context.getRuntimeConfig()),
);
const agentId = resolveSuggestionAgentId(params.suggestion, params.options);
const sessionKey = buildDashboardSessionKey(agentId);
const fail = (key: string, error: NonNullable<Parameters<RespondFn>[2]>) =>
failSuggestedTaskSession({
taskId: params.taskId,
sessionKey: key,
agentId,
options: params.options,
error,
});
try {
await sessionCreateHandlers["sessions.create"]?.({
...params.options,
@@ -163,8 +251,8 @@ async function createSuggestedTaskSession(params: {
agentId,
parentSessionKey: params.suggestion.sessionKey,
label: params.suggestion.title,
task: params.suggestion.prompt,
worktree: true,
...(params.mode === "cloud" ? {} : { task: params.suggestion.prompt }),
...(params.mode === "local" ? {} : { worktree: true }),
cwd: params.suggestion.cwd,
},
respond: (...args) => {
@@ -172,44 +260,82 @@ async function createSuggestedTaskSession(params: {
},
});
} catch (error) {
return await failSuggestedTaskSession({
taskId: params.taskId,
sessionKey,
agentId,
options: params.options,
error: errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)),
});
return await fail(sessionKey, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
if (!sessionResponse) {
return await failSuggestedTaskSession({
taskId: params.taskId,
return await fail(
sessionKey,
agentId,
options: params.options,
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"),
});
errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"),
);
}
const [ok, payload, error] = sessionResponse;
const [ok, payload, sessionError] = sessionResponse;
if (!ok) {
return await failSuggestedTaskSession({
taskId: params.taskId,
return await fail(
sessionKey,
agentId,
options: params.options,
error: error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create suggested task"),
});
sessionError ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create suggested task"),
);
}
const key =
payload && typeof payload === "object" && typeof (payload as { key?: unknown }).key === "string"
? (payload as { key: string }).key
: undefined;
if (!key) {
return await failSuggestedTaskSession({
taskId: params.taskId,
return await fail(
sessionKey,
agentId,
errorShape(ErrorCodes.UNAVAILABLE, "sessions.create returned no session key"),
);
}
if (params.mode === "cloud") {
let dispatchResponse: Parameters<RespondFn> | undefined;
try {
await sessionDispatchHandlers["sessions.dispatch"]?.({
...params.options,
params: { key, agentId, profileId: params.cloudProfileId },
respond: (...args) => {
dispatchResponse = args;
},
});
} catch (error) {
return await fail(key, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
if (!dispatchResponse?.[0]) {
return await fail(
key,
dispatchResponse?.[2] ??
errorShape(
ErrorCodes.UNAVAILABLE,
dispatchResponse
? "failed to dispatch suggested task"
: "sessions.dispatch did not respond",
),
);
}
let sendResponse: Parameters<RespondFn> | undefined;
try {
sendResponse = await sendSuggestedTaskPrompt({
taskId: params.taskId,
suggestion: params.suggestion,
options: params.options,
sessionKey: key,
agentId,
});
} catch (error) {
return await fail(key, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
if (!sendResponse?.[0]) {
return await fail(
key,
sendResponse?.[2] ??
errorShape(
ErrorCodes.UNAVAILABLE,
sendResponse ? "failed to deliver suggested task" : "chat.send did not respond",
),
);
}
return finishSuggestedTaskAcceptance({
taskId: params.taskId,
sessionKey: key,
options: params.options,
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create returned no session key"),
});
}
const result = payload as { runError?: unknown; runStarted?: unknown };
@@ -220,21 +346,96 @@ async function createSuggestedTaskSession(params: {
typeof (result.runError as { message?: unknown }).message === "string"
? (result.runError as { message: string }).message
: "initial task did not start";
return await failSuggestedTaskSession({
taskId: params.taskId,
sessionKey: key,
agentId,
options: params.options,
error: errorShape(ErrorCodes.UNAVAILABLE, runMessage),
});
return await fail(key, errorShape(ErrorCodes.UNAVAILABLE, runMessage));
}
completeTaskSuggestionAcceptance(params.taskId, key);
params.options.context.broadcast(
"task.suggestion",
{ action: "resolved", taskId: params.taskId, resolution: "accepted" },
{ dropIfSlow: true },
);
return { ok: true, result: { taskId: params.taskId, key } };
return finishSuggestedTaskAcceptance({
taskId: params.taskId,
sessionKey: key,
options: params.options,
});
}
async function deliverSuggestedTaskToSourceSession(params: {
taskId: string;
suggestion: TaskSuggestion;
options: GatewayRequestHandlerOptions;
}): Promise<TaskSuggestionAcceptanceResult> {
const agentId = resolveSuggestionAgentId(params.suggestion, params.options);
const fail = (error: NonNullable<Parameters<RespondFn>[2]>) =>
failSuggestedTaskDelivery({ taskId: params.taskId, options: params.options, error });
let source: ReturnType<typeof loadSessionEntryReadOnly>;
try {
source = loadSessionEntryReadOnly(params.suggestion.sessionKey, { agentId });
} catch (error) {
return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
if (!source.entry?.sessionId) {
return fail(
errorShape(
ErrorCodes.INVALID_REQUEST,
"source session no longer exists; start it in a worktree instead",
),
);
}
const lifecycleError = resolveSessionWorkStartError(source.canonicalKey, source.entry);
if (lifecycleError) {
return fail(errorShape(ErrorCodes.INVALID_REQUEST, lifecycleError));
}
let activeRunState: ReturnType<typeof resolveVisibleActiveSessionRunState>;
try {
activeRunState = resolveVisibleActiveSessionRunState({
context: params.options.context,
requestedKey: params.suggestion.sessionKey,
canonicalKey: source.canonicalKey,
sessionId: source.entry.sessionId,
agentId,
});
} catch (error) {
return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
if (activeRunState.active && activeRunState.runIds.length !== 1) {
const message =
activeRunState.runIds.length === 0
? "active session run has no exact dispatch identity; refresh and retry"
: "session has multiple active runs; choose the target run before accepting the task suggestion";
return fail(
errorShape(ErrorCodes.INVALID_REQUEST, message, {
retryable: false,
details: {
code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS",
sessionKey: params.suggestion.sessionKey,
},
}),
);
}
let sendResponse: Parameters<RespondFn> | undefined;
try {
sendResponse = await sendSuggestedTaskPrompt({
taskId: params.taskId,
suggestion: params.suggestion,
options: params.options,
sessionKey: params.suggestion.sessionKey,
agentId,
sessionId: source.entry.sessionId,
activeRunId: activeRunState.runIds[0],
});
} catch (error) {
return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
if (!sendResponse?.[0]) {
return fail(
sendResponse?.[2] ??
errorShape(
ErrorCodes.UNAVAILABLE,
sendResponse ? "failed to deliver suggested task" : "chat.send did not respond",
),
);
}
return finishSuggestedTaskAcceptance({
taskId: params.taskId,
sessionKey: params.suggestion.sessionKey,
options: params.options,
});
}
export const taskSuggestionsHandlers: GatewayRequestHandlers = {
@@ -328,6 +529,33 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = {
);
return;
}
const mode = params.mode ?? "worktree";
let cloudProfileId: string | undefined;
if (mode === "cloud") {
const profiles = listWorkerProfiles(options.context);
if (profiles.length === 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "no cloud worker profiles configured"),
);
return;
}
cloudProfileId = params.cloudProfileId;
if (!cloudProfileId || !profiles.some((profile) => profile.id === cloudProfileId)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
cloudProfileId
? `unknown cloud worker profile: ${cloudProfileId}`
: "cloudProfileId is required for cloud mode",
),
);
return;
}
}
const active = activeAcceptances.get(params.taskId);
if (active) {
const outcome = await active;
@@ -354,11 +582,21 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = {
);
return;
}
const pending = createSuggestedTaskSession({
taskId: params.taskId,
suggestion: acceptance.suggestion,
options,
}).catch((error: unknown) => {
const pending = (
mode === "session"
? deliverSuggestedTaskToSourceSession({
taskId: params.taskId,
suggestion: acceptance.suggestion,
options,
})
: createSuggestedTaskSession({
taskId: params.taskId,
suggestion: acceptance.suggestion,
options,
mode,
...(cloudProfileId ? { cloudProfileId } : {}),
})
).catch((error: unknown) => {
abandonSuggestedTaskAcceptance(params.taskId, options);
throw error;
});
@@ -177,6 +177,9 @@ export function registerDefaultAuthTokenSuite(): void {
expect(payload?.features?.capabilities).toContain(
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
);
expect(payload?.features?.capabilities).toContain(
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
);
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR);
expect(payload?.policy?.allowedSessionVisibilities).toEqual([
@@ -102,6 +102,7 @@ export async function sendGatewayHello(
GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC,
GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT,
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
],
},
snapshot,
+64 -5
View File
@@ -1,5 +1,6 @@
// Covers gateway-backed chat behavior used by the TUI backend.
import { afterEach, describe, expect, it, vi } from "vitest";
// Covers gateway-backed chat behavior used by the TUI backend.
import { GATEWAY_SERVER_CAPS } from "../../packages/gateway-protocol/src/index.js";
const { GatewayChatClient } = await import("./gateway-chat.js");
const { GatewayClientRequestError } = await import("../gateway/client.js");
@@ -399,7 +400,7 @@ describe("GatewayChatClient", () => {
});
});
it("lists, accepts, and dismisses task suggestions through the gateway", async () => {
it("lists profiles and serializes task suggestion acceptance modes", async () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
@@ -417,29 +418,60 @@ describe("GatewayChatClient", () => {
const request = vi
.fn()
.mockResolvedValueOnce({ suggestions: [suggestion] })
.mockResolvedValueOnce({ profiles: [{ id: "build", providerId: "crabbox" }] })
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:task" })
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:task" })
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:local" })
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:session" })
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:cloud" })
.mockResolvedValueOnce({ taskId: "task_2", dismissed: true });
client.hello = {
features: {
methods: ["taskSuggestions.list", "taskSuggestions.accept", "taskSuggestions.dismiss"],
methods: [
"environments.list",
"taskSuggestions.list",
"taskSuggestions.accept",
"taskSuggestions.dismiss",
],
capabilities: [GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES],
},
auth: { role: "operator", scopes: ["operator.admin"] },
} as never;
(client as unknown as { client: { request: typeof request } }).client.request = request;
await expect(client.listTaskSuggestions()).resolves.toEqual([suggestion]);
await expect(client.listCloudWorkerProfiles()).resolves.toEqual(["build"]);
await expect(client.acceptTaskSuggestion("task_1")).resolves.toEqual({
taskId: "task_1",
key: "agent:main:task",
});
await client.acceptTaskSuggestion("task_1", "worktree");
await client.acceptTaskSuggestion("task_1", "local");
await client.acceptTaskSuggestion("task_1", "session");
await client.acceptTaskSuggestion("task_1", "cloud", "build");
await expect(client.dismissTaskSuggestion("task_2")).resolves.toEqual({
taskId: "task_2",
dismissed: true,
});
expect(request).toHaveBeenNthCalledWith(1, "taskSuggestions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "taskSuggestions.accept", { taskId: "task_1" });
expect(request).toHaveBeenNthCalledWith(3, "taskSuggestions.dismiss", { taskId: "task_2" });
expect(request).toHaveBeenNthCalledWith(2, "environments.list", {});
expect(request).toHaveBeenNthCalledWith(3, "taskSuggestions.accept", { taskId: "task_1" });
expect(request).toHaveBeenNthCalledWith(4, "taskSuggestions.accept", { taskId: "task_1" });
expect(request).toHaveBeenNthCalledWith(5, "taskSuggestions.accept", {
taskId: "task_1",
mode: "local",
});
expect(request).toHaveBeenNthCalledWith(6, "taskSuggestions.accept", {
taskId: "task_1",
mode: "session",
});
expect(request).toHaveBeenNthCalledWith(7, "taskSuggestions.accept", {
taskId: "task_1",
mode: "cloud",
cloudProfileId: "build",
});
expect(request).toHaveBeenNthCalledWith(8, "taskSuggestions.dismiss", { taskId: "task_2" });
});
it("derives task suggestion actions from negotiated methods and scopes", () => {
@@ -456,6 +488,20 @@ describe("GatewayChatClient", () => {
expect(client.getTaskSuggestionActionCapabilities()).toEqual({
canAccept: false,
canAcceptModes: false,
canDismiss: true,
});
client.hello = {
features: {
methods: ["taskSuggestions.accept", "taskSuggestions.dismiss"],
capabilities: [GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES],
},
auth: { role: "operator", scopes: ["operator.admin"] },
} as never;
expect(client.getTaskSuggestionActionCapabilities()).toEqual({
canAccept: true,
canAcceptModes: true,
canDismiss: true,
});
});
@@ -470,6 +516,19 @@ describe("GatewayChatClient", () => {
(client as unknown as { client: { request: typeof request } }).client.request = request;
await expect(client.listTaskSuggestions()).resolves.toEqual([]);
await expect(client.listCloudWorkerProfiles()).resolves.toEqual([]);
expect(request).not.toHaveBeenCalled();
});
it("keeps cloud profile discovery failures quiet", async () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
});
const request = vi.fn().mockRejectedValue(new Error("not available"));
client.hello = { features: { methods: ["environments.list"] } } as never;
(client as unknown as { client: { request: typeof request } }).client.request = request;
await expect(client.listCloudWorkerProfiles()).resolves.toEqual([]);
});
});
+31 -4
View File
@@ -12,15 +12,18 @@ import {
} from "../../packages/gateway-protocol/src/connect-error-details.js";
import {
type HelloOk,
GATEWAY_SERVER_CAPS,
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
type CommandEntry,
type CommandsListParams,
type CommandsListResult,
type EnvironmentsListResult,
type SessionsListParams,
type SessionsPatchResult,
type SessionsPatchParams,
type TaskSuggestionsAcceptResult,
type TaskSuggestionsAcceptParams,
type TaskSuggestionsListResult,
} from "../../packages/gateway-protocol/src/index.js";
import { getRuntimeConfig } from "../config/config.js";
@@ -52,6 +55,7 @@ import type {
TuiSessionCreateOptions,
TuiSessionMutationResult,
TuiChatSendResult,
TuiTaskSuggestionAcceptMode,
} from "./tui-backend.js";
type GatewayConnectionOptions = {
@@ -440,6 +444,7 @@ export class GatewayChatClient implements TuiBackend {
getTaskSuggestionActionCapabilities() {
const auth = this.hello?.auth;
const methods = this.hello?.features?.methods;
const capabilities = this.hello?.features?.capabilities;
const allows = (method: string, scope: "operator.admin" | "operator.write") =>
Array.isArray(methods) &&
methods.includes(method) &&
@@ -453,6 +458,9 @@ export class GatewayChatClient implements TuiBackend {
);
return {
canAccept: allows("taskSuggestions.accept", "operator.admin"),
canAcceptModes:
Array.isArray(capabilities) &&
capabilities.includes(GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES),
canDismiss: allows("taskSuggestions.dismiss", "operator.write"),
};
}
@@ -469,10 +477,29 @@ export class GatewayChatClient implements TuiBackend {
return result.suggestions;
}
async acceptTaskSuggestion(taskId: string) {
return await this.client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", {
taskId,
});
async listCloudWorkerProfiles() {
if (this.hello?.features?.methods?.includes("environments.list") !== true) {
return [];
}
try {
const result = await this.client.request<EnvironmentsListResult>("environments.list", {});
return result.profiles?.map((profile) => profile.id) ?? [];
} catch {
// Cloud placement is optional; older or temporarily failing gateways stay quiet.
return [];
}
}
async acceptTaskSuggestion(
taskId: string,
mode?: TuiTaskSuggestionAcceptMode,
cloudProfileId?: string,
) {
const params: TaskSuggestionsAcceptParams =
!mode || mode === "worktree"
? { taskId }
: { taskId, mode, ...(cloudProfileId ? { cloudProfileId } : {}) };
return await this.client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", params);
}
async dismissTaskSuggestion(taskId: string) {
+10 -1
View File
@@ -7,6 +7,7 @@ import type {
SessionsPatchParams,
SessionsPatchResult,
TaskSuggestion,
TaskSuggestionsAcceptParams,
TaskSuggestionsAcceptResult,
} from "../../packages/gateway-protocol/src/index.js";
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
@@ -33,9 +34,12 @@ export type TuiApprovalDecision = "allow-once" | "allow-always" | "deny";
type TuiTaskSuggestionActionCapabilities = {
canAccept: boolean;
canAcceptModes: boolean;
canDismiss: boolean;
};
export type TuiTaskSuggestionAcceptMode = NonNullable<TaskSuggestionsAcceptParams["mode"]>;
export type TuiPluginApproval = {
id: string;
request: {
@@ -212,7 +216,12 @@ export type TuiBackend = {
resolvePluginApproval?: (id: string, decision: TuiApprovalDecision) => Promise<{ ok?: boolean }>;
getTaskSuggestionActionCapabilities?: () => TuiTaskSuggestionActionCapabilities;
listTaskSuggestions?: () => Promise<TaskSuggestion[]>;
acceptTaskSuggestion?: (taskId: string) => Promise<TaskSuggestionsAcceptResult>;
listCloudWorkerProfiles?: () => Promise<string[]>;
acceptTaskSuggestion?: (
taskId: string,
mode?: TuiTaskSuggestionAcceptMode,
cloudProfileId?: string,
) => Promise<TaskSuggestionsAcceptResult>;
dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>;
runGoalCommand?: (
opts: TuiGoalCommandOptions,
+1 -1
View File
@@ -642,7 +642,7 @@ describe.sequential("TUI PTY harness", () => {
await fixture.run.write("\x1b[A", { delay: false });
await fixture.run.write("\r", { delay: false });
await fixture.run.waitForOutput("Press Enter again to start this task in a worktree.");
await fixture.run.waitForOutput("Press Enter again to start this task.");
await fixture.run.write("\r", { delay: false });
await fixture.waitForLogEntry(
(entry) =>
+118 -7
View File
@@ -53,6 +53,7 @@ function createHarness() {
});
const requestRender = vi.fn();
const listTaskSuggestions = vi.fn().mockResolvedValue([]);
const listCloudWorkerProfiles = vi.fn().mockResolvedValue([]);
const acceptTaskSuggestion = vi
.fn()
.mockResolvedValue({ taskId: "task_1", key: "agent:main:task" });
@@ -60,11 +61,12 @@ function createHarness() {
const onAccepted = vi.fn().mockResolvedValue(undefined);
let agentId = "main";
let sessionKey = "agent:main:main";
let actionCapabilities = { canAccept: true, canDismiss: true };
let actionCapabilities = { canAccept: true, canAcceptModes: true, canDismiss: true };
const controller = createTuiTaskSuggestionController({
client: {
getTaskSuggestionActionCapabilities: () => actionCapabilities,
listTaskSuggestions,
listCloudWorkerProfiles,
acceptTaskSuggestion,
dismissTaskSuggestion,
},
@@ -96,6 +98,7 @@ function createHarness() {
overlayHandles,
requestRender,
listTaskSuggestions,
listCloudWorkerProfiles,
acceptTaskSuggestion,
dismissTaskSuggestion,
onAccepted,
@@ -105,7 +108,11 @@ function createHarness() {
setSessionKey: (value: string) => {
sessionKey = value;
},
setActionCapabilities: (value: { canAccept: boolean; canDismiss: boolean }) => {
setActionCapabilities: (value: {
canAccept: boolean;
canAcceptModes: boolean;
canDismiss: boolean;
}) => {
actionCapabilities = value;
},
};
@@ -139,8 +146,13 @@ describe("TUI task suggestions", () => {
expect(renderedPrompt).toContain("Why: The adapter is unreachable");
expect(renderedPrompt).toContain("Instructions:");
expect(renderedPrompt).toContain("Delete the stale adapter and update its tests.");
expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual(["accept", "dismiss"]);
expect(harness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(1);
expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual([
"accept-local",
"accept-session",
"accept",
"dismiss",
]);
expect(harness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(3);
const accept = { value: "accept", label: "Start in worktree" };
harness.selectors[0]?.onSelect?.(accept);
@@ -157,6 +169,57 @@ describe("TUI task suggestions", () => {
expect(harness.addSystem).toHaveBeenCalledWith("follow-up task started in agent:main:task");
});
it.each([
{ value: "accept-local", mode: "local" as const },
{ value: "accept-session", mode: "session" as const },
])("forwards $mode acceptance after double Enter", async ({ value, mode }) => {
const harness = createHarness();
harness.controller.handleEvent("task.suggestion", {
action: "created",
suggestion: suggestionPayload(),
});
const action = expectDefined(
harness.selectors[0]?.items.find((item) => item.value === value),
`${mode} action`,
);
harness.selectors[0]?.onSelect?.(action);
expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled();
harness.selectors[0]?.onSelect?.(action);
await vi.waitFor(() => {
expect(harness.acceptTaskSuggestion).toHaveBeenCalledWith("task_1", mode);
});
if (mode === "session") {
expect(harness.onAccepted).not.toHaveBeenCalled();
}
});
it("offers one cloud action per profile and forwards the selected profile", async () => {
const harness = createHarness();
const suggestion = suggestionPayload();
harness.listTaskSuggestions.mockResolvedValueOnce([suggestion]);
harness.listCloudWorkerProfiles.mockResolvedValueOnce(["build", "review"]);
await harness.controller.refresh();
const cloudActions = harness.selectors[0]?.items.filter(
(item) => item.value === "accept-cloud",
);
expect(cloudActions?.map((item) => item.label)).toEqual([
"Send to cloud · build",
"Send to cloud · review",
]);
const review = expectDefined(cloudActions?.[1], "review cloud action");
harness.selectors[0]?.onSelect?.(review);
expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled();
harness.selectors[0]?.onSelect?.(review);
await vi.waitFor(() => {
expect(harness.acceptTaskSuggestion).toHaveBeenCalledWith("task_1", "cloud", "review");
});
});
it("keeps actions visible while paging through long instructions", () => {
const harness = createHarness();
const promptLines = Array.from(
@@ -252,7 +315,11 @@ describe("TUI task suggestions", () => {
it("offers only actions allowed by the connected operator scopes", () => {
const writeHarness = createHarness();
writeHarness.setActionCapabilities({ canAccept: false, canDismiss: true });
writeHarness.setActionCapabilities({
canAccept: false,
canAcceptModes: true,
canDismiss: true,
});
writeHarness.controller.handleEvent("task.suggestion", {
action: "created",
suggestion: suggestionPayload(),
@@ -261,7 +328,11 @@ describe("TUI task suggestions", () => {
expect(writeHarness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(0);
const readHarness = createHarness();
readHarness.setActionCapabilities({ canAccept: false, canDismiss: false });
readHarness.setActionCapabilities({
canAccept: false,
canAcceptModes: true,
canDismiss: false,
});
readHarness.controller.handleEvent("task.suggestion", {
action: "created",
suggestion: suggestionPayload(),
@@ -269,6 +340,22 @@ describe("TUI task suggestions", () => {
expect(readHarness.openOverlay).not.toHaveBeenCalled();
});
it("offers only worktree acceptance when modes are not advertised", () => {
const harness = createHarness();
harness.setActionCapabilities({
canAccept: true,
canAcceptModes: false,
canDismiss: true,
});
harness.controller.handleEvent("task.suggestion", {
action: "created",
suggestion: suggestionPayload(),
});
expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual(["accept", "dismiss"]);
});
it("rebuilds an active selector when reconnect changes action scopes", async () => {
const harness = createHarness();
const suggestion = suggestionPayload();
@@ -278,7 +365,11 @@ describe("TUI task suggestions", () => {
});
const staleSelector = harness.selectors[0];
harness.setActionCapabilities({ canAccept: false, canDismiss: true });
harness.setActionCapabilities({
canAccept: false,
canAcceptModes: true,
canDismiss: true,
});
harness.listTaskSuggestions.mockResolvedValueOnce([suggestion]);
await harness.controller.refresh();
@@ -290,6 +381,26 @@ describe("TUI task suggestions", () => {
expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled();
});
it("rebuilds an active selector when cloud profile identity changes", async () => {
const harness = createHarness();
const suggestion = suggestionPayload();
harness.listTaskSuggestions.mockResolvedValue([suggestion]);
harness.listCloudWorkerProfiles.mockResolvedValueOnce(["build"]);
await harness.controller.refresh();
expect(harness.selectors[0]?.items.map((item) => item.label)).toContain(
"Send to cloud · build",
);
harness.listCloudWorkerProfiles.mockResolvedValueOnce(["review"]);
await harness.controller.refresh();
expect(harness.closeOverlay).toHaveBeenCalledWith(harness.overlayHandles[0]);
expect(harness.selectors[1]?.items.map((item) => item.label)).toContain(
"Send to cloud · review",
);
});
it("shows a still-pending suggestion again when its action fails", async () => {
const harness = createHarness();
harness.acceptTaskSuggestion.mockRejectedValueOnce(new Error("gateway unavailable"));
+89 -21
View File
@@ -11,7 +11,7 @@ import type { TaskSuggestion } from "../../packages/gateway-protocol/src/index.j
import { formatErrorMessage } from "../infra/errors.js";
import { createTuiRefreshCoalescer } from "./coalesced-refresh.js";
import { selectListTheme, theme } from "./theme/theme.js";
import type { TuiBackend } from "./tui-backend.js";
import type { TuiBackend, TuiTaskSuggestionAcceptMode } from "./tui-backend.js";
import { sanitizeRenderableText } from "./tui-formatters.js";
type TaskSelector = Component & {
@@ -26,6 +26,7 @@ type TaskSuggestionControllerDeps = {
TuiBackend,
| "getTaskSuggestionActionCapabilities"
| "listTaskSuggestions"
| "listCloudWorkerProfiles"
| "acceptTaskSuggestion"
| "dismissTaskSuggestion"
>;
@@ -45,18 +46,65 @@ const TASK_DETAIL_PAGE_LINES = TASK_DETAIL_VIEWPORT_LINES - 1;
const PAGE_UP_INPUT = "\u001b[5~";
const PAGE_DOWN_INPUT = "\u001b[6~";
const TASK_ACTIONS = [
{
type TaskAction = SelectItem & {
kind: "accept" | "dismiss";
mode?: TuiTaskSuggestionAcceptMode;
cloudProfileId?: string;
};
const TASK_ACTIONS = {
worktree: {
value: "accept",
label: "Start in worktree",
description: "Create an isolated session and begin this task",
kind: "accept",
mode: "worktree",
},
{
local: {
value: "accept-local",
label: "Start locally",
description: "Start a new session in this checkout",
kind: "accept",
mode: "local",
},
session: {
value: "accept-session",
label: "Fix in this session",
description: "Deliver the task into this transcript",
kind: "accept",
mode: "session",
},
dismiss: {
value: "dismiss",
label: "Dismiss",
description: "Leave the repository untouched",
kind: "dismiss",
},
] satisfies SelectItem[];
} as const satisfies Record<string, TaskAction>;
function taskActions(cloudProfileIds: string[]): TaskAction[] {
return [
TASK_ACTIONS.local,
...cloudProfileIds.map(
(profileId): TaskAction => ({
value: "accept-cloud",
label: `Send to cloud · ${clean(profileId)}`,
description: "Start a new session on this cloud worker",
kind: "accept",
mode: "cloud",
cloudProfileId: profileId,
}),
),
TASK_ACTIONS.session,
// Keep the established one-Up shortcut from the default Dismiss selection.
TASK_ACTIONS.worktree,
TASK_ACTIONS.dismiss,
];
}
function taskActionKey(action: SelectItem): string {
return `${action.value}\0${(action as TaskAction).cloudProfileId ?? ""}`;
}
function clean(text: string): string {
return sanitizeTaskText(text.replace(/\s+/g, " ").trim());
@@ -202,6 +250,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
let activeOverlay: OverlayHandle | null = null;
let activeSelector: TaskSelector | null = null;
let activeActionKey: string | null = null;
let cloudProfileIds: string[] = [];
let revision = 0;
let disposed = false;
@@ -231,10 +280,14 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
const availableActions = () => {
const capabilities = deps.client.getTaskSuggestionActionCapabilities?.() ?? {
canAccept: Boolean(deps.client.acceptTaskSuggestion),
canAcceptModes: false,
canDismiss: Boolean(deps.client.dismissTaskSuggestion),
};
return TASK_ACTIONS.filter((action) =>
action.value === "accept" ? capabilities.canAccept : capabilities.canDismiss,
const actions: TaskAction[] = capabilities.canAcceptModes
? taskActions(cloudProfileIds)
: [TASK_ACTIONS.worktree, TASK_ACTIONS.dismiss];
return actions.filter((action) =>
action.kind === "accept" ? capabilities.canAccept : capabilities.canDismiss,
);
};
@@ -243,7 +296,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
return;
}
const actions = availableActions();
const actionKey = actions.map((action) => action.value).join(",");
const actionKey = actions.map(taskActionKey).join(",");
if (activeId) {
if (activeActionKey === actionKey) {
return;
@@ -270,7 +323,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
let acceptArmed = false;
let prompt: TaskPrompt | null = null;
const resolve = async (action: "accept" | "dismiss") => {
const resolve = async (action: TaskAction) => {
if (activeId !== suggestion.id || activeSelector !== selector) {
return;
}
@@ -278,14 +331,22 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
hiddenIds.add(suggestion.id);
deps.requestRender();
try {
if (action === "accept") {
if (action.kind === "accept") {
if (!deps.client.acceptTaskSuggestion) {
throw new Error("task suggestion acceptance is unavailable");
}
const result = await deps.client.acceptTaskSuggestion(suggestion.id);
const result = action.cloudProfileId
? await deps.client.acceptTaskSuggestion(
suggestion.id,
action.mode,
action.cloudProfileId,
)
: action.mode === "worktree"
? await deps.client.acceptTaskSuggestion(suggestion.id)
: await deps.client.acceptTaskSuggestion(suggestion.id, action.mode);
remove(suggestion.id);
deps.chatLog.addSystem(`follow-up task started in ${result.key}`);
if (matchesSession(suggestion)) {
if (action.mode !== "session" && matchesSession(suggestion)) {
await deps.onAccepted(result.key);
}
} else {
@@ -322,25 +383,28 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
if (activeSelector !== selector) {
return;
}
if (!availableActions().some((action) => action.value === item.value)) {
const selectedAction = actions.find(
(action) => taskActionKey(action) === taskActionKey(item),
);
if (
!selectedAction ||
!availableActions().some((action) => taskActionKey(action) === taskActionKey(item))
) {
closeActive();
presentNext();
deps.requestRender();
return;
}
if (item.value === "dismiss") {
void resolve("dismiss");
return;
}
if (item.value !== "accept") {
if (selectedAction.kind === "dismiss") {
void resolve(selectedAction);
return;
}
if (acceptArmed) {
void resolve("accept");
void resolve(selectedAction);
return;
}
acceptArmed = true;
prompt?.setConfirmation("Press Enter again to start this task in a worktree.");
prompt?.setConfirmation("Press Enter again to start this task.");
deps.requestRender();
};
selector.onCancel = () => {
@@ -361,7 +425,10 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
const refreshRunner = createTuiRefreshCoalescer(
async (requestRerun) => {
const startRevision = revision;
const listed = await deps.client.listTaskSuggestions?.();
const [listed, profiles] = await Promise.all([
deps.client.listTaskSuggestions?.(),
deps.client.listCloudWorkerProfiles?.() ?? Promise.resolve([]),
]);
if (disposed || !listed) {
return false;
}
@@ -371,6 +438,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
return true;
}
suggestions.clear();
cloudProfileIds = profiles;
for (const value of listed) {
const suggestion = parseTuiTaskSuggestion(value);
if (suggestion) {
+61 -3
View File
@@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
@@ -77,8 +78,12 @@ suite.define(() => {
});
await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] });
const startButton = page.getByRole("button", { name: "Start in worktree" });
const startButton = page.getByRole("button", { name: "Start with worktree" });
await startButton.waitFor({ state: "visible", timeout: 10_000 });
expect(await page.getByRole("button", { name: "More ways to start this task" }).count()).toBe(
0,
);
await page.getByText("Show instructions", { exact: true }).click();
await page
.getByText("/projects/example", { exact: true })
.waitFor({ state: "visible", timeout: 10_000 });
@@ -96,6 +101,59 @@ suite.define(() => {
}
});
it("fixes a model-suggested follow-up in the source session", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const suggestion = {
id: "task_session",
title: "Repair the active flow",
prompt: "Fix the active flow and keep this transcript selected.",
tldr: "The follow-up belongs in this session.",
cwd: "/projects/example",
sessionKey: "main",
agentId: "main",
createdAt: Date.now(),
};
const gateway = await installMockGateway(page, {
featureCapabilities: [GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES],
featureMethods: [
"chat.metadata",
"chat.startup",
"environments.list",
"taskSuggestions.list",
"taskSuggestions.accept",
],
methodResponses: {
"environments.list": { environments: [], profiles: [] },
"taskSuggestions.list": { suggestions: [suggestion] },
"taskSuggestions.accept": { taskId: suggestion.id, key: suggestion.sessionKey },
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const card = page.locator(`.task-suggestion[data-task-id="${suggestion.id}"]`);
await card.waitFor({ state: "visible", timeout: 10_000 });
await gateway.waitForRequest("environments.list");
const routeBeforeAccept = page.url();
await card.getByRole("button", { name: "More ways to start this task" }).click();
const sessionItem = card.locator('wa-dropdown-item[value="session"]');
await sessionItem.waitFor({ state: "visible", timeout: 10_000 });
await sessionItem.click();
const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept");
expect(acceptRequest.params).toEqual({ taskId: suggestion.id, mode: "session" });
await expect.poll(() => card.count()).toBe(0);
expect(page.url()).toBe(routeBeforeAccept);
} finally {
await suite.closeBrowserContext(context);
}
});
it("clears model-suggested follow-ups while switching sessions", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
@@ -133,7 +191,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}chat`);
const startButton = page.getByRole("button", { name: "Start in worktree" });
const startButton = page.getByRole("button", { name: "Start with worktree" });
await startButton.waitFor({ state: "visible", timeout: 10_000 });
await gateway.deferNext("taskSuggestions.list");
await page
@@ -195,7 +253,7 @@ suite.define(() => {
await page
.locator(".agent-chat__composer-shell")
.waitFor({ state: "visible", timeout: 10_000 });
expect(await page.getByRole("button", { name: "Start in worktree" }).count()).toBe(0);
expect(await page.getByRole("button", { name: "Start with worktree" }).count()).toBe(0);
expect(await page.locator(".task-suggestion").count()).toBe(0);
} finally {
await suite.closeBrowserContext(context);
+10 -2
View File
@@ -4604,13 +4604,21 @@ export const en: TranslationMap = {
terminalUnavailable: "Terminal opening is unavailable for this session.",
},
taskSuggestions: {
eyebrow: "Suggested follow-up",
eyebrow: "Suggested task · in {repo}",
start: "Start in worktree",
startWorktree: "Start with worktree",
startLocal: "Start locally",
startCloud: "Send to cloud · {profile}",
startCloudGeneric: "Send to cloud",
fixInSession: "Fix in this session",
noCloudConfigured: "No cloud environment configured",
showInstructions: "Show instructions",
moreActions: "More ways to start this task",
starting: "Starting…",
dismiss: "Dismiss {title}",
project: "Project",
instructions: "Instructions",
adminRequired: "Administrator access is required to create a worktree from this project.",
adminRequired: "Administrator access is required to start suggested tasks.",
},
pullRequests: {
linkLabel: "Pull request #{number}: {title}",
+7 -2
View File
@@ -61,6 +61,11 @@ type SessionWorktreeDisplayRow = {
execNode?: string;
};
/** Basename shown for a repository path on every Control UI surface. */
export function repoName(repoRoot: string): string {
return repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot;
}
/** Compact "repo ⎇ branch" (plus node host) line for worktree/work sessions. */
export function resolveSessionWorkSubtitle(row: SessionWorktreeDisplayRow): string | undefined {
const repoRoot = normalizeOptionalString(row.worktree?.repoRoot);
@@ -68,11 +73,11 @@ export function resolveSessionWorkSubtitle(row: SessionWorktreeDisplayRow): stri
// execNode is often a raw node id (long hex); never render it in full.
const rawNode = normalizeOptionalString(row.execNode);
const node = rawNode ? shortenOpaqueIdRuns(rawNode) : undefined;
const repoName = repoRoot ? (repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot) : undefined;
const repo = repoRoot ? repoName(repoRoot) : undefined;
const shortBranch = branch?.startsWith(WORKTREE_BRANCH_PREFIX)
? branch.slice(WORKTREE_BRANCH_PREFIX.length)
: branch;
const checkout = repoName ? (shortBranch ? `${repoName}${shortBranch}` : repoName) : undefined;
const checkout = repo ? (shortBranch ? `${repo}${shortBranch}` : repo) : undefined;
if (checkout && node) {
// Checkout first: it names the work; the node is routing detail.
return `${checkout} · ${node}`;
+14
View File
@@ -0,0 +1,14 @@
import type { TaskSuggestionsAcceptParams } from "../../../packages/gateway-protocol/src/index.js";
export type TaskSuggestionAcceptMode = NonNullable<TaskSuggestionsAcceptParams["mode"]>;
export function taskSuggestionAcceptParams(
taskId: string,
mode: TaskSuggestionAcceptMode,
cloudProfileId?: string,
): TaskSuggestionsAcceptParams {
if (mode === "worktree") {
return { taskId };
}
return { taskId, mode, ...(cloudProfileId ? { cloudProfileId } : {}) };
}
+6
View File
@@ -295,6 +295,12 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
protected readonly taskSuggestionBusyIds = new Set<string>();
protected readonly taskSuggestionOperations = new Map<string, symbol>();
protected taskSuggestionsRequestVersion = 0;
protected taskSuggestionCloudProfiles: Array<{ id: string }> = [];
protected taskSuggestionCloudProfileGeneration = -1;
protected resetTaskSuggestionCloudProfiles(): void {
this.taskSuggestionCloudProfiles = [];
this.taskSuggestionCloudProfileGeneration = -1;
}
protected sessionSuggestions: SessionSuggestion[] = [];
protected sessionSuggestionRole: SessionSharingRole | undefined;
protected readonly sessionSuggestionBusyIds = new Set<string>();
+1
View File
@@ -215,6 +215,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
this.taskSuggestions = [];
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
this.resetTaskSuggestionCloudProfiles();
this.resetSessionSuggestions();
this.clearTypingActors();
this.sessionDiscussionStates.clear();
+1
View File
@@ -726,6 +726,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
this.taskSuggestions = [];
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
this.resetTaskSuggestionCloudProfiles();
this.resetSessionSuggestions();
this.clearTypingActors();
this.resetSessionPullRequests();
+14 -2
View File
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { GATEWAY_SERVER_CAPS } from "../../../../packages/gateway-protocol/src/index.js";
import { findInlineApproval } from "../../app/approval-presentation.ts";
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
import { cancelQuestionPrompt, submitQuestionPrompt } from "../../app/question-prompt.ts";
@@ -9,7 +10,10 @@ import {
resolveControlUiFollowUpMode,
resolveControlUiServerQueueMode,
} from "../../lib/chat/follow-up-mode.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import {
isGatewayCapabilityAdvertised,
isGatewayMethodAdvertised,
} from "../../lib/gateway-methods.ts";
import {
pickFreshestObserverDigest,
projectSessionObserverDigest,
@@ -80,6 +84,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
if (!state) {
return html`<main class="app-shell app-shell--booting" aria-busy="true"></main>`;
}
void this.ensureTaskSuggestionCloudProfiles();
const selectedSession = selectedChatSessionRow(state);
const mutationAccess = readChatPaneMutationAccess(
this.context.gateway.snapshot,
@@ -448,12 +453,19 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
state.connected &&
hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.accept") === true,
canAcceptTaskSuggestionModes:
isGatewayCapabilityAdvertised(
this.context.gateway.snapshot,
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
) === true,
canDismissTaskSuggestions:
state.connected &&
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.dismiss") ===
true,
onAcceptTaskSuggestion: (suggestion) => void this.acceptTaskSuggestion(suggestion),
taskSuggestionCloudProfiles: this.taskSuggestionCloudProfiles,
onAcceptTaskSuggestion: (suggestion, mode, cloudProfileId) =>
void this.acceptTaskSuggestion(suggestion, mode, cloudProfileId),
onDismissTaskSuggestion: (suggestion) => void this.dismissTaskSuggestion(suggestion),
onOpenWorkspaceFile: (target) => openSessionWorkspaceFile(state, target),
onRevealWorkspaceFile: (path) => revealSessionWorkspaceFile(state, path),
+54 -8
View File
@@ -9,6 +9,7 @@ import type {
import type { ControlUiSessionPullRequest } from "../../../../src/gateway/control-ui-contract.js";
import type { GatewaySessionRow } from "../../api/types.ts";
import { selectApplicationSession } from "../../app/agent-selection.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { clampText } from "../../lib/format.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
@@ -26,6 +27,11 @@ import {
} from "../../lib/sessions/catalog-key.ts";
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
import {
taskSuggestionAcceptParams,
type TaskSuggestionAcceptMode,
} from "../../lib/task-suggestion-acceptance.ts";
import { discoverCloudProfiles } from "../new-session/cloud-profile-discovery.ts";
import { catalogMessageId } from "./catalog-message-id.ts";
import { refreshChatAvatar } from "./chat-avatar.ts";
import {
@@ -68,6 +74,35 @@ import {
import { scheduleChatScroll } from "./scroll.ts";
export abstract class ChatPaneSession extends ChatPaneSharing {
protected async ensureTaskSuggestionCloudProfiles(): Promise<void> {
const scope = this.captureConnectionScope();
if (
!scope ||
this.taskSuggestions.length === 0 ||
this.taskSuggestionCloudProfileGeneration === scope.generation ||
!hasOperatorAdminAccess(scope.context.gateway.snapshot.hello?.auth ?? null) ||
isGatewayMethodAdvertised(scope.context.gateway.snapshot, "taskSuggestions.accept") !== true
) {
return;
}
// Profile metadata is connection-stable. Mark the generation before the
// request so repeated renders cannot turn this optional affordance into polling.
this.taskSuggestionCloudProfileGeneration = scope.generation;
if (isGatewayMethodAdvertised(scope.context.gateway.snapshot, "environments.list") !== true) {
return;
}
try {
const profiles = await discoverCloudProfiles(scope.client, true);
if (!this.isConnectionScopeCurrent(scope)) {
return;
}
this.taskSuggestionCloudProfiles = profiles.map((profile) => ({ id: profile.id }));
this.requestUpdate();
} catch {
// Cloud is optional; a failed one-shot discovery leaves the disabled hint.
}
}
protected async refreshTaskSuggestions(): Promise<void> {
const requestVersion = ++this.taskSuggestionsRequestVersion;
const scope = this.captureConnectionScope();
@@ -208,8 +243,11 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
void this.refreshTaskSuggestions();
}
protected readonly acceptTaskSuggestion = (suggestion: TaskSuggestion): Promise<void> =>
this.resolveTaskSuggestion(suggestion, "accept");
protected readonly acceptTaskSuggestion = (
suggestion: TaskSuggestion,
mode: TaskSuggestionAcceptMode,
cloudProfileId?: string,
): Promise<void> => this.resolveTaskSuggestion(suggestion, "accept", mode, cloudProfileId);
protected readonly dismissTaskSuggestion = (suggestion: TaskSuggestion): Promise<void> =>
this.resolveTaskSuggestion(suggestion, "dismiss");
@@ -217,6 +255,8 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
protected async resolveTaskSuggestion(
suggestion: TaskSuggestion,
action: "accept" | "dismiss",
mode: TaskSuggestionAcceptMode = "worktree",
cloudProfileId?: string,
): Promise<void> {
const scope = this.captureConnectionScope();
if (
@@ -236,16 +276,22 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
this.taskSuggestionBusyIds.add(suggestion.id);
this.requestUpdate();
try {
const result = await scope.client.request<TaskSuggestionsAcceptResult>(
action === "accept" ? "taskSuggestions.accept" : "taskSuggestions.dismiss",
{ taskId: suggestion.id },
);
let acceptedKey: string | undefined;
if (action === "accept") {
const result = await scope.client.request<TaskSuggestionsAcceptResult>(
"taskSuggestions.accept",
taskSuggestionAcceptParams(suggestion.id, mode, cloudProfileId),
);
acceptedKey = result.key;
} else {
await scope.client.request("taskSuggestions.dismiss", { taskId: suggestion.id });
}
if (!isCurrent()) {
return;
}
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
if (action === "accept") {
this.onPaneSessionChange?.(this.paneId, result.key);
if (acceptedKey && mode !== "session") {
this.onPaneSessionChange?.(this.paneId, acceptedKey);
}
} catch (error) {
if (!isCurrent()) {
+6 -1
View File
@@ -19,6 +19,7 @@ import { createInitialUserMessageHandoff } from "../../app/initial-user-message-
import type { CatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import "./chat-pane.ts";
import type { TaskSuggestionAcceptMode } from "../../lib/task-suggestion-acceptance.ts";
import { attachChatRealtimeActions, createInitialChatRealtimeState } from "./chat-realtime.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts";
@@ -40,7 +41,11 @@ export type TestChatPane = HTMLElement & {
restoreArchivedSession: (sessionKey: string) => Promise<void>;
disconnectedCallback: () => void;
discardBrowserAnnotations?: () => void;
acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise<void>;
acceptTaskSuggestion: (
suggestion: TaskSuggestion,
mode: TaskSuggestionAcceptMode,
cloudProfileId?: string,
) => Promise<void>;
handleDocumentKeydown: (event: KeyboardEvent) => void;
handleTaskSuggestionEvent: (event: TaskSuggestionEvent) => void;
refreshTaskSuggestions: () => Promise<void>;
+18 -2
View File
@@ -1044,7 +1044,7 @@ describe("chat pane task suggestion lifecycle", () => {
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
const pending = pane.acceptTaskSuggestion(suggestion);
const pending = pane.acceptTaskSuggestion(suggestion, "worktree");
pane.handleTaskSuggestionEvent({
action: "resolved",
taskId: suggestion.id,
@@ -1066,7 +1066,7 @@ describe("chat pane task suggestion lifecycle", () => {
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
const pending = pane.acceptTaskSuggestion(suggestion);
const pending = pane.acceptTaskSuggestion(suggestion, "worktree");
pane.connectionGeneration += 1;
accepted.resolve({ taskId: suggestion.id, key: "agent:main:stale" });
@@ -1074,6 +1074,22 @@ describe("chat pane task suggestion lifecycle", () => {
expect(navigate).not.toHaveBeenCalled();
});
it("keeps session-mode acceptance in the source pane", async () => {
const request = vi.fn().mockResolvedValue({ taskId: suggestion.id, key: "agent:main:task" });
const client = { request } as unknown as GatewayBrowserClient;
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
await pane.acceptTaskSuggestion(suggestion, "session");
expect(request).toHaveBeenCalledWith("taskSuggestions.accept", {
taskId: suggestion.id,
mode: "session",
});
expect(navigate).not.toHaveBeenCalled();
});
it("drops a list response after a same-client reconnect", async () => {
const listed = createDeferred<TaskSuggestionsListResult>();
const client = {
+129 -67
View File
@@ -10,104 +10,166 @@ const suggestion: TaskSuggestion = {
title: "Remove stale adapter",
prompt: "Delete the stale adapter and update tests.",
tldr: "The adapter is unreachable and adds maintenance cost.",
cwd: "/repo",
cwd: "/repo/project",
sessionKey: "agent:main:main",
agentId: "main",
createdAt: 1,
};
function renderSuggestion(
overrides: Partial<Parameters<typeof renderChatTaskSuggestions>[0]> = {},
) {
const container = document.createElement("div");
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
renderChatTaskSuggestions({
suggestions: [suggestion],
busyIds: new Set(),
canAccept: true,
canAcceptModes: true,
canDismiss: true,
cloudProfiles: [],
onAccept,
onDismiss,
...overrides,
}),
container,
);
return { container, onAccept, onDismiss };
}
function selectMenuItem(container: HTMLElement, item: Element) {
container.querySelector("wa-dropdown")?.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
composed: true,
detail: { item },
}),
);
}
describe("chat task suggestions", () => {
it("renders an actionable chip", () => {
const container = document.createElement("div");
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
renderChatTaskSuggestions({
suggestions: [suggestion],
busyIds: new Set(),
canAccept: true,
canDismiss: true,
onAccept,
onDismiss,
}),
container,
it("renders a compact card with collapsed instructions and worktree primary action", () => {
const { container, onAccept, onDismiss } = renderSuggestion();
expect(container.querySelector(".task-suggestion__eyebrow")?.textContent).toContain(
"Suggested task · in project",
);
expect(container.querySelector(".task-suggestion__eyebrow")?.getAttribute("title")).toBe(
"/repo/project",
);
expect(container.querySelector(".task-suggestion__title")?.textContent).toContain(
"Remove stale adapter",
);
expect(container.querySelector(".task-suggestion__summary")?.textContent).toContain(
"The adapter is unreachable",
);
expect(container.textContent).toContain("Remove stale adapter");
expect(container.textContent).toContain("The adapter is unreachable");
expect(container.textContent).toContain("/repo");
expect(container.textContent).toContain("Delete the stale adapter and update tests.");
const details = container.querySelector<HTMLDetailsElement>(".task-suggestion__instructions");
expect(details?.open).toBe(false);
details?.querySelector("summary")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(details?.open).toBe(true);
expect(details?.textContent).toContain("Delete the stale adapter and update tests.");
container.querySelector<HTMLButtonElement>(".task-suggestion__start")?.click();
container.querySelector<HTMLButtonElement>(".task-suggestion__dismiss")?.click();
expect(onAccept).toHaveBeenCalledWith(suggestion);
expect(onAccept).toHaveBeenCalledWith(suggestion, "worktree", undefined);
expect(onDismiss).toHaveBeenCalledWith(suggestion);
});
it("renders nothing when no task actions are permitted", () => {
const container = document.createElement("div");
render(
renderChatTaskSuggestions({
suggestions: [suggestion],
busyIds: new Set(),
canAccept: false,
canDismiss: false,
onAccept: vi.fn(),
onDismiss: vi.fn(),
}),
container,
it("forwards local, session, and per-profile cloud menu actions", () => {
const { container, onAccept } = renderSuggestion({
cloudProfiles: [{ id: "build" }, { id: "review" }],
});
const local = container.querySelector('wa-dropdown-item[value="local"]');
const session = container.querySelector('wa-dropdown-item[value="session"]');
const cloud = [...container.querySelectorAll('wa-dropdown-item[value="cloud"]')];
expect(cloud.map((item) => item.textContent?.trim())).toEqual([
"Send to cloud · build",
"Send to cloud · review",
]);
expect(local).not.toBeNull();
expect(session).not.toBeNull();
selectMenuItem(container, local!);
selectMenuItem(container, session!);
selectMenuItem(container, cloud[0]!);
selectMenuItem(container, cloud[1]!);
expect(onAccept.mock.calls).toEqual([
[suggestion, "local", undefined],
[suggestion, "session", undefined],
[suggestion, "cloud", "build"],
[suggestion, "cloud", "review"],
]);
});
it("renders a worktree-only action when acceptance modes are not advertised", () => {
const { container, onAccept } = renderSuggestion({ canAcceptModes: false });
expect(container.querySelector(".task-suggestion__start")).not.toBeNull();
expect(container.querySelector(".task-suggestion__menu")).toBeNull();
expect(container.querySelector(".task-suggestion__menu-trigger")).toBeNull();
container.querySelector<HTMLButtonElement>(".task-suggestion__start")?.click();
expect(onAccept).toHaveBeenCalledWith(suggestion, "worktree", undefined);
});
it("uses a generic single-cloud label and a disabled hint when none are configured", () => {
const single = renderSuggestion({ cloudProfiles: [{ id: "build" }] }).container;
expect(single.querySelector('wa-dropdown-item[value="cloud"]')?.textContent?.trim()).toBe(
"Send to cloud",
);
const none = renderSuggestion().container;
const item = none.querySelector('wa-dropdown-item[value="cloud"]');
expect(item?.hasAttribute("disabled")).toBe(true);
expect(item?.getAttribute("title")).toBe("No cloud environment configured");
});
it("renders nothing when no task actions are permitted", () => {
const { container } = renderSuggestion({ canAccept: false, canDismiss: false });
expect(container.querySelector(".task-suggestions")).toBeNull();
});
it("allows dismissal while requiring admin access to start", () => {
const container = document.createElement("div");
render(
renderChatTaskSuggestions({
suggestions: [suggestion],
busyIds: new Set(),
canAccept: false,
canDismiss: true,
onAccept: vi.fn(),
onDismiss: vi.fn(),
}),
container,
);
const { container } = renderSuggestion({ canAccept: false, canDismiss: true });
const start = container.querySelector<HTMLButtonElement>(".task-suggestion__start");
expect(start?.disabled).toBe(true);
expect(start?.title).toBe(
"Administrator access is required to create a worktree from this project.",
);
expect(start?.title).toBe("Administrator access is required to start suggested tasks.");
expect(container.querySelector(".task-suggestion__dismiss")).not.toBeNull();
});
it("strips bidi controls from every displayed field", () => {
const container = document.createElement("div");
render(
renderChatTaskSuggestions({
suggestions: [
{
...suggestion,
title: "safe\u202eevil",
tldr: "why\u200f now",
cwd: "/repo/\u2066project",
prompt: "run\u202d exactly",
},
],
busyIds: new Set(),
canAccept: true,
canDismiss: true,
onAccept: vi.fn(),
onDismiss: vi.fn(),
}),
container,
);
const rawProfileId = "build\u202eprofile";
const { container, onAccept } = renderSuggestion({
suggestions: [
{
...suggestion,
title: "safe\u202eevil",
tldr: "why\u200f now",
cwd: "/repo/\u2066project",
prompt: "run\u202d exactly",
},
],
cloudProfiles: [{ id: rawProfileId }, { id: "review" }],
});
expect(container.textContent).toContain("safeevil");
expect(container.textContent).toContain("why now");
expect(container.textContent).toContain("/repo/project");
expect(container.textContent).toContain("run exactly");
expect(container.textContent).toContain("buildprofile");
expect(container.textContent).not.toMatch(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u);
expect(container.querySelector(".task-suggestion__eyebrow")?.getAttribute("title")).toBe(
"/repo/project",
);
const cloud = container.querySelector("wa-dropdown-item[data-cloud-profile]");
expect(cloud).not.toBeNull();
selectMenuItem(container, cloud!);
expect(onAccept).toHaveBeenCalledWith(expect.anything(), "cloud", rawProfileId);
});
});
+7 -2
View File
@@ -254,9 +254,11 @@ export type ChatProps = {
backgroundTasks?: BackgroundTasksProps;
taskSuggestions?: TaskSuggestion[];
taskSuggestionBusyIds?: ReadonlySet<string>;
taskSuggestionCloudProfiles?: Array<{ id: string }>;
canAcceptTaskSuggestions?: boolean;
canAcceptTaskSuggestionModes?: boolean;
canDismissTaskSuggestions?: boolean;
onAcceptTaskSuggestion?: (suggestion: TaskSuggestion) => void;
onAcceptTaskSuggestion?: Parameters<typeof renderChatTaskSuggestions>[0]["onAccept"];
onDismissTaskSuggestion?: (suggestion: TaskSuggestion) => void;
sessionSuggestions?: readonly SessionSuggestion[];
sessionSuggestionRole?: SessionSharingRole;
@@ -644,9 +646,12 @@ export function renderChat(props: ChatProps) {
${renderChatTaskSuggestions({
suggestions: props.taskSuggestions ?? [],
busyIds: props.taskSuggestionBusyIds ?? new Set(),
cloudProfiles: props.taskSuggestionCloudProfiles ?? [],
canAccept: props.canAcceptTaskSuggestions === true,
canAcceptModes: props.canAcceptTaskSuggestionModes === true,
canDismiss: props.canDismissTaskSuggestions === true,
onAccept: (suggestion) => props.onAcceptTaskSuggestion?.(suggestion),
onAccept: (suggestion, mode, cloudProfileId) =>
props.onAcceptTaskSuggestion?.(suggestion, mode, cloudProfileId),
onDismiss: (suggestion) => props.onDismissTaskSuggestion?.(suggestion),
})}
${renderChatPullRequests({
@@ -2,7 +2,12 @@
import { html, nothing } from "lit";
import type { TaskSuggestion } from "../../../../../packages/gateway-protocol/src/index.js";
import { icons } from "../../../components/icons.ts";
import "../../../components/web-awesome.ts";
import { t } from "../../../i18n/index.ts";
import { repoName } from "../../../lib/session-display.ts";
import type { TaskSuggestionAcceptMode } from "../../../lib/task-suggestion-acceptance.ts";
type TaskSuggestionCloudProfile = { id: string };
// Mirrors the TUI sanitizer to prevent directionality spoofing. This stays local
// because the Control UI cannot import core src/ modules.
@@ -15,8 +20,14 @@ export function renderChatTaskSuggestions(props: {
busyIds: ReadonlySet<string>;
canAccept: boolean;
canDismiss: boolean;
onAccept: (suggestion: TaskSuggestion) => void;
cloudProfiles: TaskSuggestionCloudProfile[];
onAccept: (
suggestion: TaskSuggestion,
mode: TaskSuggestionAcceptMode,
cloudProfileId?: string,
) => void;
onDismiss: (suggestion: TaskSuggestion) => void;
canAcceptModes: boolean;
}) {
if (props.suggestions.length === 0 || (!props.canAccept && !props.canDismiss)) {
return nothing;
@@ -29,50 +40,130 @@ export function renderChatTaskSuggestions(props: {
const tldr = sanitizeTaskSuggestionText(suggestion.tldr);
const cwd = sanitizeTaskSuggestionText(suggestion.cwd);
const prompt = sanitizeTaskSuggestionText(suggestion.prompt);
const repo = sanitizeTaskSuggestionText(repoName(cwd));
const cloudProfiles = props.cloudProfiles.map((profile) => ({
id: profile.id,
label: sanitizeTaskSuggestionText(profile.id),
}));
const accept = (mode: TaskSuggestionAcceptMode, cloudProfileId?: string) => {
if (!busy && props.canAccept) {
props.onAccept(suggestion, mode, cloudProfileId);
}
};
return html`
<article class="task-suggestion" data-task-id=${suggestion.id}>
<div class="task-suggestion__icon" aria-hidden="true">${icons.spark}</div>
<div class="task-suggestion__body">
<div class="task-suggestion__eyebrow">${t("chat.taskSuggestions.eyebrow")}</div>
<div class="task-suggestion__eyebrow" title=${cwd}>
${t("chat.taskSuggestions.eyebrow", { repo })}
</div>
<div class="task-suggestion__title">${title}</div>
<div class="task-suggestion__summary">${tldr}</div>
<div class="task-suggestion__details">
<div class="task-suggestion__detail">
<span>${t("chat.taskSuggestions.project")}</span>
<details class="chat-json-collapse task-suggestion__instructions">
<summary class="chat-json-summary">
${t("chat.taskSuggestions.showInstructions")}
</summary>
<div class="task-suggestion__instruction-body">
<code>${cwd}</code>
</div>
<div class="task-suggestion__detail task-suggestion__detail--instructions">
<span>${t("chat.taskSuggestions.instructions")}</span>
<pre>${prompt}</pre>
</div>
</div>
</details>
</div>
${props.canDismiss
? html`
<button
class="btn btn--ghost btn--icon task-suggestion__dismiss"
type="button"
?disabled=${busy}
aria-label=${t("chat.taskSuggestions.dismiss", { title })}
@click=${() => props.onDismiss(suggestion)}
>
${icons.x}
</button>
`
: nothing}
<div class="task-suggestion__actions">
<button
class="btn btn--primary task-suggestion__start"
type="button"
?disabled=${busy || !props.canAccept}
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
@click=${() => props.onAccept(suggestion)}
>
${icons.play}
${busy ? t("chat.taskSuggestions.starting") : t("chat.taskSuggestions.start")}
</button>
${props.canDismiss
? html`
<button
class="btn btn--ghost btn--icon task-suggestion__dismiss"
type="button"
?disabled=${busy}
aria-label=${t("chat.taskSuggestions.dismiss", {
title,
})}
@click=${() => props.onDismiss(suggestion)}
>
${icons.x}
</button>
`
: nothing}
<div class="task-suggestion__split">
<button
class="btn primary btn--primary task-suggestion__start"
type="button"
?disabled=${busy || !props.canAccept}
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
@click=${() => accept("worktree")}
>
${icons.play}
${busy
? t("chat.taskSuggestions.starting")
: t("chat.taskSuggestions.startWorktree")}
</button>
${props.canAcceptModes
? html`
<wa-dropdown
class="task-suggestion__menu"
placement="bottom-end"
@wa-select=${(
event: CustomEvent<{ item: HTMLElement & { value?: string } }>,
) => {
const item = event.detail.item;
if (item.value === "local") {
accept("local");
} else if (item.value === "session") {
accept("session");
} else if (item.value === "cloud") {
const profileId = item.dataset.cloudProfile;
if (profileId) {
accept("cloud", profileId);
}
}
}}
>
<button
slot="trigger"
class="btn primary task-suggestion__menu-trigger"
type="button"
?disabled=${busy || !props.canAccept}
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
aria-label=${t("chat.taskSuggestions.moreActions")}
aria-haspopup="menu"
aria-expanded="false"
>
${icons.chevronDown}
</button>
<wa-dropdown-item value="local" ?disabled=${busy || !props.canAccept}>
${t("chat.taskSuggestions.startLocal")}
</wa-dropdown-item>
${cloudProfiles.length === 0
? html`
<wa-dropdown-item
value="cloud"
disabled
title=${t("chat.taskSuggestions.noCloudConfigured")}
>
${t("chat.taskSuggestions.startCloudGeneric")}
</wa-dropdown-item>
`
: cloudProfiles.map(
(profile) => html`
<wa-dropdown-item
value="cloud"
data-cloud-profile=${profile.id}
?disabled=${busy || !props.canAccept}
>
${cloudProfiles.length > 1
? t("chat.taskSuggestions.startCloud", {
profile: profile.label,
})
: t("chat.taskSuggestions.startCloudGeneric")}
</wa-dropdown-item>
`,
)}
<wa-dropdown-item value="session" ?disabled=${busy || !props.canAccept}>
${t("chat.taskSuggestions.fixInSession")}
</wa-dropdown-item>
</wa-dropdown>
`
: nothing}
</div>
</div>
</article>
`;
+1 -4
View File
@@ -20,6 +20,7 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
import { repoName } from "../../lib/session-display.ts";
import {
resolveSessionPreferredFaceForKey,
sessionNavigationTarget,
@@ -38,10 +39,6 @@ type WorktreeBranchesResult = {
headBranch?: string;
};
function repoName(repoRoot: string): string {
return repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot;
}
class WorktreesPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
+74 -33
View File
@@ -1584,8 +1584,10 @@ openclaw-chat-video-player {
}
.task-suggestion {
display: flex;
align-items: flex-start;
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
@@ -1597,7 +1599,6 @@ openclaw-chat-video-player {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
border-radius: 10px;
color: var(--accent);
@@ -1612,7 +1613,6 @@ openclaw-chat-video-player {
.task-suggestion__body {
min-width: 0;
flex: 1;
}
.task-suggestion__eyebrow {
@@ -1630,36 +1630,28 @@ openclaw-chat-video-player {
}
.task-suggestion__summary {
display: -webkit-box;
margin-top: 3px;
overflow: hidden;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.task-suggestion__details {
.task-suggestion__instructions {
margin-top: 8px;
}
.task-suggestion__instruction-body {
display: grid;
gap: 7px;
margin-top: 10px;
gap: 6px;
padding: 0 8px 8px;
}
.task-suggestion__detail {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
align-items: start;
color: var(--muted);
font-size: 11px;
}
.task-suggestion__detail > span {
padding-top: 3px;
font-weight: 650;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.task-suggestion__detail code,
.task-suggestion__detail pre {
.task-suggestion__instruction-body code,
.task-suggestion__instruction-body pre {
min-width: 0;
max-height: 9rem;
margin: 0;
@@ -1675,33 +1667,82 @@ openclaw-chat-video-player {
}
.task-suggestion__actions {
grid-column: 2 / -1;
display: flex;
align-items: center;
gap: 6px;
justify-self: end;
}
.task-suggestion__split {
display: inline-flex;
align-items: stretch;
}
.task-suggestion__start {
display: inline-flex;
align-items: center;
gap: 6px;
border-color: var(--accent);
border-radius: var(--radius-md) 0 0 var(--radius-md);
background: var(--primary);
color: var(--primary-foreground);
white-space: nowrap;
}
.task-suggestion__start:only-child {
border-radius: var(--radius-md);
}
.task-suggestion__menu-trigger {
min-width: 34px;
height: 100%;
margin-left: -1px;
padding: 8px;
border-color: var(--accent);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: var(--primary);
color: var(--primary-foreground);
}
.task-suggestion__menu-trigger svg {
width: 14px;
height: 14px;
}
.task-suggestion__menu[open] .task-suggestion__menu-trigger svg {
transform: rotate(180deg);
}
.task-suggestion__menu::part(menu) {
min-width: 190px;
max-width: min(320px, calc(100vw - 24px));
}
.task-suggestion__dismiss {
width: 28px;
min-width: 28px;
height: 28px;
padding: 5px;
}
@media (max-width: 640px) {
.task-suggestion {
align-items: flex-start;
flex-wrap: wrap;
grid-template-columns: auto minmax(0, 1fr);
}
.task-suggestion__actions {
width: 100%;
grid-column: 1 / -1;
justify-content: flex-end;
padding-left: 44px;
}
.task-suggestion__detail {
grid-template-columns: minmax(0, 1fr);
gap: 3px;
.task-suggestion__dismiss {
position: absolute;
top: 8px;
right: 8px;
}
.task-suggestion:has(.task-suggestion__dismiss) .task-suggestion__body {
padding-right: 28px;
}
}