diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 4d9e9058e99b..624688dec4e2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 9671f6c24ab9..597ea659ab16 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 474e40011b88..a18d7669b0d2 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -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. diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index b56cbc64d6ed..4750f80cc5b0 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -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; /** diff --git a/packages/gateway-protocol/src/schema/task-suggestions.ts b/packages/gateway-protocol/src/schema/task-suggestions.ts index c55d1dffb789..bd922d161980 100644 --- a/packages/gateway-protocol/src/schema/task-suggestions.ts +++ b/packages/gateway-protocol/src/schema/task-suggestions.ts @@ -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, diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 85274f11c67a..e4867e4322ff 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -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 []; } diff --git a/src/gateway/server-methods/task-suggestions.test.ts b/src/gateway/server-methods/task-suggestions.test.ts index a36d2f236407..ae169e53dd41 100644 --- a/src/gateway/server-methods/task-suggestions.test.ts +++ b/src/gateway/server-methods/task-suggestions.test.ts @@ -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, broadcast = vi.fn()) { +async function call( + method: Method, + params: Record, + broadcast = vi.fn(), + overrides: { + client?: GatewayClient | null; + context?: Partial; + } = {}, +) { const calls: Parameters[] = []; 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 { } } -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 = { primary: { provider: "test" } }, +): Partial { + 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", diff --git a/src/gateway/server-methods/task-suggestions.ts b/src/gateway/server-methods/task-suggestions.ts index aa8fe7e9d2bc..0b4a07c8b4b9 100644 --- a/src/gateway/server-methods/task-suggestions.ts +++ b/src/gateway/server-methods/task-suggestions.ts @@ -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[0]) { @@ -42,6 +48,8 @@ type TaskSuggestionAcceptanceResult = | { ok: true; result: TaskSuggestionsAcceptResult } | { ok: false; error: NonNullable[2]> }; +type TaskSuggestionAcceptMode = NonNullable; + const activeAcceptances = new Map>(); 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[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 | undefined> { + let response: Parameters | 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; + cloudProfileId?: string; }): Promise { let sessionResponse: Parameters | 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[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 | 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 | 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 { + const agentId = resolveSuggestionAgentId(params.suggestion, params.options); + const fail = (error: NonNullable[2]>) => + failSuggestedTaskDelivery({ taskId: params.taskId, options: params.options, error }); + let source: ReturnType; + 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; + 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 | 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; }); diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index bca0d71823a7..7606bd203119 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -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([ diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index 3db8bc453e15..966aaf3c1fef 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -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, diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index bf963ee19bc0..da453680ec30 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -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([]); + }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 477c7632b4d1..c376f0da93b6 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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("taskSuggestions.accept", { - taskId, - }); + async listCloudWorkerProfiles() { + if (this.hello?.features?.methods?.includes("environments.list") !== true) { + return []; + } + try { + const result = await this.client.request("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("taskSuggestions.accept", params); } async dismissTaskSuggestion(taskId: string) { diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index e732b03ddc2e..18322a3bf8e1 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -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; + 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; - acceptTaskSuggestion?: (taskId: string) => Promise; + listCloudWorkerProfiles?: () => Promise; + acceptTaskSuggestion?: ( + taskId: string, + mode?: TuiTaskSuggestionAcceptMode, + cloudProfileId?: string, + ) => Promise; dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>; runGoalCommand?: ( opts: TuiGoalCommandOptions, diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 9542c8ca8a01..94c3ab0569d7 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -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) => diff --git a/src/tui/tui-task-suggestions.test.ts b/src/tui/tui-task-suggestions.test.ts index 43c773e520ba..df44a2767a6d 100644 --- a/src/tui/tui-task-suggestions.test.ts +++ b/src/tui/tui-task-suggestions.test.ts @@ -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")); diff --git a/src/tui/tui-task-suggestions.ts b/src/tui/tui-task-suggestions.ts index 07c3dc0afa16..97ed717c8f57 100644 --- a/src/tui/tui-task-suggestions.ts +++ b/src/tui/tui-task-suggestions.ts @@ -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; + +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) { diff --git a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts index 89e93b14702d..948c4dceed04 100644 --- a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts @@ -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); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 03f0201066c6..51d99482dd4e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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}", diff --git a/ui/src/lib/session-display.ts b/ui/src/lib/session-display.ts index b7bad4f16bc5..f2fc5e1f4a8e 100644 --- a/ui/src/lib/session-display.ts +++ b/ui/src/lib/session-display.ts @@ -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}`; diff --git a/ui/src/lib/task-suggestion-acceptance.ts b/ui/src/lib/task-suggestion-acceptance.ts new file mode 100644 index 000000000000..b688f34181a3 --- /dev/null +++ b/ui/src/lib/task-suggestion-acceptance.ts @@ -0,0 +1,14 @@ +import type { TaskSuggestionsAcceptParams } from "../../../packages/gateway-protocol/src/index.js"; + +export type TaskSuggestionAcceptMode = NonNullable; + +export function taskSuggestionAcceptParams( + taskId: string, + mode: TaskSuggestionAcceptMode, + cloudProfileId?: string, +): TaskSuggestionsAcceptParams { + if (mode === "worktree") { + return { taskId }; + } + return { taskId, mode, ...(cloudProfileId ? { cloudProfileId } : {}) }; +} diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index f1eb2bde79e9..9dd80c1434bb 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -295,6 +295,12 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { protected readonly taskSuggestionBusyIds = new Set(); protected readonly taskSuggestionOperations = new Map(); 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(); diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index 725d5fb9c8e9..7849c993b30c 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -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(); diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 3458de467b38..700cd3173423 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -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(); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 12d4f9f70f4a..9ee30af809b2 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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`
`; } + 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), diff --git a/ui/src/pages/chat/chat-pane-session.ts b/ui/src/pages/chat/chat-pane-session.ts index 560939d8a301..f9646372dcfa 100644 --- a/ui/src/pages/chat/chat-pane-session.ts +++ b/ui/src/pages/chat/chat-pane-session.ts @@ -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 { + 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 { 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 => - this.resolveTaskSuggestion(suggestion, "accept"); + protected readonly acceptTaskSuggestion = ( + suggestion: TaskSuggestion, + mode: TaskSuggestionAcceptMode, + cloudProfileId?: string, + ): Promise => this.resolveTaskSuggestion(suggestion, "accept", mode, cloudProfileId); protected readonly dismissTaskSuggestion = (suggestion: TaskSuggestion): Promise => 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 { 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( - action === "accept" ? "taskSuggestions.accept" : "taskSuggestions.dismiss", - { taskId: suggestion.id }, - ); + let acceptedKey: string | undefined; + if (action === "accept") { + const result = await scope.client.request( + "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()) { diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 7480f9f0f70e..804fa9ad0d69 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -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; disconnectedCallback: () => void; discardBrowserAnnotations?: () => void; - acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise; + acceptTaskSuggestion: ( + suggestion: TaskSuggestion, + mode: TaskSuggestionAcceptMode, + cloudProfileId?: string, + ) => Promise; handleDocumentKeydown: (event: KeyboardEvent) => void; handleTaskSuggestionEvent: (event: TaskSuggestionEvent) => void; refreshTaskSuggestions: () => Promise; diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 6406f789b824..dc8d288d3ae7 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -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(); const client = { diff --git a/ui/src/pages/chat/chat-task-suggestions.test.ts b/ui/src/pages/chat/chat-task-suggestions.test.ts index 6a5bf8dd2541..f8e4ffb5ca5d 100644 --- a/ui/src/pages/chat/chat-task-suggestions.test.ts +++ b/ui/src/pages/chat/chat-task-suggestions.test.ts @@ -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[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(".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(".task-suggestion__start")?.click(); container.querySelector(".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(".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(".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); }); }); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index e5636f854278..18b412b14dfd 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -254,9 +254,11 @@ export type ChatProps = { backgroundTasks?: BackgroundTasksProps; taskSuggestions?: TaskSuggestion[]; taskSuggestionBusyIds?: ReadonlySet; + taskSuggestionCloudProfiles?: Array<{ id: string }>; canAcceptTaskSuggestions?: boolean; + canAcceptTaskSuggestionModes?: boolean; canDismissTaskSuggestions?: boolean; - onAcceptTaskSuggestion?: (suggestion: TaskSuggestion) => void; + onAcceptTaskSuggestion?: Parameters[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({ diff --git a/ui/src/pages/chat/components/chat-task-suggestions.ts b/ui/src/pages/chat/components/chat-task-suggestions.ts index 0c56e5a881a3..6b9cfdbcd591 100644 --- a/ui/src/pages/chat/components/chat-task-suggestions.ts +++ b/ui/src/pages/chat/components/chat-task-suggestions.ts @@ -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; 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`
-
${t("chat.taskSuggestions.eyebrow")}
+
+ ${t("chat.taskSuggestions.eyebrow", { repo })} +
${title}
${tldr}
-
-
- ${t("chat.taskSuggestions.project")} +
+ + ${t("chat.taskSuggestions.showInstructions")} + +
${cwd} -
-
- ${t("chat.taskSuggestions.instructions")}
${prompt}
-
+
+ ${props.canDismiss + ? html` + + ` + : nothing}
- - ${props.canDismiss - ? html` - - ` - : nothing} +
+ + ${props.canAcceptModes + ? html` + , + ) => { + 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); + } + } + }} + > + + + ${t("chat.taskSuggestions.startLocal")} + + ${cloudProfiles.length === 0 + ? html` + + ${t("chat.taskSuggestions.startCloudGeneric")} + + ` + : cloudProfiles.map( + (profile) => html` + + ${cloudProfiles.length > 1 + ? t("chat.taskSuggestions.startCloud", { + profile: profile.label, + }) + : t("chat.taskSuggestions.startCloudGeneric")} + + `, + )} + + ${t("chat.taskSuggestions.fixInSession")} + + + ` + : nothing} +
`; diff --git a/ui/src/pages/worktrees/worktrees-page.ts b/ui/src/pages/worktrees/worktrees-page.ts index 96966310b7db..5ef0dc2b2ce7 100644 --- a/ui/src/pages/worktrees/worktrees-page.ts +++ b/ui/src/pages/worktrees/worktrees-page.ts @@ -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; diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index ff9e5c49315e..8c859dd6e269 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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; } }