commit 60cdebfa01877df98566549b65b5e717627b8b2e Author: Xisheng-Zhao Date: Sun May 17 04:19:54 2026 +0800 initial android app snapshot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21fffcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +app/build/ +build/ +.kotlin/ +local.properties +.idea/ +*.iml +.DS_Store +*.hprof diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f75bb46 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Thanks for considering a contribution. + +This is an independent Android companion app for Multica users. Contributions should keep that boundary clear and avoid implying endorsement by upstream project maintainers. + +## License of contributions + +By submitting a pull request or patch, you agree that your contribution is licensed under GPL-3.0-only, the same license used by this repository. + +Do not submit code that you cannot license under GPL-3.0-only. + +## Contribution rules + +- Keep the app focused on the public Multica service workflow. +- Do not add normal-user runtime backend switching. +- Do not commit analytics keys, tokens, private endpoints, local credentials, or generated APKs. +- Keep analytics conservative: user consent first, minimal events, no content payloads. +- Preserve the public package identity: `ai.multicasual.app`. +- Keep README wording respectful and clear about the relationship to Multica. + +## Development checks + +Before proposing a functional Android change, run: + +```bash +JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ +ANDROID_HOME="$HOME/Library/Android/sdk" \ +./gradlew testPublicDebugUnitTest assemblePublicDebug --console=plain +``` + +If your change touches dependencies or release packaging, update `THIRD_PARTY_NOTICES.md` and verify that the public APK still has a clear source tag and working analytics configuration. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f263a4a --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# Multi-Casual Android + +English | [简体中文](#简体中文) + +An independent Android companion app for Multica users. + +This repository publishes an Android app that works with the Multica backend service. It is not maintained, endorsed, or published by the Multica project maintainers, and it should not be presented as an Android app from the upstream Multica project. + +The app exists for users who already use Multica and want the core workspace workflow on Android: issue browsing, issue details, comments, projects, agents, skills, labels, and settings. + +- Multica website: https://multica.ai +- Multica web app: https://app.multica.ai +- Service API used by this app: `https://api.multica.ai` +- Android package: `ai.multicasual.app` + +## Relationship to Multica + +Multica is the original product and backend service. Multi-Casual Android is an independent companion client for that service. + +The name “Multi-Casual” is used here to describe this Android client and its compatibility with Multica workflows. Product ownership, trademarks, service availability, and backend behavior remain with the Multica project and its maintainers. + +If you represent Multica and would like wording or attribution adjusted, please open an issue. The goal is to keep the relationship accurate, respectful, and clear. + +## What this app does + +- Sign in with the email-code flow used by the Multica service. +- Browse workspaces, inbox items, issues, projects, agents, runtimes, skills, labels, and settings. +- Create and update issues, comments, labels, projects, agents, skills, and related resources through the Multica service API. +- Bring the everyday Multica workspace loop to Android without bundling a server. + +## Privacy and analytics + +Analytics are opt-in. The app asks for consent before sending analytics. + +After consent, the public Android build reports only the `app_open` event: + +- Mainland China route: Umeng +- Global route: PostHog +- The app does not intentionally report page views, chat content, file content, email addresses, tokens, or precise location. + +You can also build the app without analytics keys. In that case, analytics providers will not start. + +## Build + +Requirements: + +- Android Studio / Android SDK +- JDK 17; Android Studio's bundled JBR is recommended + +Create local configuration: + +```bash +cp local.properties.example local.properties +``` + +Optional analytics keys can be filled in locally. Do not commit real keys. + +Build the Android APK: + +```bash +JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ +ANDROID_HOME="$HOME/Library/Android/sdk" \ +./gradlew assemblePublicDebug --console=plain +``` + +APK output: + +```text +app/build/outputs/apk/public/debug/app-public-debug.apk +``` + +Install locally: + +```bash +$ANDROID_HOME/platform-tools/adb install -r app/build/outputs/apk/public/debug/app-public-debug.apk +``` + +## Local analytics configuration + +`local.properties.example` documents optional local keys: + +```properties +multicaUmengPublicAppKey= +multicaPostHogPublicApiKey= +multicaPostHogPublicHost=https://us.i.posthog.com +multicaAnalyticsRoute=auto +``` + +Use `multicaAnalyticsRoute=auto` for normal public builds. The app chooses the analytics route at runtime after user consent. + +## Contributing + +Contributions are welcome if they keep the boundaries clear: + +- Keep this as an independent Android companion app for Multica users. +- Do not imply Multica maintainer endorsement. +- Do not add runtime backend switching for normal users. +- Do not commit secrets, analytics keys, private endpoints, or company-internal material. +- Keep privacy-sensitive changes conservative and easy to review. + +## License + +This repository is licensed under **GPL-3.0-only**. See `LICENSE` for the full license text. + +If you distribute a modified APK or other binary based on this project, keep the corresponding source code available under GPL-3.0-only and preserve the third-party notices. + +--- + +# 简体中文 + +这是一个面向 Multica 用户的独立 Android companion app。 + +本仓库发布的是一个对接 Multica 后端服务的 Android App。它并不由 Multica 项目维护者维护、背书或发布,也不应该被表述为上游 Multica 项目推出的 Android App。 + +这个 App 面向已经使用 Multica 的用户,把核心 workspace 工作流带到 Android 上:Issue 浏览、Issue 详情、评论、项目、Agent、Skill、Label 和设置等能力。 + +- Multica 网站:https://multica.ai +- Multica Web 应用:https://app.multica.ai +- 本 App 使用的服务 API:`https://api.multica.ai` +- Android 包名:`ai.multicasual.app` + +## 与 Multica 的关系 + +Multica 是原始产品和后端服务。Multi-Casual Android 是面向该服务的独立 companion 客户端。 + +这里使用 “Multi-Casual” 名称,是为了说明这个 Android 客户端及其与 Multica 工作流的兼容关系。产品归属、商标、服务可用性和后端行为都仍属于 Multica 项目及其维护者。 + +如果你代表 Multica,并希望调整措辞或署名方式,欢迎开 issue。这个仓库希望把关系说明得准确、礼貌、清楚。 + +## App 能做什么 + +- 使用 Multica 服务的邮箱验证码流程登录。 +- 浏览 workspace、Inbox、Issue、Project、Agent、Runtime、Skill、Label 和 Settings。 +- 通过 Multica 服务 API 创建和更新 Issue、评论、标签、项目、Agent、Skill 等资源。 +- 在不内置服务端的前提下,把日常 Multica workspace 闭环带到 Android。 + +## 隐私与 analytics + +Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analytics。 + +同意后,公开 Android build 只上报 `app_open` 启动事件: + +- 中国大陆路线:友盟 +- 全球路线:PostHog +- App 不会有意上报页面访问、聊天内容、文件内容、邮箱、token 或精确位置。 + +如果本地不配置 analytics key,analytics provider 不会启动。 + +## 构建 + +环境要求: + +- Android Studio / Android SDK +- JDK 17;推荐使用 Android Studio 自带 JBR + +创建本地配置: + +```bash +cp local.properties.example local.properties +``` + +可选 analytics key 只填在本地。不要提交真实 key。 + +构建 Android APK: + +```bash +JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ +ANDROID_HOME="$HOME/Library/Android/sdk" \ +./gradlew assemblePublicDebug --console=plain +``` + +APK 输出: + +```text +app/build/outputs/apk/public/debug/app-public-debug.apk +``` + +本地安装: + +```bash +$ANDROID_HOME/platform-tools/adb install -r app/build/outputs/apk/public/debug/app-public-debug.apk +``` + +## 本地 analytics 配置 + +`local.properties.example` 记录了可选本地 key: + +```properties +multicaUmengPublicAppKey= +multicaPostHogPublicApiKey= +multicaPostHogPublicHost=https://us.i.posthog.com +multicaAnalyticsRoute=auto +``` + +普通公开 build 使用 `multicaAnalyticsRoute=auto`。App 会在用户同意后自动选择 analytics 路线。 + +## 贡献 + +欢迎贡献,但需要保持边界清楚: + +- 保持这是面向 Multica 用户的独立 Android companion app。 +- 不暗示 Multica 维护者背书。 +- 不为普通用户加入运行时后端切换。 +- 不提交 secrets、analytics key、私有 endpoint 或公司内部材料。 +- 涉及隐私的改动要保守、清晰、容易审阅。 + +## License + +本仓库使用 **GPL-3.0-only** 授权。完整协议文本见 `LICENSE`。 + +如果你分发基于本项目修改后的 APK 或其他二进制产物,需要按 GPL-3.0-only 提供对应源码,并保留第三方依赖 notices。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2732287 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,32 @@ +# Third-Party Notices + +This project is licensed under GPL-3.0-only. It also uses third-party open source libraries with their own licenses. + +This notice is informational and does not replace the license text shipped by each dependency. + +## Android runtime dependencies + +| Dependency group | Representative artifacts | License | +|---|---|---| +| AndroidX / Jetpack Compose | `androidx.activity`, `androidx.compose`, `androidx.lifecycle`, `androidx.core` | Apache-2.0 | +| Kotlin / Kotlinx | `org.jetbrains.kotlin`, `org.jetbrains.kotlinx` | Apache-2.0 | +| OkHttp / Okio | `com.squareup.okhttp3`, `com.squareup.okio` | Apache-2.0 | +| Coil | `io.coil-kt:coil-compose` | Apache-2.0 | +| Lottie | `com.airbnb.android:lottie-compose` | Apache-2.0 | +| Haze | `dev.chrisbanes.haze:haze` | Apache-2.0 | +| Cupertino UI | `io.github.alexzhirkevich:cupertino` | Apache-2.0 | +| Umeng SDK | `com.umeng.umsdk:common`, `com.umeng.umsdk:asms` | Apache-2.0 | +| PostHog Android SDK | `com.posthog:posthog-android`, `com.posthog:posthog` | MIT | +| Guava listenablefuture placeholder | `com.google.guava:listenablefuture` | Apache-2.0; Maven POM has limited license metadata | + +## Website dependencies + +The website source uses React, Vite, Motion, TypeScript, Lightning CSS, and related build-time packages. The lockfile currently contains MIT, Apache-2.0, MPL-2.0, ISC, BSD-3-Clause, and 0BSD packages. + +These website dependencies are not bundled into the Android APK. + +## Notes for redistributors + +If you redistribute a binary build, keep this file, the GPL-3.0-only `LICENSE`, and any dependency notices required by the upstream libraries you include. + +If you change dependency versions, regenerate this notice from the release dependency tree before publishing a new APK. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..b98cdc1 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,53 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" + id "org.jetbrains.kotlin.plugin.compose" +} + +android { + namespace "ai.multica.app" + compileSdk 36 + buildToolsVersion "36.1.0" + + defaultConfig { + applicationId "ai.multica.app" + minSdk 24 + targetSdk 36 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose true + } +} + +dependencies { + implementation platform("androidx.compose:compose-bom:2024.10.01") + implementation "androidx.activity:activity-compose:1.9.3" + implementation "androidx.compose.foundation:foundation" + implementation "androidx.compose.material3:material3" + implementation "androidx.compose.material:material-icons-extended" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation "io.github.alexzhirkevich:cupertino:0.1.0-alpha04" + implementation "dev.chrisbanes.haze:haze:1.6.10" + implementation "com.airbnb.android:lottie-compose:6.7.1" + implementation "io.coil-kt:coil-compose:2.7.0" + testImplementation "junit:junit:4.13.2" + testImplementation "org.json:json:20240303" + androidTestImplementation "androidx.test:runner:1.6.2" + androidTestImplementation "androidx.test.ext:junit:1.2.1" + androidTestImplementation "androidx.test.uiautomator:uiautomator:2.3.0" + debugImplementation "androidx.compose.ui:ui-tooling" +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c48aff1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ai/multica/app/ApiClient.java b/app/src/main/java/ai/multica/app/ApiClient.java new file mode 100644 index 0000000..9198ddc --- /dev/null +++ b/app/src/main/java/ai/multica/app/ApiClient.java @@ -0,0 +1,1582 @@ +package ai.multica.app; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +final class ApiClient { + interface Parser { + T parse(JSONObject json) throws Exception; + } + + private static final String BASE = "https://api.multica.ai"; + private final AuthStore authStore; + + ApiClient(AuthStore authStore) { + this.authStore = authStore; + } + + void sendCode(String email) throws Exception { + JSONObject body = new JSONObject(); + body.put("email", email); + requestObject("POST", "/auth/send-code", null, body); + } + + String verifyCode(String email, String code) throws Exception { + JSONObject body = new JSONObject(); + body.put("email", email); + body.put("code", code); + return requestObject("POST", "/auth/verify-code", null, body).optString("token"); + } + + Models.User me() throws Exception { + return new Models.User(requestObject("GET", "/api/me", null, null)); + } + + Models.User updateMe(String name, String avatarUrl, String language) throws Exception { + JSONObject body = new JSONObject(); + if (name != null) body.put("name", name); + if (avatarUrl != null) body.put("avatar_url", avatarUrl); + if (language != null) body.put("language", language); + return new Models.User(requestObject("PATCH", "/api/me", null, body)); + } + + Models.User markOnboardingComplete(String completionPath) throws Exception { + JSONObject body = new JSONObject(); + if (completionPath != null && !completionPath.trim().isEmpty()) { + body.put("completion_path", completionPath.trim()); + } + return new Models.User(requestObject("POST", "/api/me/onboarding/complete", null, body)); + } + + Models.User joinCloudWaitlist(String email, String reason) throws Exception { + JSONObject body = new JSONObject(); + body.put("email", email); + if (reason != null && !reason.trim().isEmpty()) body.put("reason", reason.trim()); + return new Models.User(requestObject("POST", "/api/me/onboarding/cloud-waitlist", null, body)); + } + + Models.User patchOnboarding(JSONObject questionnaire) throws Exception { + JSONObject body = new JSONObject(); + body.put("questionnaire", questionnaire == null ? new JSONObject() : questionnaire); + return new Models.User(requestObject("PATCH", "/api/me/onboarding", null, body)); + } + + Models.StarterContentImportResponse importStarterContent(JSONObject payload) throws Exception { + return new Models.StarterContentImportResponse( + requestObject("POST", "/api/me/starter-content/import", null, payload == null ? new JSONObject() : payload)); + } + + Models.User dismissStarterContent(String workspaceId) throws Exception { + JSONObject body = new JSONObject(); + if (workspaceId != null && !workspaceId.trim().isEmpty()) body.put("workspace_id", workspaceId.trim()); + return new Models.User(requestObject("POST", "/api/me/starter-content/dismiss", null, body)); + } + + List workspaces() throws Exception { + JSONObject json = requestObject("GET", "/api/workspaces", null, null); + return parseArray(extractArray(json, "workspaces"), Models.Workspace::new); + } + + Models.Workspace workspace(String workspaceId) throws Exception { + return new Models.Workspace(unwrap(requestObject("GET", "/api/workspaces/" + encPath(workspaceId), + query(null, "workspace_id", workspaceId), null), "workspace")); + } + + Models.Workspace createWorkspace(String name, String slug, String description, String context) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("slug", slug); + if (description == null || description.isEmpty()) body.put("description", JSONObject.NULL); + else body.put("description", description); + if (context == null || context.isEmpty()) body.put("context", JSONObject.NULL); + else body.put("context", context); + return new Models.Workspace(unwrap(requestObject("POST", "/api/workspaces", null, body), "workspace")); + } + + Models.Workspace updateWorkspace(String workspaceId, String name, String description, String context) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + if (description == null || description.isEmpty()) body.put("description", JSONObject.NULL); + else body.put("description", description); + if (context == null || context.isEmpty()) body.put("context", JSONObject.NULL); + else body.put("context", context); + return new Models.Workspace(unwrap(requestObject("PATCH", "/api/workspaces/" + encPath(workspaceId), + query(null, "workspace_id", workspaceId), body), "workspace")); + } + + Models.Workspace updateWorkspaceRepos(String workspaceId, List repoUrls) throws Exception { + JSONArray repos = new JSONArray(); + if (repoUrls != null) { + for (String url : repoUrls) { + if (url == null || url.trim().isEmpty()) continue; + repos.put(new JSONObject().put("url", url.trim())); + } + } + JSONObject body = new JSONObject(); + body.put("repos", repos); + return new Models.Workspace(unwrap(requestObject("PATCH", "/api/workspaces/" + encPath(workspaceId), + query(null, "workspace_id", workspaceId), body), "workspace")); + } + + Models.Workspace updateWorkspaceCoAuthoredBy(String workspaceId, boolean enabled) throws Exception { + JSONObject settings = new JSONObject(workspace(workspaceId).settings.toString()); + settings.put("co_authored_by_enabled", enabled); + JSONObject body = new JSONObject(); + body.put("settings", settings); + return new Models.Workspace(unwrap(requestObject("PATCH", "/api/workspaces/" + encPath(workspaceId), + query(null, "workspace_id", workspaceId), body), "workspace")); + } + + void deleteWorkspace(String workspaceId) throws Exception { + requestObject("DELETE", "/api/workspaces/" + encPath(workspaceId), + query(null, "workspace_id", workspaceId), null); + } + + void leaveWorkspace(String workspaceId) throws Exception { + requestObject("POST", "/api/workspaces/" + encPath(workspaceId) + "/leave", + query(null, "workspace_id", workspaceId), null); + } + + Models.Page inbox(String workspaceId, int limit, int offset) throws Exception { + JSONObject json = requestObject("GET", "/api/inbox", query(null, "workspace_id", workspaceId, "limit", limit, "offset", offset), null); + if (offset == 0) { + authStore.saveInboxCache(workspaceId, json.toString()); + } + return parsePage(json, "inbox", Models.InboxItem::new); + } + + Models.Page cachedInbox(String workspaceId) throws Exception { + String cached = authStore.inboxCache(workspaceId); + if (cached == null || cached.trim().isEmpty()) return null; + return parsePage(new JSONObject(cached), "inbox", Models.InboxItem::new); + } + + Models.InboxItem markInboxRead(String workspaceId, String id) throws Exception { + return new Models.InboxItem(requestObject("POST", "/api/inbox/" + encPath(id) + "/read", + query(null, "workspace_id", workspaceId), null)); + } + + Models.InboxItem archiveInbox(String workspaceId, String id) throws Exception { + return new Models.InboxItem(requestObject("POST", "/api/inbox/" + encPath(id) + "/archive", + query(null, "workspace_id", workspaceId), null)); + } + + int markAllInboxRead(String workspaceId) throws Exception { + return requestObject("POST", "/api/inbox/mark-all-read", + query(null, "workspace_id", workspaceId), null).optInt("count", 0); + } + + int archiveAllReadInbox(String workspaceId) throws Exception { + return requestObject("POST", "/api/inbox/archive-all-read", + query(null, "workspace_id", workspaceId), null).optInt("count", 0); + } + + int archiveAllInbox(String workspaceId) throws Exception { + return requestObject("POST", "/api/inbox/archive-all", + query(null, "workspace_id", workspaceId), null).optInt("count", 0); + } + + int archiveCompletedInbox(String workspaceId) throws Exception { + return requestObject("POST", "/api/inbox/archive-completed", + query(null, "workspace_id", workspaceId), null).optInt("count", 0); + } + + Models.Page issues(String workspaceId, int limit, int offset) throws Exception { + return issues(workspaceId, limit, offset, null); + } + + Models.Page issues(String workspaceId, int limit, int offset, String assigneeId) throws Exception { + return issues(workspaceId, limit, offset, assigneeId, null); + } + + Models.Page issues(String workspaceId, int limit, int offset, String assigneeId, String status) throws Exception { + return issuesFiltered(workspaceId, limit, offset, assigneeId, status, null, Collections.emptyList()); + } + + Models.Page issuesFiltered(String workspaceId, int limit, int offset, String assigneeId, String status, + String creatorId, List assigneeIds) throws Exception { + JSONObject query = query(null, "workspace_id", workspaceId, "limit", limit, "offset", offset); + if (assigneeId != null && !assigneeId.isEmpty()) query.put("assignee_id", assigneeId); + if (status != null && !status.isEmpty()) query.put("status", status); + if (creatorId != null && !creatorId.isEmpty()) query.put("creator_id", creatorId); + if (assigneeIds != null && !assigneeIds.isEmpty()) query.put("assignee_ids", String.join(",", assigneeIds)); + JSONObject json = requestObject("GET", "/api/issues", query, null); + return parsePage(json, "issues", Models.Issue::new); + } + + Models.Issue issue(String id) throws Exception { + return new Models.Issue(requestObject("GET", "/api/issues/" + encPath(id), null, null)); + } + + Models.Issue issue(String id, String workspaceId) throws Exception { + return new Models.Issue(requestObject("GET", "/api/issues/" + encPath(id), + query(null, "workspace_id", workspaceId), null)); + } + + Models.Page projectIssues(String workspaceId, String projectId, int limit, int offset) throws Exception { + return projectIssues(workspaceId, projectId, null, limit, offset); + } + + Models.Page projectIssues(String workspaceId, String projectId, String status, int limit, int offset) throws Exception { + JSONObject query = query(null, "workspace_id", workspaceId, "project_id", projectId, "limit", limit, "offset", offset); + if (status != null && !status.isEmpty()) query.put("status", status); + JSONObject json = requestObject("GET", "/api/issues", query, null); + return parsePage(json, "issues", Models.Issue::new); + } + + List searchIssues(String workspaceId, String text, int limit) throws Exception { + JSONObject query = query(null, + "workspace_id", workspaceId, + "q", text, + "limit", limit, + "include_closed", "true"); + JSONObject json = requestObject("GET", "/api/issues/search", query, null); + return parseArray(extractArray(json, "issues"), Models.Issue::new); + } + + Models.Issue createIssue(String title, String description, String workspaceId, String projectId, + String status, String priority, Models.Assignee assignee) throws Exception { + return createIssue(title, description, workspaceId, projectId, status, priority, assignee, null); + } + + Models.Issue createIssue(String title, String description, String workspaceId, String projectId, + String status, String priority, Models.Assignee assignee, String dueDate) throws Exception { + return createIssue(title, description, workspaceId, projectId, status, priority, assignee, dueDate, null); + } + + Models.Issue createIssue(String title, String description, String workspaceId, String projectId, + String status, String priority, Models.Assignee assignee, String dueDate, + String parentIssueId) throws Exception { + JSONObject body = new JSONObject(); + body.put("title", title); + body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description); + body.put("workspace_id", workspaceId); + if (projectId != null) body.put("project_id", projectId); + if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId); + if (status != null) body.put("status", status); + if (priority != null) body.put("priority", priority); + if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL); + if (assignee != null && assignee.id != null) { + body.put("assignee_id", assignee.id); + body.put("assignee_type", assignee.type); + } + return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body)); + } + + List childIssues(String workspaceId, String issueId) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/children", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "issues"), Models.Issue::new); + } + + Models.Issue updateIssue(Models.Issue issue, String title, String description, String projectId, + String status, String priority, Models.Assignee assignee) throws Exception { + return updateIssue(issue, title, description, projectId, status, priority, assignee, issue.dueDate); + } + + Models.Issue updateIssue(Models.Issue issue, String title, String description, String projectId, + String status, String priority, Models.Assignee assignee, String dueDate) throws Exception { + JSONObject body = new JSONObject(); + body.put("title", title); + body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description); + body.put("status", status); + body.put("priority", priority); + if (projectId == null) body.put("project_id", JSONObject.NULL); else body.put("project_id", projectId); + if (dueDate == null || dueDate.isEmpty()) body.put("due_date", JSONObject.NULL); else body.put("due_date", dueDate); + if (assignee == null || assignee.id == null) { + body.put("assignee_id", JSONObject.NULL); + body.put("assignee_type", JSONObject.NULL); + } else { + body.put("assignee_id", assignee.id); + body.put("assignee_type", assignee.type); + } + return new Models.Issue(requestObject("PUT", "/api/issues/" + encPath(issue.id), + query(null, "workspace_id", issue.workspaceId), body)); + } + + Models.Issue updateIssueParent(Models.Issue issue, String parentIssueId) throws Exception { + JSONObject body = new JSONObject(); + if (parentIssueId == null || parentIssueId.isEmpty()) { + body.put("parent_issue_id", JSONObject.NULL); + } else { + body.put("parent_issue_id", parentIssueId); + } + return new Models.Issue(requestObject("PUT", "/api/issues/" + encPath(issue.id), + query(null, "workspace_id", issue.workspaceId), body)); + } + + Models.Issue updateIssueStatus(String workspaceId, String issueId, String status) throws Exception { + JSONObject body = new JSONObject(); + body.put("status", status); + return new Models.Issue(requestObject("PUT", "/api/issues/" + encPath(issueId), + query(null, "workspace_id", workspaceId), body)); + } + + Models.QuickCreateTask quickCreateIssue(String workspaceId, String agentId, String prompt) throws Exception { + JSONObject body = new JSONObject(); + body.put("workspace_id", workspaceId); + body.put("agent_id", agentId); + body.put("prompt", prompt); + return new Models.QuickCreateTask(requestObject("POST", "/api/issues/quick-create", + query(null, "workspace_id", workspaceId), body)); + } + + Models.IssueUsage issueUsage(String workspaceId, String issueId) throws Exception { + return new Models.IssueUsage(requestObject("GET", "/api/issues/" + encPath(issueId) + "/usage", + query(null, "workspace_id", workspaceId), null)); + } + + Models.TimelinePage issueTimeline(String workspaceId, String issueId, int limit, String beforeCursor, String afterCursor) throws Exception { + return issueTimeline(workspaceId, issueId, limit, beforeCursor, afterCursor, null); + } + + Models.TimelinePage issueTimeline(String workspaceId, String issueId, int limit, String beforeCursor, String afterCursor, String aroundId) throws Exception { + JSONObject params = query(null, "workspace_id", workspaceId, "limit", limit); + if (beforeCursor != null && !beforeCursor.isEmpty()) params.put("before", beforeCursor); + if (afterCursor != null && !afterCursor.isEmpty()) params.put("after", afterCursor); + if (aroundId != null && !aroundId.isEmpty()) params.put("around", aroundId); + return new Models.TimelinePage(requestObject("GET", "/api/issues/" + encPath(issueId) + "/timeline", params, null)); + } + + List activeIssueTasks(String workspaceId, String issueId) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/active-task", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "tasks"), Models.AgentTask::new); + } + + Models.AgentTask cancelIssueTask(String workspaceId, String issueId, String taskId) throws Exception { + return new Models.AgentTask(requestObject("POST", + "/api/issues/" + encPath(issueId) + "/tasks/" + encPath(taskId) + "/cancel", + query(null, "workspace_id", workspaceId), null)); + } + + void deleteIssue(String workspaceId, String issueId) throws Exception { + requestObject("DELETE", "/api/issues/" + encPath(issueId), + query(null, "workspace_id", workspaceId), null); + } + + int batchUpdateIssues(String workspaceId, List issueIds, String status, String priority) throws Exception { + return batchUpdateIssues(workspaceId, issueIds, status, priority, null, null); + } + + int batchUpdateIssues(String workspaceId, List issueIds, String status, String priority, + String assigneeType, String assigneeId) throws Exception { + JSONObject updates = new JSONObject(); + if (status != null && !status.isEmpty()) updates.put("status", status); + if (priority != null && !priority.isEmpty()) updates.put("priority", priority); + if (assigneeType != null && !assigneeType.isEmpty()) updates.put("assignee_type", assigneeType); + if (assigneeId != null && !assigneeId.isEmpty()) updates.put("assignee_id", assigneeId); + JSONObject body = new JSONObject(); + JSONArray ids = new JSONArray(); + for (String id : issueIds) ids.put(id); + body.put("issue_ids", ids); + body.put("updates", updates); + return requestObject("POST", "/api/issues/batch-update", + query(null, "workspace_id", workspaceId), body).optInt("updated", 0); + } + + int batchDeleteIssues(String workspaceId, List issueIds) throws Exception { + JSONObject body = new JSONObject(); + JSONArray ids = new JSONArray(); + for (String id : issueIds) ids.put(id); + body.put("issue_ids", ids); + return requestObject("POST", "/api/issues/batch-delete", + query(null, "workspace_id", workspaceId), body).optInt("deleted", 0); + } + + Models.Page comments(String issueId, int limit, int offset) throws Exception { + return comments(issueId, null, limit, offset); + } + + Models.Page comments(String issueId, String workspaceId, int limit, int offset) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/comments", + query(null, "workspace_id", workspaceId, "limit", limit, "offset", offset), null); + return parsePage(json, "comments", Models.Comment::new); + } + + Models.Comment addComment(String issueId, String content) throws Exception { + return addComment(issueId, null, content); + } + + Models.Comment addComment(String issueId, String workspaceId, String content) throws Exception { + return addComment(issueId, workspaceId, content, null); + } + + Models.Comment addComment(String issueId, String workspaceId, String content, List attachmentIds) throws Exception { + return addComment(issueId, workspaceId, content, attachmentIds, null); + } + + Models.Comment addComment(String issueId, String workspaceId, String content, List attachmentIds, String parentId) throws Exception { + JSONObject body = new JSONObject(); + body.put("content", content); + body.put("type", "comment"); + body.put("parent_id", parentId == null || parentId.isEmpty() ? JSONObject.NULL : parentId); + if (attachmentIds != null && !attachmentIds.isEmpty()) { + JSONArray ids = new JSONArray(); + for (String id : attachmentIds) ids.put(id); + body.put("attachment_ids", ids); + } + return new Models.Comment(requestObject("POST", "/api/issues/" + encPath(issueId) + "/comments", + query(null, "workspace_id", workspaceId), body)); + } + + Models.Comment addCommentAsAgent(String issueId, String workspaceId, String agentId, String content) throws Exception { + JSONObject body = new JSONObject(); + body.put("content", content); + body.put("type", "comment"); + body.put("parent_id", JSONObject.NULL); + return new Models.Comment(requestObject("POST", "/api/issues/" + encPath(issueId) + "/comments", + query(null, "workspace_id", workspaceId), body, Collections.singletonMap("X-Agent-ID", agentId))); + } + + Models.Comment updateComment(String workspaceId, String commentId, String content) throws Exception { + JSONObject body = new JSONObject(); + body.put("content", content); + return new Models.Comment(requestObject("PUT", "/api/comments/" + encPath(commentId), + query(null, "workspace_id", workspaceId), body)); + } + + void deleteComment(String workspaceId, String commentId) throws Exception { + requestObject("DELETE", "/api/comments/" + encPath(commentId), + query(null, "workspace_id", workspaceId), null); + } + + Models.Reaction addCommentReaction(String workspaceId, String commentId, String emoji) throws Exception { + JSONObject body = new JSONObject(); + body.put("emoji", emoji); + JSONObject json = requestObject("POST", "/api/comments/" + encPath(commentId) + "/reactions", + query(null, "workspace_id", workspaceId), body); + return new Models.Reaction(unwrap(json, "reaction")); + } + + void removeCommentReaction(String workspaceId, String commentId, String emoji) throws Exception { + JSONObject body = new JSONObject(); + body.put("emoji", emoji); + requestObject("DELETE", "/api/comments/" + encPath(commentId) + "/reactions", + query(null, "workspace_id", workspaceId), body); + } + + Models.Reaction addIssueReaction(String workspaceId, String issueId, String emoji) throws Exception { + JSONObject body = new JSONObject(); + body.put("emoji", emoji); + JSONObject json = requestObject("POST", "/api/issues/" + encPath(issueId) + "/reactions", + query(null, "workspace_id", workspaceId), body); + return new Models.Reaction(unwrap(json, "reaction")); + } + + void removeIssueReaction(String workspaceId, String issueId, String emoji) throws Exception { + JSONObject body = new JSONObject(); + body.put("emoji", emoji); + requestObject("DELETE", "/api/issues/" + encPath(issueId) + "/reactions", + query(null, "workspace_id", workspaceId), body); + } + + List issueSubscribers(String workspaceId, String issueId) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/subscribers", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "subscribers"), Models.IssueSubscriber::new); + } + + void subscribeToIssue(String workspaceId, String issueId, String userId, String userType) throws Exception { + JSONObject body = new JSONObject(); + if (userId != null && !userId.isEmpty()) body.put("user_id", userId); + if (userType != null && !userType.isEmpty()) body.put("user_type", userType); + requestObject("POST", "/api/issues/" + encPath(issueId) + "/subscribe", + query(null, "workspace_id", workspaceId), body); + } + + void unsubscribeFromIssue(String workspaceId, String issueId, String userId, String userType) throws Exception { + JSONObject body = new JSONObject(); + if (userId != null && !userId.isEmpty()) body.put("user_id", userId); + if (userType != null && !userType.isEmpty()) body.put("user_type", userType); + requestObject("POST", "/api/issues/" + encPath(issueId) + "/unsubscribe", + query(null, "workspace_id", workspaceId), body); + } + + List issueAttachments(String workspaceId, String issueId) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/attachments", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "attachments"), Models.Attachment::new); + } + + Models.Attachment uploadIssueAttachment(String workspaceId, String issueId, String filename, + String contentType, byte[] data) throws Exception { + JSONObject fields = new JSONObject(); + fields.put("issue_id", issueId); + JSONObject json = requestMultipart("/api/upload-file", query(null, "workspace_id", workspaceId), + fields, "file", filename, contentType, data); + return new Models.Attachment(json); + } + + Models.Attachment uploadAttachment(String workspaceId, String filename, String contentType, byte[] data) throws Exception { + JSONObject json = requestMultipart("/api/upload-file", query(null, "workspace_id", workspaceId), + "file", filename, contentType, data); + return new Models.Attachment(json); + } + + void deleteAttachment(String workspaceId, String attachmentId) throws Exception { + requestObject("DELETE", "/api/attachments/" + encPath(attachmentId), + query(null, "workspace_id", workspaceId), null); + } + + List agentRuns(String issueId) throws Exception { + return agentRuns(issueId, null); + } + + List agentRuns(String issueId, String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/task-runs", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "runs"), Models.AgentTask::new); + } + + List agentTasks(String workspaceId, String agentId) throws Exception { + JSONObject json = requestObject("GET", "/api/agents/" + encPath(agentId) + "/tasks", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "tasks"), Models.AgentTask::new); + } + + List agentTaskSnapshot(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/agent-task-snapshot", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "tasks"), Models.AgentTask::new); + } + + List agentActivity30d(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/agent-activity-30d", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "activity"), Models.AgentActivityBucket::new); + } + + List agentRunCounts30d(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/agent-run-counts", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "run_counts"), Models.AgentRunCount::new); + } + + List runMessages(String taskId) throws Exception { + return runMessages(taskId, null); + } + + List runMessages(String taskId, String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/tasks/" + encPath(taskId) + "/messages", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "messages"), Models.TaskMessage::new); + } + + Models.Page projects(String workspaceId, int limit, int offset) throws Exception { + JSONObject query = query(null, "workspace_id", workspaceId, "limit", limit, "offset", offset); + JSONObject json = requestObject("GET", "/api/projects", query, null); + return parsePage(json, "projects", Models.Project::new); + } + + List searchProjects(String workspaceId, String text, int limit) throws Exception { + JSONObject query = query(null, + "workspace_id", workspaceId, + "q", text, + "limit", limit, + "include_closed", "true"); + JSONObject json = requestObject("GET", "/api/projects/search", query, null); + return parseArray(extractArray(json, "projects"), Models.Project::new); + } + + Models.Project project(String workspaceId, String projectId) throws Exception { + return new Models.Project(unwrap(requestObject("GET", "/api/projects/" + encPath(projectId), + query(null, "workspace_id", workspaceId), null), "project")); + } + + Models.Project createProject(String workspaceId, String title, String description) throws Exception { + return createProject(workspaceId, title, description, "planned", "none", "", "", "", new ArrayList<>()); + } + + Models.Project createProject( + String workspaceId, + String title, + String description, + String status, + String priority, + String icon, + String leadType, + String leadId, + List resourceUrls + ) throws Exception { + JSONObject body = new JSONObject(); + body.put("title", title); + body.put("workspace_id", workspaceId); + if (description == null || description.isEmpty()) body.put("description", JSONObject.NULL); + else body.put("description", description); + body.put("status", status == null || status.isEmpty() ? "planned" : status); + body.put("priority", priority == null || priority.isEmpty() ? "none" : priority); + if (icon == null || icon.isEmpty()) body.put("icon", JSONObject.NULL); + else body.put("icon", icon); + if (leadType == null || leadType.isEmpty() || leadId == null || leadId.isEmpty()) { + body.put("lead_type", JSONObject.NULL); + body.put("lead_id", JSONObject.NULL); + } else { + body.put("lead_type", leadType); + body.put("lead_id", leadId); + } + if (resourceUrls != null && !resourceUrls.isEmpty()) { + JSONArray resources = new JSONArray(); + int position = 0; + for (String url : resourceUrls) { + if (url == null || url.trim().isEmpty()) continue; + JSONObject resource = new JSONObject(); + resource.put("resource_type", "github_repo"); + resource.put("url", url.trim()); + resource.put("position", position++); + resources.put(resource); + } + if (resources.length() > 0) body.put("resources", resources); + } + return new Models.Project(unwrap(requestObject("POST", "/api/projects", + query(null, "workspace_id", workspaceId), body), "project")); + } + + Models.Project updateProject(String workspaceId, Models.Project project, String title, String description) throws Exception { + return updateProject(workspaceId, project, title, description, project.status, project.priority, project.icon, project.leadType, project.leadId); + } + + Models.Project updateProject( + String workspaceId, + Models.Project project, + String title, + String description, + String status, + String priority, + String icon, + String leadType, + String leadId + ) throws Exception { + JSONObject body = new JSONObject(); + body.put("title", title); + if (description == null || description.isEmpty()) body.put("description", JSONObject.NULL); + else body.put("description", description); + body.put("status", status == null || status.isEmpty() ? "planned" : status); + body.put("priority", priority == null || priority.isEmpty() ? "none" : priority); + if (icon == null || icon.isEmpty()) body.put("icon", JSONObject.NULL); + else body.put("icon", icon); + if (leadType == null || leadType.isEmpty() || leadId == null || leadId.isEmpty()) { + body.put("lead_type", JSONObject.NULL); + body.put("lead_id", JSONObject.NULL); + } else { + body.put("lead_type", leadType); + body.put("lead_id", leadId); + } + return new Models.Project(unwrap(requestObject("PUT", "/api/projects/" + encPath(project.id), + query(null, "workspace_id", workspaceId), body), "project")); + } + + void deleteProject(String workspaceId, String projectId) throws Exception { + requestObject("DELETE", "/api/projects/" + encPath(projectId), + query(null, "workspace_id", workspaceId), null); + } + + Models.Page projectResources(String workspaceId, String projectId) throws Exception { + JSONObject json = requestObject("GET", "/api/projects/" + encPath(projectId) + "/resources", + query(null, "workspace_id", workspaceId), null); + return parsePage(json, "resources", Models.ProjectResource::new); + } + + Models.ProjectResource createProjectResource(String workspaceId, String projectId, String resourceType, String url) throws Exception { + JSONObject ref = new JSONObject(); + ref.put("url", url); + JSONObject body = new JSONObject(); + body.put("resource_type", resourceType); + body.put("resource_ref", ref); + return new Models.ProjectResource(unwrap(requestObject("POST", "/api/projects/" + encPath(projectId) + "/resources", + query(null, "workspace_id", workspaceId), body), "resource")); + } + + void deleteProjectResource(String workspaceId, String projectId, String resourceId) throws Exception { + requestObject("DELETE", "/api/projects/" + encPath(projectId) + "/resources/" + encPath(resourceId), + query(null, "workspace_id", workspaceId), null); + } + + List pins(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/pins", query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "pins"), Models.PinnedItem::new); + } + + Models.PinnedItem createPin(String workspaceId, String itemType, String itemId) throws Exception { + JSONObject body = new JSONObject(); + body.put("item_type", itemType); + body.put("item_id", itemId); + return new Models.PinnedItem(unwrap(requestObject("POST", "/api/pins", + query(null, "workspace_id", workspaceId), body), "pin")); + } + + void deletePin(String workspaceId, String itemType, String itemId) throws Exception { + requestObject("DELETE", "/api/pins/" + encPath(itemType) + "/" + encPath(itemId), + query(null, "workspace_id", workspaceId), null); + } + + void reorderPins(String workspaceId, List orderedPins) throws Exception { + JSONArray items = new JSONArray(); + if (orderedPins != null) { + for (int i = 0; i < orderedPins.size(); i++) { + Models.PinnedItem pin = orderedPins.get(i); + if (pin == null || pin.id == null || pin.id.isEmpty()) continue; + items.put(new JSONObject() + .put("id", pin.id) + .put("position", i + 1)); + } + } + JSONObject body = new JSONObject().put("items", items); + requestObject("PUT", "/api/pins/reorder", query(null, "workspace_id", workspaceId), body); + } + + List agents(String workspaceId) throws Exception { + return agents(workspaceId, false); + } + + List agents(String workspaceId, boolean includeArchived) throws Exception { + JSONObject query = query(null, "workspace_id", workspaceId); + if (includeArchived) query.put("include_archived", "true"); + JSONObject json = requestObject("GET", "/api/agents", query, null); + return parseArray(extractArray(json, "agents"), Models.Agent::new); + } + + List runtimes(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/runtimes", query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "runtimes"), Models.Runtime::new); + } + + List runtimeUsage(String workspaceId, String runtimeId, int days) throws Exception { + JSONObject json = requestObject("GET", "/api/runtimes/" + encPath(runtimeId) + "/usage", + query(null, "workspace_id", workspaceId, "days", days), null); + return parseArray(extractArray(json, "usage"), Models.RuntimeUsage::new); + } + + List runtimeActivity(String workspaceId, String runtimeId) throws Exception { + JSONObject json = requestObject("GET", "/api/runtimes/" + encPath(runtimeId) + "/activity", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "activity"), Models.RuntimeHourlyActivity::new); + } + + List runtimeUsageByAgent(String workspaceId, String runtimeId, int days) throws Exception { + JSONObject json = requestObject("GET", "/api/runtimes/" + encPath(runtimeId) + "/usage/by-agent", + query(null, "workspace_id", workspaceId, "days", days), null); + return parseArray(extractArray(json, "usage"), Models.RuntimeUsageByAgent::new); + } + + List runtimeUsageByHour(String workspaceId, String runtimeId, int days) throws Exception { + JSONObject json = requestObject("GET", "/api/runtimes/" + encPath(runtimeId) + "/usage/by-hour", + query(null, "workspace_id", workspaceId, "days", days), null); + return parseArray(extractArray(json, "usage"), Models.RuntimeUsageByHour::new); + } + + void deleteRuntime(String workspaceId, String runtimeId) throws Exception { + requestObject("DELETE", "/api/runtimes/" + encPath(runtimeId), query(null, "workspace_id", workspaceId), null); + } + + Models.RuntimeUpdate initiateRuntimeUpdate(String workspaceId, String runtimeId, String targetVersion) throws Exception { + JSONObject body = new JSONObject(); + body.put("target_version", targetVersion); + JSONObject json = requestObject("POST", "/api/runtimes/" + encPath(runtimeId) + "/update", + query(null, "workspace_id", workspaceId), body); + return new Models.RuntimeUpdate(json); + } + + Models.RuntimeUpdate runtimeUpdateResult(String workspaceId, String runtimeId, String updateId) throws Exception { + return new Models.RuntimeUpdate(requestObject("GET", + "/api/runtimes/" + encPath(runtimeId) + "/update/" + encPath(updateId), + query(null, "workspace_id", workspaceId), null)); + } + + Models.RuntimeModelListRequest initiateListRuntimeModels(String workspaceId, String runtimeId) throws Exception { + return new Models.RuntimeModelListRequest(requestObject("POST", + "/api/runtimes/" + encPath(runtimeId) + "/models", + query(null, "workspace_id", workspaceId), null, null, 8000, 8000)); + } + + Models.RuntimeModelListRequest runtimeModelListResult(String workspaceId, String runtimeId, String requestId) throws Exception { + return new Models.RuntimeModelListRequest(requestObject("GET", + "/api/runtimes/" + encPath(runtimeId) + "/models/" + encPath(requestId), + query(null, "workspace_id", workspaceId), null)); + } + + Models.RuntimeLocalSkillListRequest initiateListRuntimeLocalSkills(String workspaceId, String runtimeId) throws Exception { + return new Models.RuntimeLocalSkillListRequest(requestObject("POST", + "/api/runtimes/" + encPath(runtimeId) + "/local-skills", + query(null, "workspace_id", workspaceId), null, null, 8000, 8000)); + } + + Models.RuntimeLocalSkillListRequest runtimeLocalSkillListResult(String workspaceId, String runtimeId, String requestId) throws Exception { + return new Models.RuntimeLocalSkillListRequest(requestObject("GET", + "/api/runtimes/" + encPath(runtimeId) + "/local-skills/" + encPath(requestId), + query(null, "workspace_id", workspaceId), null)); + } + + Models.RuntimeLocalSkillImportRequest initiateImportRuntimeLocalSkill(String workspaceId, String runtimeId, + String skillKey, String name, String description) throws Exception { + JSONObject body = new JSONObject(); + body.put("skill_key", skillKey); + if (name == null || name.trim().isEmpty()) body.put("name", JSONObject.NULL); else body.put("name", name.trim()); + if (description == null || description.trim().isEmpty()) body.put("description", JSONObject.NULL); + else body.put("description", description.trim()); + return new Models.RuntimeLocalSkillImportRequest(requestObject("POST", + "/api/runtimes/" + encPath(runtimeId) + "/local-skills/import", + query(null, "workspace_id", workspaceId), body)); + } + + Models.RuntimeLocalSkillImportRequest runtimeLocalSkillImportResult(String workspaceId, String runtimeId, String requestId) throws Exception { + return new Models.RuntimeLocalSkillImportRequest(requestObject("GET", + "/api/runtimes/" + encPath(runtimeId) + "/local-skills/import/" + encPath(requestId), + query(null, "workspace_id", workspaceId), null)); + } + + List skills(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/skills", query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "skills"), Models.Skill::new); + } + + Models.Skill skill(String workspaceId, String skillId) throws Exception { + return new Models.Skill(requestObject("GET", "/api/skills/" + encPath(skillId), + query(null, "workspace_id", workspaceId), null)); + } + + List skillFiles(String workspaceId, String skillId) throws Exception { + JSONObject json = requestObject("GET", "/api/skills/" + encPath(skillId) + "/files", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "items"), Models.SkillFile::new); + } + + Models.SkillFile upsertSkillFile(String workspaceId, String skillId, String path, String content) throws Exception { + JSONObject body = new JSONObject(); + body.put("path", path); + body.put("content", content == null ? "" : content); + return new Models.SkillFile(requestObject("PUT", "/api/skills/" + encPath(skillId) + "/files", + query(null, "workspace_id", workspaceId), body)); + } + + Models.Skill createSkill(String workspaceId, String name, String description, String content) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("description", description == null ? "" : description); + body.put("content", content == null ? "" : content); + return new Models.Skill(requestObject("POST", "/api/skills", query(null, "workspace_id", workspaceId), body)); + } + + Models.Skill updateSkill(String workspaceId, Models.Skill skill, String name, String description, String content) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("description", description == null ? "" : description); + body.put("content", content == null ? "" : content); + return new Models.Skill(requestObject("PUT", "/api/skills/" + encPath(skill.id), + query(null, "workspace_id", workspaceId), body)); + } + + Models.Skill importSkill(String workspaceId, String url) throws Exception { + JSONObject body = new JSONObject(); + body.put("url", url); + return new Models.Skill(requestObject("POST", "/api/skills/import", + query(null, "workspace_id", workspaceId), body)); + } + + void deleteSkill(String workspaceId, String skillId) throws Exception { + requestObject("DELETE", "/api/skills/" + encPath(skillId), query(null, "workspace_id", workspaceId), null); + } + + List autopilots(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/autopilots", query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "autopilots"), Models.Autopilot::new); + } + + Models.Autopilot createAutopilot(String workspaceId, String title, String description, String assigneeId, + String executionMode, String issueTitleTemplate) throws Exception { + JSONObject body = new JSONObject(); + body.put("title", title); + body.put("description", description == null || description.trim().isEmpty() ? JSONObject.NULL : description); + body.put("assignee_id", assigneeId); + body.put("execution_mode", executionMode == null || executionMode.isEmpty() ? "create_issue" : executionMode); + if (issueTitleTemplate == null || issueTitleTemplate.trim().isEmpty()) { + body.put("issue_title_template", JSONObject.NULL); + } else { + body.put("issue_title_template", issueTitleTemplate); + } + return new Models.Autopilot(unwrap(requestObject("POST", "/api/autopilots", query(null, "workspace_id", workspaceId), body), "autopilot")); + } + + Models.Autopilot updateAutopilot(String workspaceId, Models.Autopilot autopilot, String title, String description, + String assigneeId, String status, String executionMode, String issueTitleTemplate) throws Exception { + JSONObject body = new JSONObject(); + body.put("title", title); + body.put("description", description == null || description.trim().isEmpty() ? JSONObject.NULL : description); + body.put("assignee_id", assigneeId); + body.put("status", status == null || status.isEmpty() ? "active" : status); + body.put("execution_mode", executionMode == null || executionMode.isEmpty() ? "create_issue" : executionMode); + if (issueTitleTemplate == null || issueTitleTemplate.trim().isEmpty()) { + body.put("issue_title_template", JSONObject.NULL); + } else { + body.put("issue_title_template", issueTitleTemplate); + } + return new Models.Autopilot(unwrap(requestObject("PATCH", "/api/autopilots/" + encPath(autopilot.id), + query(null, "workspace_id", workspaceId), body), "autopilot")); + } + + void deleteAutopilot(String workspaceId, String autopilotId) throws Exception { + requestObject("DELETE", "/api/autopilots/" + encPath(autopilotId), query(null, "workspace_id", workspaceId), null); + } + + Models.AutopilotRun triggerAutopilot(String workspaceId, String autopilotId) throws Exception { + return new Models.AutopilotRun(unwrap(requestObject("POST", "/api/autopilots/" + encPath(autopilotId) + "/trigger", + query(null, "workspace_id", workspaceId), null), "run")); + } + + List autopilotTriggers(String workspaceId, String autopilotId) throws Exception { + JSONObject json = requestObject("GET", "/api/autopilots/" + encPath(autopilotId), + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "triggers"), Models.AutopilotTrigger::new); + } + + List autopilotRuns(String workspaceId, String autopilotId) throws Exception { + JSONObject json = requestObject("GET", "/api/autopilots/" + encPath(autopilotId) + "/runs", + query(null, "workspace_id", workspaceId, "limit", 20, "offset", 0), null); + return parseArray(extractArray(json, "runs"), Models.AutopilotRun::new); + } + + Models.AutopilotTrigger createAutopilotTrigger(String workspaceId, String autopilotId, String kind, + String cronExpression, String timezone, String label) throws Exception { + JSONObject body = new JSONObject(); + body.put("kind", normalizedAutopilotTriggerKind(kind)); + if (cronExpression == null || cronExpression.trim().isEmpty()) body.put("cron_expression", JSONObject.NULL); + else body.put("cron_expression", cronExpression.trim()); + if (timezone == null || timezone.trim().isEmpty()) body.put("timezone", JSONObject.NULL); + else body.put("timezone", timezone.trim()); + if (label == null || label.trim().isEmpty()) body.put("label", JSONObject.NULL); + else body.put("label", label.trim()); + return new Models.AutopilotTrigger(unwrap(requestObject("POST", + "/api/autopilots/" + encPath(autopilotId) + "/triggers", + query(null, "workspace_id", workspaceId), body), "trigger")); + } + + static String normalizedAutopilotTriggerKind(String kind) { + if (kind == null || kind.trim().isEmpty() || "cron".equals(kind.trim())) return "schedule"; + return kind.trim(); + } + + Models.AutopilotTrigger updateAutopilotTrigger(String workspaceId, String autopilotId, String triggerId, + Boolean enabled, String cronExpression, String timezone, + String label) throws Exception { + JSONObject body = new JSONObject(); + if (enabled == null) body.put("enabled", JSONObject.NULL); + else body.put("enabled", enabled.booleanValue()); + if (cronExpression == null || cronExpression.trim().isEmpty()) body.put("cron_expression", JSONObject.NULL); + else body.put("cron_expression", cronExpression.trim()); + if (timezone == null || timezone.trim().isEmpty()) body.put("timezone", JSONObject.NULL); + else body.put("timezone", timezone.trim()); + if (label == null || label.trim().isEmpty()) body.put("label", JSONObject.NULL); + else body.put("label", label.trim()); + return new Models.AutopilotTrigger(unwrap(requestObject("PATCH", + "/api/autopilots/" + encPath(autopilotId) + "/triggers/" + encPath(triggerId), + query(null, "workspace_id", workspaceId), body), "trigger")); + } + + void deleteAutopilotTrigger(String workspaceId, String autopilotId, String triggerId) throws Exception { + requestObject("DELETE", "/api/autopilots/" + encPath(autopilotId) + "/triggers/" + encPath(triggerId), + query(null, "workspace_id", workspaceId), null); + } + + List labels(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/labels", query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "labels"), Models.IssueLabel::new); + } + + List issueLabels(String workspaceId, String issueId) throws Exception { + JSONObject json = requestObject("GET", "/api/issues/" + encPath(issueId) + "/labels", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "labels"), Models.IssueLabel::new); + } + + List attachIssueLabel(String workspaceId, String issueId, String labelId) throws Exception { + JSONObject body = new JSONObject(); + body.put("label_id", labelId); + JSONObject json = requestObject("POST", "/api/issues/" + encPath(issueId) + "/labels", + query(null, "workspace_id", workspaceId), body); + return parseArray(extractArray(json, "labels"), Models.IssueLabel::new); + } + + List detachIssueLabel(String workspaceId, String issueId, String labelId) throws Exception { + JSONObject json = requestObject("DELETE", "/api/issues/" + encPath(issueId) + "/labels/" + encPath(labelId), + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "labels"), Models.IssueLabel::new); + } + + Models.IssueLabel createLabel(String workspaceId, String name, String color) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("color", color); + return new Models.IssueLabel(requestObject("POST", "/api/labels", query(null, "workspace_id", workspaceId), body)); + } + + Models.IssueLabel updateLabel(String workspaceId, String labelId, String name, String color) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("color", color); + return new Models.IssueLabel(requestObject("PUT", "/api/labels/" + encPath(labelId), + query(null, "workspace_id", workspaceId), body)); + } + + void deleteLabel(String workspaceId, String labelId) throws Exception { + requestObject("DELETE", "/api/labels/" + encPath(labelId), + query(null, "workspace_id", workspaceId), null); + } + + void createFeedback(String workspaceId, String message, String url) throws Exception { + JSONObject body = new JSONObject(); + body.put("workspace_id", workspaceId); + body.put("message", message); + if (url != null && !url.trim().isEmpty()) body.put("url", url.trim()); + requestObject("POST", "/api/feedback", query(null, "workspace_id", workspaceId), body); + } + + List chatSessions(String workspaceId) throws Exception { + return chatSessions(workspaceId, null); + } + + List chatSessions(String workspaceId, String status) throws Exception { + JSONObject query = query(null, "workspace_id", workspaceId); + if (status != null && !status.trim().isEmpty()) { + query.put("status", status.trim()); + } + JSONObject json = requestObject("GET", "/api/chat/sessions", query, null); + return parseArray(extractArray(json, "sessions"), Models.ChatSession::new); + } + + Models.ChatSession chatSession(String workspaceId, String sessionId) throws Exception { + return new Models.ChatSession(requestObject("GET", "/api/chat/sessions/" + encPath(sessionId), + query(null, "workspace_id", workspaceId), null)); + } + + Models.ChatSession createChatSession(String workspaceId, String agentId, String title) throws Exception { + JSONObject body = new JSONObject(); + body.put("agent_id", agentId); + if (title != null && !title.trim().isEmpty()) body.put("title", title.trim()); + return new Models.ChatSession(requestObject("POST", "/api/chat/sessions", query(null, "workspace_id", workspaceId), body)); + } + + void archiveChatSession(String workspaceId, String sessionId) throws Exception { + requestObject("DELETE", "/api/chat/sessions/" + encPath(sessionId), + query(null, "workspace_id", workspaceId), null); + } + + List chatMessages(String workspaceId, String sessionId) throws Exception { + JSONObject json = requestObject("GET", "/api/chat/sessions/" + encPath(sessionId) + "/messages", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "messages"), Models.ChatMessage::new); + } + + void sendChatMessage(String workspaceId, String sessionId, String content) throws Exception { + JSONObject body = new JSONObject(); + body.put("content", content); + requestObject("POST", "/api/chat/sessions/" + encPath(sessionId) + "/messages", + query(null, "workspace_id", workspaceId), body); + } + + Models.ChatPendingTask pendingChatTask(String workspaceId, String sessionId) throws Exception { + return new Models.ChatPendingTask(requestObject("GET", + "/api/chat/sessions/" + encPath(sessionId) + "/pending-task", + query(null, "workspace_id", workspaceId), null)); + } + + List pendingChatTasks(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/chat/pending-tasks", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "tasks"), Models.PendingChatTaskItem::new); + } + + void markChatSessionRead(String workspaceId, String sessionId) throws Exception { + requestObject("POST", "/api/chat/sessions/" + encPath(sessionId) + "/read", + query(null, "workspace_id", workspaceId), null); + } + + void cancelTaskById(String workspaceId, String taskId) throws Exception { + requestObject("POST", "/api/tasks/" + encPath(taskId) + "/cancel", + query(null, "workspace_id", workspaceId), null); + } + + List memberSummaries(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/workspaces/" + encPath(workspaceId) + "/members", + query(null, "workspace_id", workspaceId), null); + JSONArrayLike array = new JSONArrayLike(extractArray(json, "members")); + List rows = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.getJSONObject(i); + rows.add(item.optString("name", item.optString("email", Models.shortId(item.optString("id")))) + + "\n" + item.optString("email") + + (item.optString("role").isEmpty() ? "" : " · " + item.optString("role"))); + } + return rows; + } + + List members(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/workspaces/" + encPath(workspaceId) + "/members", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "members"), Models.Member::new); + } + + Models.Invitation createMemberInvitation(String workspaceId, String email, String role) throws Exception { + JSONObject body = new JSONObject(); + body.put("email", email); + body.put("role", role == null || role.isEmpty() ? "member" : role); + JSONObject json = requestObject("POST", "/api/workspaces/" + encPath(workspaceId) + "/members", + query(null, "workspace_id", workspaceId), body); + return new Models.Invitation(unwrap(json, "invitation")); + } + + Models.Member updateMemberRole(String workspaceId, String memberId, String role) throws Exception { + JSONObject body = new JSONObject(); + body.put("role", role); + JSONObject json = requestObject("PATCH", "/api/workspaces/" + encPath(workspaceId) + "/members/" + encPath(memberId), + query(null, "workspace_id", workspaceId), body); + return new Models.Member(unwrap(json, "member")); + } + + void deleteMember(String workspaceId, String memberId) throws Exception { + requestObject("DELETE", "/api/workspaces/" + encPath(workspaceId) + "/members/" + encPath(memberId), + query(null, "workspace_id", workspaceId), null); + } + + List workspaceInvitations(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/workspaces/" + encPath(workspaceId) + "/invitations", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "invitations"), Models.Invitation::new); + } + + void revokeInvitation(String workspaceId, String invitationId) throws Exception { + requestObject("DELETE", "/api/workspaces/" + encPath(workspaceId) + "/invitations/" + encPath(invitationId), + query(null, "workspace_id", workspaceId), null); + } + + List myInvitations() throws Exception { + JSONObject json = requestObject("GET", "/api/invitations", null, null); + return parseArray(extractArray(json, "invitations"), Models.Invitation::new); + } + + Models.Invitation invitation(String invitationId) throws Exception { + return new Models.Invitation(requestObject("GET", "/api/invitations/" + encPath(invitationId), null, null)); + } + + Models.Member acceptInvitation(String invitationId) throws Exception { + JSONObject json = requestObject("POST", "/api/invitations/" + encPath(invitationId) + "/accept", null, null); + return new Models.Member(unwrap(json, "member")); + } + + void declineInvitation(String invitationId) throws Exception { + requestObject("POST", "/api/invitations/" + encPath(invitationId) + "/decline", null, null); + } + + List tokenSummaries() throws Exception { + JSONObject json = requestObject("GET", "/api/tokens", null, null); + JSONArrayLike array = new JSONArrayLike(extractArray(json, "tokens")); + List rows = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.getJSONObject(i); + rows.add(item.optString("name", Models.shortId(item.optString("id"))) + + "\n" + item.optString("token_prefix") + + (item.optString("last_used_at").isEmpty() ? "" : " · " + item.optString("last_used_at"))); + } + return rows; + } + + List personalAccessTokens() throws Exception { + JSONObject json = requestObject("GET", "/api/tokens", null, null); + return parseArray(extractArray(json, "tokens"), Models.PersonalAccessToken::new); + } + + Models.CreatedPersonalAccessToken createPersonalAccessToken(String name, Integer expiresInDays) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + if (expiresInDays == null) body.put("expires_in_days", JSONObject.NULL); + else body.put("expires_in_days", expiresInDays); + return new Models.CreatedPersonalAccessToken(unwrap(requestObject("POST", "/api/tokens", null, body), "token")); + } + + void revokePersonalAccessToken(String id) throws Exception { + requestObject("DELETE", "/api/tokens/" + encPath(id), null, null); + } + + List notificationSummaries(String workspaceId) throws Exception { + Models.NotificationPreferences preferences = notificationPreferences(workspaceId); + List rows = new ArrayList<>(); + for (Map.Entry entry : preferences.values.entrySet()) { + rows.add(entry.getKey() + "\n" + entry.getValue()); + } + return rows; + } + + Models.NotificationPreferences notificationPreferences(String workspaceId) throws Exception { + return new Models.NotificationPreferences(requestObject("GET", "/api/notification-preferences", + query(null, "workspace_id", workspaceId), null)); + } + + Models.NotificationPreferences updateNotificationPreferences(String workspaceId, Map preferences) throws Exception { + JSONObject values = new JSONObject(); + for (Map.Entry entry : preferences.entrySet()) { + values.put(entry.getKey(), entry.getValue()); + } + JSONObject body = new JSONObject(); + body.put("preferences", values); + return new Models.NotificationPreferences(requestObject("PUT", "/api/notification-preferences", + query(null, "workspace_id", workspaceId), body)); + } + + String workspaceSummary(String workspaceId) throws Exception { + JSONObject json = requestObject("GET", "/api/workspaces/" + encPath(workspaceId), + query(null, "workspace_id", workspaceId), null); + return json.optString("name") + "\n" + + json.optString("slug") + "\n" + + json.optString("description") + "\n" + + json.optString("context"); + } + + private static final class JSONArrayLike { + private final org.json.JSONArray array; + JSONArrayLike(org.json.JSONArray array) { this.array = array; } + int length() { return array.length(); } + JSONObject getJSONObject(int index) throws Exception { return array.getJSONObject(index); } + } + + Models.Agent createAgent(String workspaceId, String name, String description, String runtimeId) throws Exception { + return createAgent(workspaceId, name, description, + description == null || description.trim().isEmpty() ? "Help with workspace tasks." : description, + runtimeId, "private", 1, "", null); + } + + Models.Agent createAgent(String workspaceId, String name, String description, String instructions, String runtimeId, + String visibility, int maxConcurrentTasks, String model, String avatarUrl) throws Exception { + return createAgent(workspaceId, name, description, instructions, runtimeId, visibility, maxConcurrentTasks, + model, avatarUrl, null, null); + } + + Models.Agent createAgent(String workspaceId, String name, String description, String instructions, String runtimeId, + String visibility, int maxConcurrentTasks, String model, String avatarUrl, + Map customEnv, List customArgs) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("description", description == null ? "" : description); + body.put("instructions", instructions == null || instructions.trim().isEmpty() ? "Help with workspace tasks." : instructions); + body.put("runtime_id", runtimeId); + body.put("visibility", visibility == null || visibility.isEmpty() ? "private" : visibility); + body.put("max_concurrent_tasks", Math.max(1, maxConcurrentTasks)); + body.put("model", model == null ? "" : model); + if (avatarUrl == null || avatarUrl.trim().isEmpty()) body.put("avatar_url", JSONObject.NULL); + else body.put("avatar_url", avatarUrl.trim()); + putCustomAgentConfig(body, customEnv, customArgs); + return new Models.Agent(requestObject("POST", "/api/agents", query(null, "workspace_id", workspaceId), body)); + } + + Models.Agent updateAgent(String workspaceId, Models.Agent agent, String name, String description, String instructions, + String runtimeId, String visibility, int maxConcurrentTasks, String model, String avatarUrl) throws Exception { + return updateAgent(workspaceId, agent, name, description, instructions, runtimeId, visibility, maxConcurrentTasks, + model, avatarUrl, null, null); + } + + Models.Agent updateAgent(String workspaceId, Models.Agent agent, String name, String description, String instructions, + String runtimeId, String visibility, int maxConcurrentTasks, String model, String avatarUrl, + Map customEnv, List customArgs) throws Exception { + JSONObject body = new JSONObject(); + body.put("name", name); + body.put("description", description == null ? "" : description); + body.put("instructions", instructions == null || instructions.trim().isEmpty() ? "Help with workspace tasks." : instructions); + if (runtimeId == null || runtimeId.trim().isEmpty()) body.put("runtime_id", JSONObject.NULL); + else body.put("runtime_id", runtimeId); + body.put("visibility", visibility == null || visibility.isEmpty() ? "private" : visibility); + body.put("max_concurrent_tasks", Math.max(1, maxConcurrentTasks)); + body.put("model", model == null ? "" : model); + if (avatarUrl == null || avatarUrl.trim().isEmpty()) body.put("avatar_url", JSONObject.NULL); + else body.put("avatar_url", avatarUrl.trim()); + putCustomAgentConfig(body, customEnv, customArgs); + return new Models.Agent(requestObject("PUT", "/api/agents/" + encPath(agent.id), + query(null, "workspace_id", workspaceId), body)); + } + + private static void putCustomAgentConfig(JSONObject body, Map customEnv, List customArgs) throws Exception { + if (customEnv != null) { + JSONObject env = new JSONObject(); + for (Map.Entry entry : customEnv.entrySet()) { + if (entry.getKey() != null && !entry.getKey().trim().isEmpty()) { + env.put(entry.getKey().trim(), entry.getValue() == null ? "" : entry.getValue()); + } + } + body.put("custom_env", env); + } + if (customArgs != null) { + JSONArray args = new JSONArray(); + for (String arg : customArgs) { + if (arg != null && !arg.trim().isEmpty()) args.put(arg.trim()); + } + body.put("custom_args", args); + } + } + + List agentSkills(String workspaceId, String agentId) throws Exception { + JSONObject json = requestObject("GET", "/api/agents/" + encPath(agentId) + "/skills", + query(null, "workspace_id", workspaceId), null); + return parseArray(extractArray(json, "skills"), Models.Skill::new); + } + + void setAgentSkills(String workspaceId, String agentId, List skillIds) throws Exception { + JSONObject body = new JSONObject(); + JSONArray ids = new JSONArray(); + for (String id : skillIds) ids.put(id); + body.put("skill_ids", ids); + requestObject("PUT", "/api/agents/" + encPath(agentId) + "/skills", + query(null, "workspace_id", workspaceId), body); + } + + Models.Agent archiveAgent(String workspaceId, String agentId) throws Exception { + return new Models.Agent(requestObject("POST", "/api/agents/" + encPath(agentId) + "/archive", + query(null, "workspace_id", workspaceId), null)); + } + + Models.Agent restoreAgent(String workspaceId, String agentId) throws Exception { + return new Models.Agent(requestObject("POST", "/api/agents/" + encPath(agentId) + "/restore", + query(null, "workspace_id", workspaceId), null)); + } + + String uploadFile(String workspaceId, String filename, String contentType, byte[] data) throws Exception { + JSONObject json = requestMultipart("/api/upload-file", query(null, "workspace_id", workspaceId), + "file", filename, contentType, data); + return json.optString("url", json.optString("link", "")); + } + + int cancelAgentTasks(String workspaceId, String agentId) throws Exception { + JSONObject json = requestObject("POST", "/api/agents/" + encPath(agentId) + "/cancel-tasks", + query(null, "workspace_id", workspaceId), null); + return json.optInt("cancelled", json.optInt("count", 0)); + } + + private JSONObject requestMultipart(String path, JSONObject query, String fieldName, String filename, + String contentType, byte[] data) throws Exception { + return requestMultipart(path, query, null, fieldName, filename, contentType, data); + } + + private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename, + String contentType, byte[] data) throws Exception { + String boundary = "Boundary-" + System.currentTimeMillis(); + URL url = new URL(BASE + path + queryString(query)); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("X-Client-Platform", "android"); + conn.setRequestProperty("X-Client-Version", "debug"); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + String token = authStore.token(); + if (token != null && !token.isEmpty()) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setDoOutput(true); + try (OutputStream out = conn.getOutputStream()) { + if (fields != null) { + JSONArray names = fields.names(); + if (names != null) { + for (int i = 0; i < names.length(); i++) { + String key = names.getString(i); + Object value = fields.opt(key); + if (value == null || value == JSONObject.NULL) continue; + writeUtf8(out, "--" + boundary + "\r\n"); + writeUtf8(out, "Content-Disposition: form-data; name=\"" + key + "\"\r\n\r\n"); + writeUtf8(out, String.valueOf(value)); + writeUtf8(out, "\r\n"); + } + } + } + writeUtf8(out, "--" + boundary + "\r\n"); + writeUtf8(out, "Content-Disposition: form-data; name=\"" + fieldName + "\"; filename=\"" + filename + "\"\r\n"); + writeUtf8(out, "Content-Type: " + (contentType == null || contentType.isEmpty() ? "application/octet-stream" : contentType) + "\r\n\r\n"); + out.write(data); + writeUtf8(out, "\r\n--" + boundary + "--\r\n"); + } + + int code = conn.getResponseCode(); + saveResponseCookies(conn); + InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + String text = readAll(stream); + if (code < 200 || code >= 300) { + throw new ApiException(code, text); + } + if (text == null || text.trim().isEmpty()) return new JSONObject(); + return new JSONObject(text.trim()); + } + + private static void writeUtf8(OutputStream out, String value) throws Exception { + out.write(value.getBytes(StandardCharsets.UTF_8)); + } + + private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body) throws Exception { + return requestObject(method, path, query, body, null); + } + + private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body, Map extraHeaders) throws Exception { + return requestObject(method, path, query, body, extraHeaders, 15000, 30000); + } + + private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body, + Map extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception { + URL url = new URL(BASE + path + queryString(query)); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(connectTimeoutMs); + conn.setReadTimeout(readTimeoutMs); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("X-Client-Platform", "android"); + conn.setRequestProperty("X-Client-Version", "debug"); + String token = authStore.token(); + if (token != null && !token.isEmpty()) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + if (extraHeaders != null) { + for (Map.Entry entry : extraHeaders.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + conn.setRequestProperty(entry.getKey(), entry.getValue()); + } + } + } + if (body != null) { + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bytes.length); + try (OutputStream out = conn.getOutputStream()) { + out.write(bytes); + } + } + + int code = conn.getResponseCode(); + saveResponseCookies(conn); + InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + String text = readAll(stream); + if (code < 200 || code >= 300) { + throw new ApiException(code, text); + } + if (text == null || text.trim().isEmpty()) return new JSONObject(); + String trimmed = text.trim(); + if (trimmed.startsWith("[")) { + JSONObject wrapper = new JSONObject(); + wrapper.put("items", new JSONArray(trimmed)); + return wrapper; + } + return new JSONObject(trimmed); + } + + private void saveResponseCookies(HttpURLConnection conn) { + if (conn == null) return; + Map> headers = conn.getHeaderFields(); + List cookies = headers == null ? null : headers.get("Set-Cookie"); + if (cookies == null && headers != null) { + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase("Set-Cookie")) { + cookies = entry.getValue(); + break; + } + } + } + authStore.saveCloudFrontCookies(cookies); + } + + private static String readAll(InputStream stream) throws Exception { + if (stream == null) return ""; + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) sb.append(line); + } + return sb.toString(); + } + + private static String queryString(JSONObject query) throws Exception { + if (query == null || query.length() == 0) return ""; + StringBuilder sb = new StringBuilder("?"); + boolean first = true; + JSONArray names = query.names(); + if (names == null) return ""; + for (int i = 0; i < names.length(); i++) { + String key = names.getString(i); + Object value = query.opt(key); + if (value == null || value == JSONObject.NULL) continue; + if (!first) sb.append('&'); + first = false; + sb.append(URLEncoder.encode(key, "UTF-8")); + sb.append('='); + sb.append(URLEncoder.encode(String.valueOf(value), "UTF-8")); + } + return first ? "" : sb.toString(); + } + + private static JSONObject query(JSONObject seed, Object... pairs) throws Exception { + JSONObject obj = seed == null ? new JSONObject() : seed; + for (int i = 0; i + 1 < pairs.length; i += 2) { + String key = String.valueOf(pairs[i]); + Object value = pairs[i + 1]; + if ("workspace_id".equals(key) && isBlankQueryValue(value)) { + throw new IllegalArgumentException("workspace_id is required. Select a workspace first."); + } + obj.put(key, value); + } + return obj; + } + + private static boolean isBlankQueryValue(Object value) { + if (value == null || value == JSONObject.NULL) return true; + return String.valueOf(value).trim().isEmpty(); + } + + private static String encPath(String value) throws Exception { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); + } + + private static JSONArray extractArray(JSONObject json, String preferredKey) { + JSONArray direct = json.optJSONArray(preferredKey); + if (direct != null) return direct; + JSONArray items = json.optJSONArray("items"); + if (items != null) return items; + JSONArray data = json.optJSONArray("data"); + return data == null ? new JSONArray() : data; + } + + private static JSONObject unwrap(JSONObject json, String preferredKey) { + JSONObject nested = json.optJSONObject(preferredKey); + if (nested != null) return nested; + JSONObject data = json.optJSONObject("data"); + return data == null ? json : data; + } + + private static List parseArray(JSONArray array, Parser parser) throws Exception { + List list = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) list.add(parser.parse(array.getJSONObject(i))); + return list; + } + + private static Models.Page parsePage(JSONObject json, String key, Parser parser) throws Exception { + List list = parseArray(extractArray(json, key), parser); + boolean hasMore = json.optBoolean("has_more", json.optBoolean("hasMore", false)); + return new Models.Page<>(list, hasMore); + } + + static final class ApiException extends Exception { + final int code; + final String response; + + ApiException(int code, String response) { + super("HTTP " + code + ": " + response); + this.code = code; + this.response = response; + } + } +} diff --git a/app/src/main/java/ai/multica/app/AuthStore.java b/app/src/main/java/ai/multica/app/AuthStore.java new file mode 100644 index 0000000..0fb28f1 --- /dev/null +++ b/app/src/main/java/ai/multica/app/AuthStore.java @@ -0,0 +1,108 @@ +package ai.multica.app; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class AuthStore { + private static final String PREFS = "multica_auth"; + private static final String TOKEN = "token"; + private static final String WORKSPACE_ID = "workspace_id"; + private static final String LANGUAGE = "language"; + private static final String INBOX_CACHE_PREFIX = "inbox_cache_"; + private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header"; + + private final SharedPreferences prefs; + + AuthStore(Context context) { + prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + String token() { + return prefs.getString(TOKEN, null); + } + + void saveToken(String token) { + prefs.edit().putString(TOKEN, token).apply(); + } + + void clearToken() { + prefs.edit().remove(TOKEN).remove(WORKSPACE_ID).remove(CLOUDFRONT_COOKIE_HEADER).apply(); + } + + String workspaceId() { + return prefs.getString(WORKSPACE_ID, null); + } + + void saveWorkspaceId(String workspaceId) { + prefs.edit().putString(WORKSPACE_ID, workspaceId).apply(); + } + + boolean isChinese() { + return "zh".equals(prefs.getString(LANGUAGE, "en")); + } + + void setChinese(boolean enabled) { + prefs.edit().putString(LANGUAGE, enabled ? "zh" : "en").apply(); + } + + String inboxCache(String workspaceId) { + if (workspaceId == null || workspaceId.trim().isEmpty()) return null; + return prefs.getString(INBOX_CACHE_PREFIX + workspaceId, null); + } + + void saveInboxCache(String workspaceId, String json) { + if (workspaceId == null || workspaceId.trim().isEmpty() || json == null || json.trim().isEmpty()) return; + prefs.edit().putString(INBOX_CACHE_PREFIX + workspaceId, json).apply(); + } + + String cloudFrontCookieHeader() { + return prefs.getString(CLOUDFRONT_COOKIE_HEADER, ""); + } + + void saveCloudFrontCookies(List setCookieHeaders) { + if (setCookieHeaders == null || setCookieHeaders.isEmpty()) return; + Map cookies = parseCookieHeader(cloudFrontCookieHeader()); + boolean changed = false; + for (String header : setCookieHeaders) { + if (header == null) continue; + int semicolon = header.indexOf(';'); + String pair = (semicolon >= 0 ? header.substring(0, semicolon) : header).trim(); + int equals = pair.indexOf('='); + if (equals <= 0) continue; + String name = pair.substring(0, equals).trim(); + String value = pair.substring(equals + 1).trim(); + if (!name.startsWith("CloudFront-") || value.isEmpty()) continue; + cookies.put(name, value); + changed = true; + } + if (!changed) return; + prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply(); + } + + private static Map parseCookieHeader(String header) { + Map cookies = new LinkedHashMap<>(); + if (header == null || header.trim().isEmpty()) return cookies; + String[] parts = header.split(";"); + for (String part : parts) { + String pair = part == null ? "" : part.trim(); + int equals = pair.indexOf('='); + if (equals <= 0) continue; + cookies.put(pair.substring(0, equals).trim(), pair.substring(equals + 1).trim()); + } + return cookies; + } + + private static String joinCookieHeader(Map cookies) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : cookies.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) continue; + if (sb.length() > 0) sb.append("; "); + sb.append(entry.getKey()).append('=').append(entry.getValue()); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt new file mode 100644 index 0000000..ffbfc52 --- /dev/null +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -0,0 +1,26063 @@ +package ai.multica.app + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color as AndroidColor +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.OpenableColumns +import android.widget.LinearLayout +import androidx.activity.compose.BackHandler +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Label +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Sort +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ViewList +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick as semanticsOnClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import ai.multica.app.ui.components.MulticaButtonTone +import ai.multica.app.ui.components.MulticaCupertinoAlertDialog +import ai.multica.app.ui.components.MulticaCupertinoActionSheet +import ai.multica.app.ui.components.MulticaCupertinoActionSheetItem +import ai.multica.app.ui.components.MulticaCupertinoListRow +import ai.multica.app.ui.components.MulticaCupertinoPage +import ai.multica.app.ui.components.MulticaCupertinoSection +import ai.multica.app.ui.components.MulticaCupertinoSecretDialog +import ai.multica.app.ui.components.MulticaCupertinoSwitchRow +import ai.multica.app.ui.components.MulticaEmptyState +import ai.multica.app.ui.components.MulticaErrorState +import ai.multica.app.ui.components.MulticaHazeBottomBar +import ai.multica.app.ui.components.MulticaListRow +import ai.multica.app.ui.components.MulticaLoadingState +import ai.multica.app.ui.components.MulticaIconPillButton +import ai.multica.app.ui.components.MulticaPillButton +import ai.multica.app.ui.components.MulticaShell +import ai.multica.app.ui.components.MulticaSuccessState +import ai.multica.app.ui.components.MulticaTab +import ai.multica.app.ui.components.MulticaTextField +import ai.multica.app.ui.theme.MulticaColors +import ai.multica.app.ui.theme.LocalMulticaSpacing +import ai.multica.app.ui.theme.MulticaTheme +import coil.compose.AsyncImage +import coil.request.ImageRequest +import dev.chrisbanes.haze.HazeDefaults +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import io.github.alexzhirkevich.cupertino.CupertinoActivityIndicator +import io.github.alexzhirkevich.cupertino.CupertinoSwitch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.LinkedHashMap +import java.util.Locale +import java.util.TimeZone + +class ComposePilotActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + applyPilotSystemBars() + val authStore = AuthStore(this) + val api = ApiClient(authStore) + val initialSettingsPageName = intent.getStringExtra("initial_settings_page") + ?: getSharedPreferences("multica_auth", Context.MODE_PRIVATE).getString("initial_settings_page", null) + val initialIssueId = intent.getStringExtra("initial_issue_id") + val initialTabName = intent.data?.host + if (intent.getBooleanExtra("preview_agent_transcript", false)) { + setContent { + MulticaTheme { + PilotAgentTranscriptPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_projects_loading", false)) { + setContent { + MulticaTheme { + ProjectListLoadingSkeleton(zh = false) + } + } + return + } + if (intent.getBooleanExtra("preview_inline_result_banners", false)) { + setContent { + MulticaTheme { + PilotInlineResultBannersPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_system_loading", false)) { + setContent { + MulticaTheme { + MulticaLoadingState("Loading workspace") + } + } + return + } + if (intent.getBooleanExtra("preview_system_error", false)) { + setContent { + MulticaTheme { + MulticaErrorState( + message = "Session failed\nCheck your connection and try again.", + onRetry = {}, + fullScreen = true, + ) + } + } + return + } + if (intent.getBooleanExtra("preview_my_invitations", false)) { + setContent { + MulticaTheme { + PilotMyInvitationsPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_members_settings", false)) { + setContent { + MulticaTheme { + PilotMembersPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_notification_settings", false)) { + setContent { + MulticaTheme { + PilotNotificationSettingsPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_workspace_settings", false)) { + setContent { + MulticaTheme { + PilotWorkspaceSettingsPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_labels_settings", false)) { + setContent { + MulticaTheme { + PilotLabelsSettingsPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_skills_settings", false)) { + setContent { + MulticaTheme { + PilotSkillsSettingsPreview() + } + } + return + } + if (intent.getBooleanExtra("preview_feedback_settings", false)) { + val showInlineError = intent.getBooleanExtra("preview_feedback_error", false) + setContent { + MulticaTheme { + PilotFeedbackSettingsPreview(showInlineError = showInlineError) + } + } + return + } + if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) { + val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false) + val previewUser = Models.User(JSONObject() + .put("id", "preview-user") + .put("email", "preview@multica.ai") + .put("name", "Preview User") + .put("language", "en") + .put("starter_content_state", JSONObject.NULL) + .put("onboarding_questionnaire", JSONObject() + .put("team_size", "solo") + .put("role", "developer") + .put("use_case", "coding"))) + setContent { + MulticaTheme { + PilotNoWorkspaceOnboarding( + api = api, + authStore = authStore, + user = previewUser, + onWorkspaceCreated = {}, + previewCreationSuccess = previewCreationSuccess, + ) + } + } + return + } + setContent { + MulticaTheme { + ComposePilotApp( + authStore = authStore, + api = api, + initialSettingsPageName = initialSettingsPageName, + initialIssueId = initialIssueId, + initialTabName = initialTabName, + ) + } + } + } + + private fun applyPilotSystemBars() { + val darkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = AndroidColor.TRANSPARENT + window.navigationBarColor = AndroidColor.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isStatusBarContrastEnforced = false + window.isNavigationBarContrastEnforced = false + } + WindowInsetsControllerCompat(window, window.decorView).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } +} + +private data class PilotSession( + val user: Models.User, + val workspaces: List, + val workspace: Models.Workspace, +) + +private data class SettingsHomeStatusSummary( + val pendingInvitationCount: Int?, + val tokenCount: Int?, + val runtimeCount: Int?, + val runtimeUpdateHint: String?, +) + +private data class InboxIssueContext( + val itemId: String, + val issueId: String, + val commentId: String, + val nextIssueId: String?, +) + +private data class PilotIssueDetailData( + val issue: Models.Issue, + val comments: List, + val runs: List, + val members: List, + val agents: List, + val issueLabels: List, + val allLabels: List, + val subscribers: List, + val attachments: List, + val children: List, + val usage: Models.IssueUsage?, + val timeline: Models.TimelinePage?, + val activeTasks: List, +) + +private data class PilotIssueFormOptions( + val projects: List, + val members: List, + val agents: List, +) + +private data class PilotChatMessagesData( + val messages: List, + val pendingTask: Models.ChatPendingTask, +) + +private data class PilotChatSessionsData( + val sessions: List, + val agents: List, +) + +private data class PilotProjectDetailData( + val project: Models.Project, + val issues: List, + val resources: List, + val isPinned: Boolean, +) + +private data class PilotSearchData( + val issues: List, + val projects: List, + val agents: List, + val skills: List, +) + +private data class PilotSkillDetailData( + val skill: Models.Skill, + val files: List, +) + +private data class PilotAssigneeOption( + val id: String?, + val type: String?, + val name: String, +) + +private val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉") +private const val PILOT_INBOX_PAGE_SIZE = 20 + +private object MulticaVisualTokens { + val SectionTop = 24.dp + val SectionBottom = 10.dp + val SectionGap = 12.dp + val CardRadius = 18.dp + val RowRadius = 14.dp + val RowMinHeight = 56.dp + val AvatarMedium = 30.dp +} + +private object MulticaTypeScale { + val SectionTitle = 15.sp + val RowTitle = 15.sp + val FormRowTitle = 16.sp + val Body = 15.sp + val Meta = 11.sp + val DetailMeta = 13.sp + val Caption = 12.sp + val AvatarInitials = 12.sp +} + +private data class PilotReactionGroup( + val emoji: String, + val count: Int, + val reacted: Boolean, + val actors: List, +) + +private data class PilotAgentSettingsData( + val agents: List, + val runtimes: List, + val skills: List, + val activity: List, + val runCounts: List, + val taskSnapshot: List, +) + +private data class PilotAutopilotSettingsData( + val autopilots: List, + val agents: List, + val triggers: List, +) + +private data class PilotMembersSettingsData( + val members: List, + val invitations: List, +) + +private data class PilotMyInvitationsSettingsData( + val invitations: List, +) + +private data class PilotWorkspaceSettingsData( + val current: Models.Workspace, + val workspaces: List, + val members: List, +) + +private data class PilotRuntimeDetailData( + val usage: List, + val activity: List, + val usageByAgent: List, + val usageByHour: List, + val members: List, + val agents: List, + val loadErrors: List = emptyList(), +) + +private data class PilotAutopilotDetailData( + val triggers: List, + val runs: List, +) + +private enum class PilotSettingsPage { + WorkspaceDetails, + MyInvitations, + Members, + Notifications, + ApiTokens, + Agents, + Autopilots, + Runtimes, + Labels, + Skills, + Feedback, +} + +private fun pilotSettingsPageFromName(value: String?): PilotSettingsPage? { + return when (value?.lowercase()?.replace("-", "_")) { + "workspace_details", "workspacedetails" -> PilotSettingsPage.WorkspaceDetails + "my_invitations", "myinvitations" -> PilotSettingsPage.MyInvitations + "members" -> PilotSettingsPage.Members + "notifications" -> PilotSettingsPage.Notifications + "api_tokens", "apitokens" -> PilotSettingsPage.ApiTokens + "agents" -> PilotSettingsPage.Agents + "autopilots" -> PilotSettingsPage.Autopilots + "runtimes" -> PilotSettingsPage.Runtimes + "labels" -> PilotSettingsPage.Labels + "skills" -> PilotSettingsPage.Skills + "feedback" -> PilotSettingsPage.Feedback + else -> null + } +} + +private sealed interface PilotState { + data object Loading : PilotState + data object SignedOut : PilotState + data class Error(val message: String) : PilotState + data class NoWorkspace(val user: Models.User) : PilotState + data class Ready(val session: PilotSession) : PilotState +} + +private sealed interface TabState { + data object Loading : TabState + data class Error(val message: String) : TabState + data class Inbox(val items: List, val loading: Boolean = false, val hasMore: Boolean = false) : TabState + data class Issues(val issues: List, val personal: Boolean, val loading: Boolean = false) : TabState + data class Projects(val projects: List) : TabState + data object Settings : TabState +} + +@Composable +private fun ComposePilotApp( + authStore: AuthStore, + api: ApiClient, + initialSettingsPageName: String? = null, + initialIssueId: String? = null, + initialTabName: String? = null, +) { + var appState by remember { mutableStateOf(PilotState.Loading) } + var refresh by remember { mutableIntStateOf(0) } + + LaunchedEffect(refresh) { + appState = PilotState.Loading + if (authStore.token().isNullOrBlank()) { + appState = PilotState.SignedOut + return@LaunchedEffect + } + appState = withContext(Dispatchers.IO) { + runCatching { + val (user, workspaces) = coroutineScope { + val userDeferred = async { api.me() } + val workspacesDeferred = async { api.workspaces() } + userDeferred.await() to workspacesDeferred.await() + } + val preferred = authStore.workspaceId() + val workspace = WorkspaceSelection.chooseWorkspace(workspaces, preferred) + if (workspace == null) { + PilotState.NoWorkspace(user) + } else { + authStore.saveWorkspaceId(workspace.id) + PilotState.Ready(PilotSession(user, workspaces, workspace)) + } + }.getOrElse { + if ((it is ApiClient.ApiException && it.code == 401) || it.message.orEmpty().contains("HTTP 401")) { + authStore.clearToken() + PilotState.SignedOut + } else { + PilotState.Error(it.message ?: it.toString()) + } + } + } + } + + when (val state = appState) { + PilotState.Loading -> MulticaLoadingState("Loading Multica...") + PilotState.SignedOut -> PilotLoginScreen( + api = api, + authStore = authStore, + onAuthenticated = { refresh++ }, + ) + is PilotState.Error -> MulticaErrorState( + message = "Session failed\n${state.message}", + onRetry = { refresh++ }, + fullScreen = true, + ) + is PilotState.NoWorkspace -> PilotNoWorkspaceOnboarding( + api = api, + authStore = authStore, + user = state.user, + onWorkspaceCreated = { refresh++ }, + ) + is PilotState.Ready -> ComposePilotShell( + session = state.session, + api = api, + authStore = authStore, + initialSettingsPageName = initialSettingsPageName, + initialIssueId = initialIssueId, + initialTabName = initialTabName, + onReloadSession = { refresh++ }, + onSessionUpdated = { appState = PilotState.Ready(it) }, + ) + } +} + +@Composable +private fun PilotLoginScreen( + api: ApiClient, + authStore: AuthStore, + onAuthenticated: () -> Unit, +) { + val zh = authStore.isChinese() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + var email by remember { mutableStateOf("") } + var code by remember { mutableStateOf("") } + var codeSentTo by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + fun sendCode() { + val cleanEmail = email.trim() + if (cleanEmail.isEmpty() || loading) { + message = if (zh) "请输入邮箱" else "Enter your email." + return + } + loading = true + message = null + scope.launch { + withContext(Dispatchers.IO) { runCatching { api.sendCode(cleanEmail) } } + .onSuccess { + codeSentTo = cleanEmail + code = "" + message = if (zh) "验证码已发送,请检查邮箱。" else "Code sent. Check your email." + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + .onFailure { + message = "${if (zh) "验证码发送失败" else "Failed to send code"}: ${it.message ?: it.toString()}" + } + loading = false + } + } + + fun verifyCode() { + val cleanEmail = codeSentTo ?: email.trim() + val cleanCode = code.trim() + if (cleanEmail.isEmpty() || cleanCode.length < 4 || loading) { + message = if (zh) "请输入邮箱验证码" else "Enter the email code." + return + } + loading = true + message = null + scope.launch { + withContext(Dispatchers.IO) { runCatching { api.verifyCode(cleanEmail, cleanCode) } } + .onSuccess { token -> + if (token.isBlank()) { + message = if (zh) "登录失败:服务端未返回 token。" else "Sign in failed: no token returned." + } else { + authStore.saveToken(token) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onAuthenticated() + } + } + .onFailure { + message = "${if (zh) "登录失败" else "Sign in failed"}: ${it.message ?: it.toString()}" + } + loading = false + } + } + + Surface(color = MulticaColors.Background) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding() + .semantics { contentDescription = "Multica Login Screen Email Code" }, + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 78.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MulticaColors.Accent.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "M", + style = MaterialTheme.typography.titleLarge.copy(fontSize = 28.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold), + color = MulticaColors.Accent, + ) + } + Text( + text = if (zh) "登录 Multica" else "Sign in to Multica", + style = MaterialTheme.typography.titleLarge.copy(fontSize = 30.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold), + color = MulticaColors.Text, + textAlign = TextAlign.Center, + ) + Text( + text = if (zh) "输入邮箱获取验证码,和 Web/桌面端使用同一套官方登录服务。" else "Use your email code. This is the same official sign-in service as web and desktop.", + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, lineHeight = 21.sp), + color = MulticaColors.Muted, + textAlign = TextAlign.Center, + ) + } + } + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Multica Login Email Code Card" }, + shape = RoundedCornerShape(20.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.75.dp, MulticaColors.Border.copy(alpha = 0.62f)), + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + MulticaTextField( + value = email, + onValueChange = { + email = it + if (codeSentTo != null && it.trim() != codeSentTo) codeSentTo = null + }, + label = if (zh) "邮箱" else "Email", + contentDescription = "Login Email Field", + enabled = !loading, + singleLine = true, + ) + if (codeSentTo != null) { + MulticaTextField( + value = code, + onValueChange = { code = it }, + label = if (zh) "验证码" else "Code", + contentDescription = "Login Code Field", + enabled = !loading, + singleLine = true, + ) + } + MulticaPillButton( + text = when { + loading -> "..." + codeSentTo == null -> if (zh) "发送验证码" else "Send Code" + else -> if (zh) "登录" else "Sign In" + }, + onClick = { if (codeSentTo == null) sendCode() else verifyCode() }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Primary, + enabled = !loading, + contentDescription = if (codeSentTo == null) "Login Send Code" else "Login Verify Code", + ) + if (codeSentTo != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = codeSentTo.orEmpty(), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + MulticaPillButton( + text = if (zh) "重发" else "Resend", + onClick = { sendCode() }, + tone = MulticaButtonTone.Ghost, + enabled = !loading, + contentDescription = "Login Resend Code", + ) + } + } + } + } + } + if (!message.isNullOrBlank()) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Login Result Message" }, + shape = RoundedCornerShape(14.dp), + color = if (message!!.contains("失败") || message!!.contains("failed", ignoreCase = true)) { + MulticaColors.Danger.copy(alpha = 0.10f) + } else { + MulticaColors.AccentSoft.copy(alpha = 0.62f) + }, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = message.orEmpty(), + modifier = Modifier.padding(14.dp), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 18.sp), + color = if (message!!.contains("失败") || message!!.contains("failed", ignoreCase = true)) MulticaColors.Danger else MulticaColors.Text, + ) + } + } + } + item { + MulticaPillButton( + text = if (zh) "用浏览器打开 Web 登录" else "Open Web Sign In", + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://app.multica.ai/login"))) + }, + tone = MulticaButtonTone.Ghost, + contentDescription = "Login Open Web Sign In", + ) + } + } + } +} + +@Composable +private fun PilotNoWorkspaceOnboarding( + api: ApiClient, + authStore: AuthStore, + user: Models.User, + onWorkspaceCreated: () -> Unit, + previewCreationSuccess: Boolean = false, +) { + val zh = authStore.isChinese() + val contextHandle = LocalContext.current + val haptic = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + var name by remember { mutableStateOf("") } + var slug by remember { mutableStateOf("") } + var slugTouched by remember { mutableStateOf(false) } + var teamSize by remember { mutableStateOf(user.onboardingQuestionnaire.optString("team_size", "")) } + var role by remember { mutableStateOf(user.onboardingQuestionnaire.optString("role", "")) } + var useCase by remember { mutableStateOf(user.onboardingQuestionnaire.optString("use_case", "")) } + var notes by remember { mutableStateOf(user.onboardingQuestionnaire.optString("notes", "")) } + var cloudEmail by remember { mutableStateOf(user.email) } + var cloudReason by remember { mutableStateOf("") } + var starterChoice by remember { mutableStateOf("import") } + var saving by remember { mutableStateOf(false) } + var onboardingSaving by remember { mutableStateOf(false) } + var cloudSaving by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + var creationSuccess by remember { mutableStateOf(previewCreationSuccess) } + val slugFieldError = noWorkspaceSlugValidationError(slug, zh) + + fun saveQuestionnaire() { + if (onboardingSaving) return + onboardingSaving = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + api.patchOnboarding(OnboardingPayload.questionnaire(teamSize, role, useCase, notes)) + } + } + onboardingSaving = false + result.onSuccess { + message = if (zh) "入门问卷已保存" else "Onboarding answers saved" + }.onFailure { + message = "${if (zh) "入门问卷保存失败" else "Onboarding save failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun joinWaitlist() { + val email = cloudEmail.trim() + if (cloudSaving || email.isBlank()) return + cloudSaving = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.joinCloudWaitlist(email, cloudReason) } + } + cloudSaving = false + result.onSuccess { + message = if (zh) "已加入云端 Runtime 等候列表" else "Joined the cloud runtime waitlist" + }.onFailure { + message = "${if (zh) "加入等候列表失败" else "Cloud waitlist failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun createWorkspace() { + val trimmedName = name.trim() + val trimmedSlug = slug.trim() + if (saving || trimmedName.isBlank() || trimmedSlug.isBlank()) return + if (slugFieldError != null) { + slugTouched = true + return + } + saving = true + message = null + creationSuccess = false + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.createWorkspace(trimmedName, trimmedSlug, "", "") } + } + saving = false + result.onSuccess { workspace -> + withContext(Dispatchers.IO) { + runCatching { + val questionnaire = OnboardingPayload.questionnaire(teamSize, role, useCase, notes) + if (questionnaire.length() > 0) api.patchOnboarding(questionnaire) + } + val completionPath = when (starterChoice) { + "import" -> { + runCatching { + api.importStarterContent(OnboardingPayload.starterContentPayload(workspace.id, true)) + } + "starter_content_imported" + } + "dismiss" -> { + runCatching { api.dismissStarterContent(workspace.id) } + "starter_content_dismissed" + } + else -> "runtime_skipped" + } + runCatching { api.markOnboardingComplete(completionPath) } + } + creationSuccess = true + message = if (zh) "工作区已创建,正在进入 Multica..." else "Workspace created. Opening Multica..." + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + delay(450) + authStore.saveWorkspaceId(workspace.id) + onWorkspaceCreated() + }.onFailure { + creationSuccess = false + message = "${if (zh) "工作区创建失败" else "Workspace create failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun logOut() { + authStore.clearToken() + contextHandle.startActivity( + Intent(contextHandle, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ) + } + + Surface(color = MulticaColors.Background) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(20.dp) + .semantics { contentDescription = "No Workspace Global State" }, + contentPadding = PaddingValues(vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = "No Workspace Web Hero" }, + shape = RoundedCornerShape(18.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Text( + text = "Multica", + style = MaterialTheme.typography.titleLarge.copy(fontSize = 28.sp, lineHeight = 33.sp), + color = MulticaColors.Text, + fontWeight = FontWeight.Bold, + ) + Text( + text = if (zh) { + "欢迎,${user.name.ifBlank { user.email }}。创建第一个工作区即可进入 Multica。" + } else { + "Welcome, ${user.name.ifBlank { user.email }}. Create your first workspace to enter Multica." + }, + style = MaterialTheme.typography.bodyMedium, + color = MulticaColors.Muted, + ) + } + } + } + item { + NoWorkspaceStepGroupPanel(contentDescription = "No Workspace Create Workspace Web Panel") { + OnboardingStepHeader( + title = if (zh) "创建第一个工作区" else "Create your first workspace", + subtitle = if (zh) { + "名称和 slug 对齐 Web 创建流程;创建成功后自动进入主界面。" + } else { + "Name and slug match the web creation flow. You will enter the app after creation." + }, + contentDescription = "No Workspace Web Step Group Create Workspace", + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .semantics { contentDescription = "No Workspace Create Workspace Fields" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = name, + onValueChange = { + name = it + if (!slugTouched) slug = workspaceSlugFromName(it) + }, + label = if (zh) "工作区名称" else "Workspace name", + singleLine = true, + ) + MulticaTextField( + value = slug, + onValueChange = { + slugTouched = true + slug = it + }, + label = if (zh) "工作区 slug" else "Workspace slug", + singleLine = true, + ) + if (slugTouched && slugFieldError != null) { + Text( + text = slugFieldError, + modifier = Modifier.semantics { contentDescription = "No Workspace Slug Field Error" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Danger, + ) + } + MulticaPillButton( + text = if (saving) "..." else if (zh) "创建工作区" else "Create Workspace", + onClick = { createWorkspace() }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Primary, + contentDescription = "No Workspace Primary Create Action", + ) + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "No Workspace Bottom Account Actions" }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (zh) "当前账号 ${user.email}" else "Signed in as ${user.email}", + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "No Workspace Signed In Account" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + MulticaPillButton( + text = if (zh) "退出" else "Log out", + onClick = { logOut() }, + tone = MulticaButtonTone.Ghost, + contentDescription = "No Workspace Account Escape", + ) + } + if (creationSuccess) { + MulticaSuccessState( + if (zh) "工作区已创建,正在进入 Multica..." else "Workspace created. Opening Multica..." + ) + } else { + message?.let { + PilotInlineResultState( + message = it, + isError = pilotInlineResultIsError(it), + contentDescription = "No Workspace Result Web Banner", + ) + } + } + } + } + } + item { + NoWorkspaceStepGroupPanel(contentDescription = "No Workspace Questionnaire Web Panel") { + OnboardingStepHeader( + title = if (zh) "入门问卷" else "Onboarding questions", + subtitle = if (zh) { + "用于生成更贴合你的 starter content,也对齐 web 的首次使用流程。" + } else { + "Used to personalize starter content and match the web first-run flow." + }, + contentDescription = "No Workspace Web Step Group Questionnaire", + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .semantics { contentDescription = "No Workspace Questionnaire Fields" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = teamSize, + onValueChange = { teamSize = it }, + label = if (zh) "团队规模" else "Team size", + singleLine = true, + ) + MulticaTextField( + value = role, + onValueChange = { role = it }, + label = if (zh) "你的角色" else "Your role", + singleLine = true, + ) + MulticaTextField( + value = useCase, + onValueChange = { useCase = it }, + label = if (zh) "使用场景" else "Use case", + singleLine = true, + ) + MulticaTextField( + value = notes, + onValueChange = { notes = it }, + label = if (zh) "补充说明" else "Notes", + minLines = 2, + ) + MulticaPillButton( + text = if (onboardingSaving) "..." else if (zh) "保存入门问卷" else "Save Onboarding Answers", + onClick = { saveQuestionnaire() }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Secondary, + ) + } + } + } + item { + NoWorkspaceStepGroupPanel(contentDescription = "No Workspace Starter Content Web Panel") { + OnboardingStepHeader( + title = if (zh) "Starter content" else "Starter content", + subtitle = if (zh) { + "创建工作区后,可以自动导入入门项目,或明确跳过。" + } else { + "After workspace creation, import a starter project or explicitly skip it." + }, + contentDescription = "No Workspace Web Step Group Starter Content", + ) + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MulticaPillButton( + text = if (zh) "导入" else "Import", + onClick = { starterChoice = "import" }, + modifier = Modifier.weight(1f), + tone = if (starterChoice == "import") MulticaButtonTone.Secondary else MulticaButtonTone.Ghost, + contentDescription = "Starter Content Import", + ) + MulticaPillButton( + text = if (zh) "不导入" else "Dismiss", + onClick = { starterChoice = "dismiss" }, + modifier = Modifier.weight(1f), + tone = if (starterChoice == "dismiss") MulticaButtonTone.Secondary else MulticaButtonTone.Ghost, + contentDescription = "Starter Content Dismiss", + ) + MulticaPillButton( + text = if (zh) "稍后" else "Later", + onClick = { starterChoice = "skip" }, + modifier = Modifier.weight(1f), + tone = if (starterChoice == "skip") MulticaButtonTone.Secondary else MulticaButtonTone.Ghost, + contentDescription = "Starter Content Later", + ) + } + Text( + text = if (zh) { + "导入会创建 Getting Started 项目和入门 issues;不导入会调用 dismiss,稍后只完成基础 onboarding。" + } else { + "Import creates a Getting Started project and starter issues; Dismiss records the decision; Later completes only the base onboarding path." + }, + style = MaterialTheme.typography.bodySmall, + color = MulticaColors.Muted, + ) + } + } + } + item { + NoWorkspaceStepGroupPanel(contentDescription = "No Workspace Runtime Setup Web Panel") { + OnboardingStepHeader( + title = if (zh) "Runtime 与 Agent 设置" else "Runtime and agent setup", + subtitle = if (zh) { + "移动端不会自动创建或删除 runtime。创建 workspace 后,可按这三步完成 web onboarding 的后续配置。" + } else { + "Mobile will not automatically create or delete runtimes. After workspace creation, use these three steps to finish the rest of web onboarding." + }, + contentDescription = "No Workspace Web Step Group Runtime Setup", + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .semantics(mergeDescendants = false) { contentDescription = "No Workspace Runtime Setup Rows" }, + ) { + OnboardingInfoRow( + title = if (zh) "稍后连接 Runtime" else "Connect Runtime later", + subtitle = if (zh) { + "进入 Settings -> Runtimes 查看 connect remote 命令,或加入云端 Runtime 等候列表。" + } else { + "Open Settings -> Runtimes for the connect remote command, or join the cloud runtime waitlist." + }, + icon = Icons.Outlined.Dashboard, + showDivider = true, + ) + OnboardingInfoRow( + title = if (zh) "稍后创建 Agent" else "Create Agent later", + subtitle = if (zh) { + "进入 Settings -> Agents 创建 agent,绑定 runtime、skills、model、env 和 custom args。" + } else { + "Open Settings -> Agents to create an agent and configure runtime, skills, model, env, and custom args." + }, + icon = Icons.Outlined.Bolt, + showDivider = true, + ) + OnboardingInfoRow( + title = if (zh) "第一个 Issue" else "First Issue", + subtitle = if (zh) { + "导入 starter content 会创建入门 issues;也可以进入主界面后用 New Issue 自己创建。" + } else { + "Importing starter content creates starter issues, or you can create one with New Issue after entering the app." + }, + icon = Icons.Outlined.Add, + ) + } + } + } + item { + NoWorkspaceStepGroupPanel(contentDescription = "No Workspace Cloud Waitlist Web Panel") { + OnboardingStepHeader( + title = if (zh) "云端 Runtime 等候列表" else "Cloud runtime waitlist", + subtitle = if (zh) { + "如果暂时没有自己的 runtime,可以在移动端加入等候列表。" + } else { + "If you do not have your own runtime yet, join the mobile waitlist." + }, + contentDescription = "No Workspace Web Step Group Cloud Waitlist", + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .semantics { contentDescription = "No Workspace Cloud Waitlist Fields" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = cloudEmail, + onValueChange = { cloudEmail = it }, + label = if (zh) "邮箱" else "Email", + singleLine = true, + ) + MulticaTextField( + value = cloudReason, + onValueChange = { cloudReason = it }, + label = if (zh) "原因" else "Reason", + minLines = 2, + ) + MulticaPillButton( + text = if (cloudSaving) "..." else if (zh) "加入等候列表" else "Join Cloud Waitlist", + onClick = { joinWaitlist() }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Secondary, + ) + } + } + } + item { + Text( + text = if (zh) "受邀加入已有工作区时,请使用相同账号登录。" else "Invited to an existing workspace? Sign in with that same account.", + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "No Workspace Invite Hint" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun NoWorkspaceStepGroupPanel( + contentDescription: String, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(14.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.66f), shape) + .padding(vertical = 2.dp), + content = content, + ) + } +} + +@Composable +private fun OnboardingStepHeader( + title: String, + subtitle: String, + contentDescription: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp) + .semantics(mergeDescendants = true) { + this.contentDescription = "No Workspace Web Step Group $contentDescription" + }, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } +} + +@Composable +private fun OnboardingInfoRow( + title: String, + subtitle: String, + icon: ImageVector, + showDivider: Boolean = false, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(28.dp), + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Border.copy(alpha = 0.38f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(15.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +private fun workspaceSlugFromName(value: String): String { + return value.trim() + .lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .take(48) +} + +private fun noWorkspaceSlugValidationError(slug: String, zh: Boolean): String? { + val trimmed = slug.trim() + if (trimmed.isBlank()) return null + if (!Regex("^[a-z0-9]+(?:-[a-z0-9]+)*$").matches(trimmed)) { + return if (zh) { + "只能使用小写字母、数字和单个连字符,且不能以连字符开头或结尾。" + } else { + "Use lowercase letters, numbers, and single hyphens; do not start or end with a hyphen." + } + } + val reserved = setOf("api", "app", "auth", "login", "new", "settings", "signup", "www", "workspaces") + if (trimmed in reserved) { + return if (zh) "这个 slug 被系统保留,请换一个。" else "This workspace URL is reserved. Choose another slug." + } + return null +} + +@Composable +private fun ComposePilotShell( + session: PilotSession, + api: ApiClient, + authStore: AuthStore, + initialSettingsPageName: String? = null, + initialIssueId: String? = null, + initialTabName: String? = null, + onReloadSession: () -> Unit, + onSessionUpdated: (PilotSession) -> Unit, +) { + val initialSettingsPage = remember(initialSettingsPageName) { pilotSettingsPageFromName(initialSettingsPageName) } + val initialTab = remember(initialSettingsPage, initialTabName) { + when { + initialSettingsPage != null -> MulticaTab.Settings + initialTabName.equals("issues", ignoreCase = true) -> MulticaTab.Issues + initialTabName.equals("inbox", ignoreCase = true) -> MulticaTab.Inbox + initialTabName.equals("projects", ignoreCase = true) -> MulticaTab.Projects + else -> MulticaTab.Inbox + } + } + var selectedTab by remember(session.workspace.id, initialTab) { + mutableStateOf(initialTab) + } + var zh by remember { mutableStateOf(authStore.isChinese()) } + var tabState by remember { mutableStateOf(TabState.Loading) } + var issueDetailId by remember(session.workspace.id, initialIssueId) { + mutableStateOf(initialIssueId?.takeIf { it.isNotBlank() }) + } + var projectDetail by remember { mutableStateOf(null) } + var issueFormOpen by remember { mutableStateOf(false) } + var issueFormIssue by remember { mutableStateOf(null) } + var issueFormParentId by remember { mutableStateOf(null) } + var issueFormParentIdentifier by remember { mutableStateOf(null) } + var searchOpen by remember { mutableStateOf(initialTabName.equals("search", ignoreCase = true)) } + var chatOpen by remember { mutableStateOf(false) } + var chatSession by remember { mutableStateOf(null) } + var settingsPage by remember(session.workspace.id, initialSettingsPage) { mutableStateOf(initialSettingsPage) } + var profileOpen by remember(session.user.id) { mutableStateOf(false) } + var selectedIssueIds by remember(session.workspace.id) { mutableStateOf(setOf()) } + var inboxIssueContext by remember(session.workspace.id) { mutableStateOf(null) } + var retainedSearchQuery by remember(session.workspace.id) { mutableStateOf("") } + var returnToSearchAfterDetail by remember(session.workspace.id) { mutableStateOf(false) } + var tabRefresh by remember { mutableIntStateOf(0) } + val shellScope = rememberCoroutineScope() + + fun markInboxReadOnOpen(item: Models.InboxItem) { + if (item.read || item.id.isBlank()) return + shellScope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.markInboxRead(session.workspace.id, item.id) } + } + result.onSuccess { tabRefresh++ } + } + } + + LaunchedEffect(selectedTab, session.workspace.id, tabRefresh) { + if (issueDetailId != null || projectDetail != null || searchOpen || chatOpen || settingsPage != null || profileOpen) return@LaunchedEffect + val currentTabState = tabState + tabState = when (selectedTab) { + MulticaTab.Inbox -> { + val cached = withContext(Dispatchers.IO) { + runCatching { api.cachedInbox(session.workspace.id) }.getOrNull() + } + if (cached != null && cached.items.isNotEmpty()) { + TabState.Inbox(cached.items, loading = false, hasMore = cached.hasMore) + } else { + TabState.Inbox(emptyList(), loading = true) + } + } + MulticaTab.Issues -> TabState.Issues(emptyList(), personal = false, loading = true) + MulticaTab.MyIssues -> TabState.Issues(emptyList(), personal = true, loading = true) + MulticaTab.Projects -> if (currentTabState is TabState.Projects) currentTabState else TabState.Loading + MulticaTab.Settings -> TabState.Settings + } + tabState = withContext(Dispatchers.IO) { + runCatching { + when (selectedTab) { + MulticaTab.Inbox -> { + val page = api.inbox(session.workspace.id, PILOT_INBOX_PAGE_SIZE, 0) + TabState.Inbox(page.items, hasMore = page.hasMore) + } + MulticaTab.Issues -> TabState.Issues( + IssueBuckets.loadAllConcurrent( + { status, limit, offset -> api.issues(session.workspace.id, limit, offset, null, status) }, + 100, + ), + personal = false, + ) + MulticaTab.MyIssues -> TabState.Issues( + IssueBuckets.loadAllConcurrent( + { status, limit, offset -> api.issues(session.workspace.id, limit, offset, session.user.id, status) }, + 100, + ), + personal = true, + ) + MulticaTab.Projects -> TabState.Projects(api.projects(session.workspace.id, 100, 0).items) + MulticaTab.Settings -> TabState.Settings + } + }.getOrElse { TabState.Error(it.message ?: it.toString()) } + } + } + + val secondaryScreenOpen = issueFormOpen || + profileOpen || + issueDetailId != null || + projectDetail != null || + searchOpen || + chatOpen || + chatSession != null || + settingsPage != null + + BackHandler(enabled = secondaryScreenOpen) { + when { + issueFormOpen -> { + issueFormOpen = false + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + } + searchOpen -> searchOpen = false + profileOpen -> profileOpen = false + settingsPage != null -> settingsPage = null + chatSession != null -> chatSession = null + chatOpen -> chatOpen = false + issueDetailId != null -> { + if (inboxIssueContext != null) tabRefresh++ + issueDetailId = null + inboxIssueContext = null + if (returnToSearchAfterDetail) { + searchOpen = true + returnToSearchAfterDetail = false + } + } + projectDetail != null -> { + projectDetail = null + if (returnToSearchAfterDetail) { + searchOpen = true + returnToSearchAfterDetail = false + } + } + } + } + + MulticaShell( + selectedTab = selectedTab, + workspaceName = session.workspace.name, + languageLabel = if (zh) "EN" else "中文", + zh = zh, + onLanguageClick = { + zh = !zh + authStore.setChinese(zh) + }, + onWorkspaceClick = { + selectedTab = MulticaTab.Settings + settingsPage = PilotSettingsPage.WorkspaceDetails + issueDetailId = null + inboxIssueContext = null + returnToSearchAfterDetail = false + projectDetail = null + searchOpen = false + chatOpen = false + chatSession = null + profileOpen = false + issueFormOpen = false + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + }, + onSearchClick = { + searchOpen = true + issueDetailId = null + inboxIssueContext = null + returnToSearchAfterDetail = false + projectDetail = null + chatOpen = false + chatSession = null + settingsPage = null + profileOpen = false + issueFormOpen = false + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + }, + onChatClick = { + selectedTab = MulticaTab.Inbox + issueDetailId = null + inboxIssueContext = null + returnToSearchAfterDetail = false + projectDetail = null + searchOpen = false + chatOpen = true + chatSession = null + settingsPage = null + profileOpen = false + issueFormOpen = false + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + }, + onTabClick = { + selectedTab = it + issueDetailId = null + inboxIssueContext = null + returnToSearchAfterDetail = false + projectDetail = null + searchOpen = false + chatOpen = false + chatSession = null + settingsPage = null + profileOpen = false + if (it != MulticaTab.Issues && it != MulticaTab.MyIssues) selectedIssueIds = emptySet() + }, + showChrome = !secondaryScreenOpen, + ) { + val detailId = issueDetailId + val activeInboxContext = inboxIssueContext?.takeIf { it.issueId == detailId } + val activeProject = projectDetail + val activeChat = chatSession + val activeSettingsPage = settingsPage + if (issueFormOpen) { + PilotIssueForm( + api = api, + session = session, + issue = issueFormIssue, + parentIssueId = issueFormParentId, + parentIssueIdentifier = issueFormParentIdentifier, + zh = zh, + onCancel = { + issueFormOpen = false + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + }, + onSaved = { saved -> + issueFormOpen = false + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + projectDetail = null + chatOpen = false + chatSession = null + issueDetailId = saved.id + tabRefresh++ + }, + ) + } else if (searchOpen) { + PilotSearchPage( + api = api, + workspaceId = session.workspace.id, + zh = zh, + query = retainedSearchQuery, + onQueryChange = { retainedSearchQuery = it }, + onBack = { searchOpen = false }, + onIssueClick = { + searchOpen = false + issueDetailId = it + inboxIssueContext = null + returnToSearchAfterDetail = true + }, + onProjectClick = { + searchOpen = false + projectDetail = it + returnToSearchAfterDetail = true + }, + onTabClick = { + selectedTab = it + searchOpen = false + returnToSearchAfterDetail = false + }, + onSettingsPageClick = { + selectedTab = MulticaTab.Settings + settingsPage = it + searchOpen = false + returnToSearchAfterDetail = false + }, + ) + } else if (activeSettingsPage != null) { + PilotSettingsSubpage( + page = activeSettingsPage, + api = api, + workspaceId = session.workspace.id, + currentUserId = session.user.id, + zh = zh, + onReloadSession = onReloadSession, + onSwitchWorkspace = { nextWorkspaceId -> + authStore.saveWorkspaceId(nextWorkspaceId) + onReloadSession() + }, + onBack = { settingsPage = null }, + ) + } else if (chatOpen && activeChat == null) { + PilotChatSessions( + api = api, + workspaceId = session.workspace.id, + zh = zh, + onBack = { chatOpen = false }, + onSessionClick = { chatSession = it }, + ) + } else if (activeChat != null) { + PilotChatMessages( + api = api, + workspaceId = session.workspace.id, + session = activeChat, + zh = zh, + onBack = { chatSession = null }, + onArchived = { + chatSession = null + chatOpen = false + }, + ) + } else if (detailId != null) { + PilotIssueDetail( + api = api, + workspaceId = session.workspace.id, + workspaceSlug = session.workspace.slug, + issueId = detailId, + currentUser = session.user, + zh = zh, + onBack = { + if (activeInboxContext != null) tabRefresh++ + issueDetailId = null + inboxIssueContext = null + if (returnToSearchAfterDetail) { + searchOpen = true + returnToSearchAfterDetail = false + } + }, + onOpenIssue = { + issueDetailId = it + inboxIssueContext = null + returnToSearchAfterDetail = false + }, + onEditIssue = { + issueFormIssue = it + issueFormParentId = null + issueFormParentIdentifier = null + issueFormOpen = true + }, + onCreateChildIssue = { + issueFormIssue = null + issueFormParentId = it.id + issueFormParentIdentifier = it.identifier + issueFormOpen = true + }, + onDeleted = { + issueDetailId = null + inboxIssueContext = null + returnToSearchAfterDetail = false + tabRefresh++ + }, + highlightCommentId = activeInboxContext?.commentId, + inboxItemId = activeInboxContext?.itemId, + inboxNextIssueId = activeInboxContext?.nextIssueId, + onInboxDone = { nextIssueId -> + issueDetailId = nextIssueId.takeIf { !it.isNullOrBlank() } + inboxIssueContext = null + tabRefresh++ + }, + ) + } else if (activeProject != null) { + PilotProjectDetail( + api = api, + workspaceId = session.workspace.id, + project = activeProject, + zh = zh, + onBack = { + projectDetail = null + if (returnToSearchAfterDetail) { + searchOpen = true + returnToSearchAfterDetail = false + } + }, + onIssueClick = { + issueDetailId = it + returnToSearchAfterDetail = false + }, + ) + } else { + PilotTabContent( + api = api, + state = tabState, + selectedTab = selectedTab, + zh = zh, + session = session, + authStore = authStore, + selectedIssueIds = selectedIssueIds, + onSelectedIssueIdsChange = { selectedIssueIds = it }, + onIssueClick = { issueDetailId = it }, + onInboxIssueClick = { item, nextIssueId -> + if (item.issueId.isNotBlank()) { + markInboxReadOnOpen(item) + selectedTab = MulticaTab.Inbox + issueDetailId = item.issueId + inboxIssueContext = InboxIssueContext( + itemId = item.id, + issueId = item.issueId, + commentId = item.commentId, + nextIssueId = nextIssueId, + ) + } + }, + onProjectClick = { projectDetail = it }, + onNewIssueClick = { + issueFormIssue = null + issueFormParentId = null + issueFormParentIdentifier = null + issueFormOpen = true + }, + onOpenSettingsPage = { settingsPage = it }, + profileOpen = profileOpen, + onProfileOpenChange = { profileOpen = it }, + onRetry = { tabRefresh++ }, + onReloadSession = onReloadSession, + onSessionUpdated = onSessionUpdated, + ) + } + } +} + +@Composable +private fun PilotTabContent( + api: ApiClient, + state: TabState, + selectedTab: MulticaTab, + zh: Boolean, + session: PilotSession, + authStore: AuthStore, + selectedIssueIds: Set, + onSelectedIssueIdsChange: (Set) -> Unit, + onIssueClick: (String) -> Unit, + onInboxIssueClick: (Models.InboxItem, String?) -> Unit, + onProjectClick: (Models.Project) -> Unit, + onNewIssueClick: () -> Unit, + onOpenSettingsPage: (PilotSettingsPage) -> Unit, + profileOpen: Boolean, + onProfileOpenChange: (Boolean) -> Unit, + onRetry: () -> Unit, + onReloadSession: () -> Unit, + onSessionUpdated: (PilotSession) -> Unit, +) { + when (state) { + TabState.Loading -> { + if (selectedTab == MulticaTab.Projects) { + ProjectListLoadingSkeleton(zh = zh) + } else { + MulticaLoadingState(if (zh) "加载中..." else "Loading...") + } + } + is TabState.Error -> MulticaErrorState( + message = "${if (zh) "加载失败" else "Load failed"}\n${state.message}", + onRetry = onRetry, + ) + is TabState.Inbox -> PilotInbox( + api = api, + workspaceId = session.workspace.id, + items = state.items, + loading = state.loading, + hasMore = state.hasMore, + zh = zh, + onIssueClick = onInboxIssueClick, + onRefresh = onRetry, + ) + is TabState.Issues -> PilotIssues( + api = api, + workspaceId = session.workspace.id, + workspaceName = session.workspace.name, + issues = state.issues, + currentUser = session.user, + personal = state.personal, + loading = state.loading, + selectedIds = selectedIssueIds, + zh = zh, + onSelectedIdsChange = onSelectedIssueIdsChange, + onIssueClick = onIssueClick, + onNewIssueClick = onNewIssueClick, + onRefresh = onRetry, + ) + is TabState.Projects -> PilotProjects( + api = api, + workspaceId = session.workspace.id, + workspace = session.workspace, + projects = state.projects, + zh = zh, + onProjectClick = onProjectClick, + onRefresh = onRetry, + ) + TabState.Settings -> PilotSettings( + api = api, + session = session, + zh = zh, + authStore = authStore, + profileOpen = profileOpen, + onProfileOpenChange = onProfileOpenChange, + onOpenSettingsPage = onOpenSettingsPage, + onReloadSession = onReloadSession, + onSessionUpdated = onSessionUpdated, + ) + } +} + +@Composable +private fun PilotSearchPage( + api: ApiClient, + workspaceId: String, + zh: Boolean, + query: String, + onQueryChange: (String) -> Unit, + onBack: () -> Unit, + onIssueClick: (String) -> Unit, + onProjectClick: (Models.Project) -> Unit, + onTabClick: (MulticaTab) -> Unit, + onSettingsPageClick: (PilotSettingsPage) -> Unit, +) { + var loading by remember(workspaceId) { mutableStateOf(false) } + var result by remember(workspaceId) { mutableStateOf?>(null) } + var message by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + + fun runSearch() { + val text = query.trim() + if (text.isBlank() || loading) { + message = if (zh) "请输入搜索内容" else "Enter a search query" + return + } + loading = true + message = null + result = null + scope.launch { + val searchResult = withContext(Dispatchers.IO) { + runCatching { + PilotSearchData( + issues = api.searchIssues(workspaceId, text, 20), + projects = api.searchProjects(workspaceId, text, 10), + agents = runCatching { searchLocalAgents(api.agents(workspaceId), text) }.getOrElse { emptyList() }, + skills = runCatching { searchLocalSkills(api.skills(workspaceId), text) }.getOrElse { emptyList() }, + ) + } + } + loading = false + result = searchResult + searchResult.onFailure { + message = "${if (zh) "搜索失败" else "Search failed"}: ${it.message ?: it.toString()}" + } + } + } + + PilotListPage( + title = if (zh) "搜索 Multica" else "Search Multica", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + item { + MulticaTextField( + value = query, + onValueChange = { + onQueryChange(it) + message = null + if (it.isBlank()) result = null + }, + label = if (zh) "输入命令或搜索" else "Type a command or search", + contentDescription = "Search Web Command Input", + singleLine = true, + ) + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .semantics(mergeDescendants = true) { contentDescription = "Search Web Action Row" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaPillButton( + text = if (loading) "..." else if (zh) "执行搜索" else "Run Search", + onClick = { runSearch() }, + tone = MulticaButtonTone.Primary, + ) + MulticaPillButton( + text = if (zh) "清空" else "Clear", + onClick = { + onQueryChange("") + result = null + message = null + }, + tone = MulticaButtonTone.Ghost, + ) + } + } + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "Search Result Web Banner", + ) + } + } + if (loading) { + item { MulticaLoadingState(if (zh) "正在搜索..." else "Searching...") } + } + if (!loading && query.trim().isBlank() && result == null) { + item { + SearchWebResultsGroup( + title = if (zh) "建议" else "Suggestions", + count = 5, + contentDescription = "Search Web Suggestions Group", + ) { + SearchWebResultRow( + icon = Icons.Outlined.Add, + eyebrow = if (zh) "命令" else "Command", + title = if (zh) "查看 Issues" else "Open Issues", + subtitle = if (zh) "浏览工作区事项" else "Browse workspace issues", + trailing = if (zh) "页面" else "Page", + contentDescription = "Search Web Suggestion Row Issues", + showDivider = true, + onClick = { onTabClick(MulticaTab.Issues) }, + ) + SearchWebResultRow( + icon = Icons.Outlined.Folder, + eyebrow = if (zh) "页面" else "Page", + title = if (zh) "查看 Projects" else "Open Projects", + subtitle = if (zh) "按项目查看进展" else "Review project progress", + trailing = if (zh) "页面" else "Page", + contentDescription = "Search Web Suggestion Row Projects", + showDivider = true, + onClick = { onTabClick(MulticaTab.Projects) }, + ) + SearchWebResultRow( + icon = Icons.Outlined.Bolt, + eyebrow = if (zh) "配置" else "Configure", + title = if (zh) "查看 Agents" else "Open Agents", + subtitle = if (zh) "管理自动化成员" else "Manage automation members", + trailing = if (zh) "设置" else "Settings", + contentDescription = "Search Web Suggestion Row Agents", + showDivider = true, + onClick = { onSettingsPageClick(PilotSettingsPage.Agents) }, + ) + SearchWebResultRow( + icon = Icons.Outlined.Tune, + eyebrow = if (zh) "配置" else "Configure", + title = if (zh) "查看 Skills" else "Open Skills", + subtitle = if (zh) "管理可复用能力" else "Manage reusable capabilities", + trailing = if (zh) "设置" else "Settings", + contentDescription = "Search Web Suggestion Row Skills", + showDivider = true, + onClick = { onSettingsPageClick(PilotSettingsPage.Skills) }, + ) + SearchWebResultRow( + icon = Icons.Outlined.Search, + eyebrow = if (zh) "提示" else "Hint", + title = if (zh) "输入关键词搜索 issue 和项目" else "Type to search issues and projects", + subtitle = if (zh) "也会匹配 Agents 与 Skills" else "Also matches Agents and Skills", + trailing = "⌘K", + contentDescription = "Search Web Suggestion Row Type To Search", + showDivider = false, + onClick = {}, + ) + } + } + } + val loaded = result + if (loaded != null && loaded.isSuccess) { + val data = loaded.getOrThrow() + val hasResults = data.issues.isNotEmpty() || data.projects.isNotEmpty() || data.agents.isNotEmpty() || data.skills.isNotEmpty() + if (!hasResults) { + item { SearchWebEmptyState(zh = zh) } + } + if (data.projects.isNotEmpty()) { + item { + SearchWebResultsGroup( + title = if (zh) "项目" else "Projects", + count = data.projects.size, + contentDescription = "Search Web Results Group Projects", + ) { + data.projects.forEachIndexed { index, project -> + SearchWebResultRow( + icon = Icons.Outlined.Folder, + eyebrow = clean(project.matchSource).ifBlank { if (zh) "项目" else "Project" }, + title = project.name, + subtitle = clean(project.matchedSnippet).ifBlank { clean(project.description).ifBlank { null } }, + trailing = Models.projectStatusLabel(project.status, zh), + contentDescription = "Search Web Project Result Row ${project.id}", + showDivider = index < data.projects.lastIndex, + onClick = { + focusManager.clearFocus() + onProjectClick(project) + }, + ) + } + } + } + } + if (data.issues.isNotEmpty()) { + item { + SearchWebResultsGroup( + title = "Issues", + count = data.issues.size, + contentDescription = "Search Web Results Group Issues", + ) { + data.issues.forEachIndexed { index, issue -> + SearchWebResultRow( + icon = issueStatusIcon(issue.status), + eyebrow = issue.identifier, + title = issue.title, + subtitle = clean(issue.matchedSnippet).ifBlank { + listOf( + clean(issue.matchSource).ifBlank { null }, + Models.statusLabel(issue.status, zh), + Models.priorityLabel(issue.priority, zh), + ).filterNotNull().joinToString(" · ") + }, + trailing = Models.statusLabel(issue.status, zh), + contentDescription = "Search Web Issue Result Row ${issue.id}", + showDivider = index < data.issues.lastIndex, + onClick = { + focusManager.clearFocus() + onIssueClick(issue.id) + }, + ) + } + } + } + } + if (data.agents.isNotEmpty()) { + item { + SearchWebResultsGroup( + title = if (zh) "Agents" else "Agents", + count = data.agents.size, + contentDescription = "Search Web Results Group Agents", + ) { + data.agents.forEachIndexed { index, agent -> + SearchWebResultRow( + icon = Icons.Outlined.Bolt, + eyebrow = clean(agent.status).ifBlank { if (zh) "Agent" else "Agent" }, + title = agent.name, + subtitle = clean(agent.description).ifBlank { clean(agent.model).ifBlank { null } }, + trailing = clean(agent.visibility).ifBlank { if (zh) "私有" else "Private" }, + contentDescription = "Search Web Agent Result Row ${agent.id}", + showDivider = index < data.agents.lastIndex, + onClick = { + focusManager.clearFocus() + onSettingsPageClick(PilotSettingsPage.Agents) + }, + ) + } + } + } + } + if (data.skills.isNotEmpty()) { + item { + SearchWebResultsGroup( + title = if (zh) "Skills" else "Skills", + count = data.skills.size, + contentDescription = "Search Web Results Group Skills", + ) { + data.skills.forEachIndexed { index, skill -> + SearchWebResultRow( + icon = Icons.Outlined.Tune, + eyebrow = clean(skill.originType).ifBlank { if (zh) "Skill" else "Skill" }, + title = skill.name, + subtitle = clean(skill.description).ifBlank { clean(skill.originSourceUrl).ifBlank { null } }, + trailing = skill.files.size.takeIf { it > 0 }?.let { if (zh) "$it 个文件" else "$it files" } ?: if (zh) "能力" else "Skill", + contentDescription = "Search Web Skill Result Row ${skill.id}", + showDivider = index < data.skills.lastIndex, + onClick = { + focusManager.clearFocus() + onSettingsPageClick(PilotSettingsPage.Skills) + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun SearchWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SEARCH_COMMAND = "/Users/park0er/coding/multica-web-source/packages/views/search/search-command.tsx" + val WEB_SSOT_SEARCH_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/search.json" + Box( + modifier = modifier.semantics { + contentDescription = "Search Web Empty State $WEB_SSOT_SEARCH_COMMAND $WEB_SSOT_SEARCH_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "未找到结果。" else "No results found.", + description = if (zh) "输入关键词搜索 issue 和项目" else "Type to search issues and projects", + ) + } +} + +private fun searchLocalAgents(agents: List, text: String): List { + val query = text.trim() + if (query.isBlank()) return emptyList() + return agents + .asSequence() + .filter { it.archivedAt.isBlank() } + .filter { agent -> + listOf(agent.name, agent.description, agent.model, agent.status, agent.visibility) + .any { it.contains(query, ignoreCase = true) } + } + .take(8) + .toList() +} + +private fun searchLocalSkills(skills: List, text: String): List { + val query = text.trim() + if (query.isBlank()) return emptyList() + return skills + .asSequence() + .filter { skill -> + listOf(skill.name, skill.description, skill.originSourceUrl, skill.originType) + .any { it.contains(query, ignoreCase = true) } + } + .take(8) + .toList() +} + +@Composable +private fun SearchWebResultsGroup( + title: String, + count: Int, + contentDescription: String, + content: @Composable ColumnScope.() -> Unit, +) { + SettingsWebTabGroup( + title = title, + subtitle = count.toString(), + contentDescription = contentDescription, + content = content, + ) +} + +@Composable +private fun SearchWebResultRow( + icon: ImageVector, + eyebrow: String, + title: String, + subtitle: String?, + trailing: String, + contentDescription: String, + showDivider: Boolean, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + semanticsOnClick(label = title) { + onClick() + true + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + Text( + text = eyebrow, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 58.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = trailing, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 72.dp), + textAlign = TextAlign.End, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun PilotInbox( + api: ApiClient, + workspaceId: String, + items: List, + loading: Boolean, + hasMore: Boolean, + zh: Boolean, + onIssueClick: (Models.InboxItem, String?) -> Unit, + onRefresh: () -> Unit, +) { + val scope = rememberCoroutineScope() + var mutating by remember(workspaceId) { mutableStateOf(false) } + var loadingMore by remember(workspaceId, items) { mutableStateOf(false) } + var extraItems by remember(workspaceId, items) { mutableStateOf>(emptyList()) } + var localHasMore by remember(workspaceId, items, hasMore) { mutableStateOf(hasMore) } + var resultText by remember(workspaceId) { mutableStateOf(null) } + var batchMenuExpanded by remember(workspaceId) { mutableStateOf(false) } + val visibleItems = remember(items, extraItems) { + InboxNotificationDeduper.deduplicateByIssue(items + extraItems.filter { extra -> items.none { it.id == extra.id } }) + } + + LaunchedEffect(workspaceId) { + while (true) { + delay(30_000) + onRefresh() + } + } + + fun runInboxAction(action: suspend () -> String) { + if (mutating) return + mutating = true + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { action() } } + mutating = false + result.onSuccess { + resultText = it + onRefresh() + }.onFailure { + resultText = "${if (zh) "收件箱操作失败" else "Inbox action failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun loadMoreInbox() { + if (loadingMore || !localHasMore) return + loadingMore = true + resultText = null + val offset = visibleItems.size + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.inbox(workspaceId, PILOT_INBOX_PAGE_SIZE, offset) } + } + loadingMore = false + result.onSuccess { page -> + val existingIds = (items + extraItems).map { it.id }.toSet() + extraItems = extraItems + page.items.filter { it.id !in existingIds } + localHasMore = page.hasMore + }.onFailure { + resultText = "${if (zh) "加载更多失败" else "Load more failed"}: ${it.message ?: it.toString()}" + } + } + } + + PilotListPage( + title = if (zh) "收件箱" else "Inbox", + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Inbox Batch Menu", + onClick = { batchMenuExpanded = true }, + tone = MulticaButtonTone.Ghost, + ) + MulticaCupertinoActionSheet( + visible = batchMenuExpanded, + title = if (zh) "收件箱批量操作" else "Inbox actions", + message = if (zh) "对当前工作区收件箱执行批量整理。" else "Triage the current workspace inbox.", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (mutating) "..." else if (zh) "全部已读" else "Mark All Read", + icon = Icons.Outlined.DoneAll, + contentDescription = "Inbox Batch Cupertino Action Sheet Mark All Read", + enabled = !mutating, + onClick = { + runInboxAction { + val count = api.markAllInboxRead(workspaceId) + if (zh) "已标记 $count 条为已读" else "Marked $count read" + } + }, + ), + MulticaCupertinoActionSheetItem( + title = if (mutating) "..." else if (zh) "全部归档" else "Archive All", + icon = Icons.Outlined.Archive, + contentDescription = "Inbox Batch Cupertino Action Sheet Archive All", + enabled = !mutating, + onClick = { + runInboxAction { + val count = api.archiveAllInbox(workspaceId) + if (zh) "已归档 $count 条消息" else "Archived $count items" + } + }, + ), + MulticaCupertinoActionSheetItem( + title = if (mutating) "..." else if (zh) "归档已读" else "Archive Read", + icon = Icons.Outlined.Archive, + contentDescription = "Inbox Batch Cupertino Action Sheet Archive Read", + enabled = !mutating, + onClick = { + runInboxAction { + val count = api.archiveAllReadInbox(workspaceId) + if (zh) "已归档 $count 条已读消息" else "Archived $count read items" + } + }, + ), + MulticaCupertinoActionSheetItem( + title = if (mutating) "..." else if (zh) "归档完成" else "Archive Done", + icon = Icons.Outlined.CheckCircle, + contentDescription = "Inbox Batch Cupertino Action Sheet Archive Completed", + enabled = !mutating, + onClick = { + runInboxAction { + val count = api.archiveCompletedInbox(workspaceId) + if (zh) "已归档 $count 条完成消息" else "Archived $count completed items" + } + }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { batchMenuExpanded = false }, + ) + }, + ) { + if (!resultText.isNullOrBlank()) { + item { + val isError = resultText!!.contains("failed", true) || resultText!!.contains("失败") + PilotInlineResultState( + message = resultText.orEmpty(), + isError = isError, + contentDescription = "Inbox Result Web Banner", + ) + } + } + if (loading) { + item { MulticaLoadingState(if (zh) "正在加载收件箱..." else "Loading inbox...") } + } else if (visibleItems.isEmpty()) { + item { InboxWebEmptyState(zh = zh) } + } + itemsIndexed(visibleItems, key = { _, item -> item.id }) { index, item -> + val next = visibleItems.getOrNull(index + 1) ?: visibleItems.getOrNull(index - 1) + PilotInboxRow( + item = item, + zh = zh, + mutating = mutating, + onIssueClick = { + onIssueClick(item, next?.issueId) + }, + onMarkRead = { + runInboxAction { + api.markInboxRead(workspaceId, item.id) + if (zh) "已标记为已读" else "Marked read" + } + }, + onArchive = { + runInboxAction { + api.archiveInbox(workspaceId, item.id) + if (zh) "已归档" else "Archived" + } + }, + ) + } + if (!loading && localHasMore) { + item { + MulticaPillButton( + text = if (loadingMore) "..." else if (zh) "加载更多" else "Load More", + onClick = { loadMoreInbox() }, + tone = MulticaButtonTone.Ghost, + contentDescription = "Inbox Load More", + enabled = !loadingMore, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun InboxWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_INBOX_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/inbox/components/inbox-page.tsx" + val WEB_SSOT_INBOX_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/inbox.json" + Box( + modifier = modifier.semantics { + contentDescription = "Inbox Web Empty State $WEB_SSOT_INBOX_PAGE $WEB_SSOT_INBOX_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "暂无通知" else "No notifications", + description = if (zh) "收件箱为空" else "Your inbox is empty", + ) + } +} + +@Composable +private fun PilotInboxRow( + item: Models.InboxItem, + zh: Boolean, + mutating: Boolean, + onIssueClick: () -> Unit, + onMarkRead: () -> Unit, + onArchive: () -> Unit, +) { + val density = LocalDensity.current + val archiveRevealPx = with(density) { 92.dp.toPx() } + var horizontalDrag by remember(item.id) { mutableStateOf(0f) } + var archiveRevealed by remember(item.id) { mutableStateOf(false) } + val rowOffset = if (archiveRevealed) -archiveRevealPx else horizontalDrag.coerceIn(-archiveRevealPx, 0f) + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 62.dp), + ) { + if (archiveRevealed) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 2.dp) + .width(82.dp) + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.12f)) + .clickable(enabled = !mutating) { onArchive() } + .semantics { contentDescription = "Inbox Swipe Archive ${item.id}" }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = null, + tint = MulticaColors.Danger, + modifier = Modifier.size(16.dp), + ) + Text( + text = if (mutating) "..." else if (zh) "归档" else "Archive", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Danger, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 62.dp) + .graphicsLayer { translationX = rowOffset } + .background(MulticaColors.Background) + .pointerInput(item.id, mutating, archiveRevealed) { + detectHorizontalDragGestures( + onDragCancel = { + horizontalDrag = 0f + }, + onDragEnd = { + archiveRevealed = if (archiveRevealed) { + horizontalDrag < archiveRevealPx * 0.34f + } else { + horizontalDrag < -archiveRevealPx * 0.42f + } + horizontalDrag = 0f + }, + onHorizontalDrag = { change, dragAmount -> + if (!mutating) { + val base = if (archiveRevealed) -archiveRevealPx else 0f + horizontalDrag = (base + horizontalDrag + dragAmount).coerceIn(-archiveRevealPx, archiveRevealPx * 0.36f) + change.consume() + } + }, + ) + } + .clickable { + if (archiveRevealed) { + archiveRevealed = false + } else { + onIssueClick() + } + } + .padding(vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = if (!item.read) 0.46f else 0.18f)) + .semantics { contentDescription = "Inbox Actor Avatar ${item.id}" }, + contentAlignment = Alignment.Center, + ) { + Text( + text = inboxAvatarText(item), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 12.sp), + color = if (!item.read) MulticaColors.Accent else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!item.read) { + Box( + modifier = Modifier + .size(6.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Accent) + ) + } + Text( + text = inboxDisplayTitle(item), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (!item.read) FontWeight.SemiBold else FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 19.sp, + ), + color = if (!item.read) MulticaColors.TextPrimary else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + if (item.issueStatus.isNotBlank()) { + Icon( + imageVector = issueStatusIcon(item.issueStatus), + contentDescription = null, + tint = MulticaColors.Muted.copy(alpha = if (!item.read) 0.84f else 0.54f), + modifier = Modifier.size(15.dp), + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = inboxDetailLabel(item, zh), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = if (!item.read) 0.92f else 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + val createdAt = relativeDate(item.createdAt, zh) + if (createdAt.isNotBlank()) { + Text( + text = createdAt, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = if (!item.read) 0.92f else 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } +} + +private fun inboxDisplayTitle(item: Models.InboxItem): String { + val title = clean(item.issueTitle) + if (title.isBlank()) return clean(item.issueIdentifier).ifBlank { clean(item.type).replace('_', ' ') } + val identifier = clean(item.issueIdentifier) + if (identifier.isNotBlank()) { + val exactPrefix = Regex("^Created\\s+${Regex.escape(identifier)}:\\s*", RegexOption.IGNORE_CASE) + val stripped = title.replace(exactPrefix, "").trim() + if (stripped != title) return stripped + } + return title.replace(Regex("^Created\\s+[A-Z][A-Z0-9]*-\\d+:\\s*", RegexOption.IGNORE_CASE), "").trim() + .ifBlank { title } +} + +private fun inboxDetailLabel(item: Models.InboxItem, zh: Boolean): String { + val body = clean(item.body) + if (body.isNotBlank()) return body + return listOfNotNull( + clean(item.issueIdentifier).takeIf { it.isNotBlank() && it != item.issueId }, + clean(item.type).replace('_', ' ').takeIf { it.isNotBlank() }, + clean(item.issueStatus).takeIf { it.isNotBlank() }?.let { Models.statusLabel(it, zh) }, + ).joinToString(" · ").ifBlank { Models.statusLabel(item.issueStatus, zh).ifBlank { if (zh) "通知" else "Notification" } } +} + +private fun inboxAvatarText(item: Models.InboxItem): String { + val source = clean(item.issueIdentifier).ifBlank { clean(item.type) }.ifBlank { "M" } + return source.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "M" +} + +@Composable +private fun InlineInboxAction( + text: String, + contentDescription: String, + emphasized: Boolean, + icon: ImageVector? = null, + iconOnly: Boolean = false, + onClick: () -> Unit, +) { + val foreground = if (emphasized) androidx.compose.ui.graphics.Color(0xFF7DB1FF) else MulticaColors.Muted + val background = if (iconOnly) { + if (emphasized) MulticaColors.AccentSoft.copy(alpha = 0.10f) else androidx.compose.ui.graphics.Color.Transparent + } else if (emphasized) { + MulticaColors.AccentSoft.copy(alpha = 0.58f) + } else { + androidx.compose.ui.graphics.Color.Transparent + } + val borderColor = if (emphasized) { + MulticaColors.Accent.copy(alpha = if (iconOnly) 0.22f else 0.45f) + } else { + MulticaColors.Border.copy(alpha = if (iconOnly) 0.58f else 1f) + } + if (iconOnly) { + Box( + modifier = Modifier + .width(44.dp) + .height(34.dp) + .clickable { onClick() } + .semantics { this.contentDescription = contentDescription }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .width(28.dp) + .height(28.dp) + .clip(RoundedCornerShape(999.dp)) + .background(background) + .border(0.75.dp, borderColor, RoundedCornerShape(999.dp)), + contentAlignment = Alignment.Center, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = foreground, + modifier = Modifier.width(15.dp).height(15.dp), + ) + } + } + } + return + } + Row( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(background) + .border(1.dp, borderColor, RoundedCornerShape(999.dp)) + .clickable { onClick() } + .semantics { this.contentDescription = contentDescription } + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = foreground, + modifier = Modifier.width(13.dp).height(13.dp), + ) + } + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ChatNewSessionWebPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(MulticaVisualTokens.CardRadius) + Surface( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat New Session Web Panel" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.70f), shape) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } +} + +@Composable +private fun ChatSessionListWebPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(10.dp) + Surface( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Session List Web Panel" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.70f), shape) + .padding(horizontal = 10.dp, vertical = 6.dp), + content = content, + ) + } +} + +@Composable +private fun PilotChatSessions( + api: ApiClient, + workspaceId: String, + zh: Boolean, + onBack: () -> Unit, + onSessionClick: (Models.ChatSession) -> Unit, +) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var title by remember(workspaceId) { mutableStateOf("") } + var selectedAgentId by remember(workspaceId) { mutableStateOf(null) } + var agentPickerOpen by remember(workspaceId) { mutableStateOf(false) } + var agentQuery by remember(workspaceId) { mutableStateOf("") } + var showAllSessions by remember(workspaceId) { mutableStateOf(false) } + var showCreateForm by remember(workspaceId) { mutableStateOf(false) } + var creating by remember(workspaceId) { mutableStateOf(false) } + var deletingSessionId by remember(workspaceId) { mutableStateOf(null) } + var actionText by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + + LaunchedEffect(workspaceId, refresh, showAllSessions) { + state = null + val loaded = withContext(Dispatchers.IO) { + runCatching { + PilotChatSessionsData( + api.chatSessions(workspaceId, if (showAllSessions) "all" else null), + api.agents(workspaceId) + .filter { it.archivedAt.isBlank() } + .sortedBy { it.name.lowercase() }, + ) + } + } + loaded.onSuccess { data -> + if (selectedAgentId == null || data.agents.none { it.id == selectedAgentId }) { + selectedAgentId = data.agents.firstOrNull()?.id + } + } + state = loaded + } + + fun createSession(data: PilotChatSessionsData) { + if (creating) return + val agent = data.agents.firstOrNull { it.id == selectedAgentId } ?: data.agents.firstOrNull() + if (agent == null) { + actionText = if (zh) "请先创建 Agent" else "Create an agent first" + return + } + val sessionTitle = title.trim().ifBlank { + if (zh) "新的聊天" else "New Chat" + } + creating = true + actionText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.createChatSession(workspaceId, agent.id, sessionTitle) } + } + creating = false + result.onSuccess { + title = "" + actionText = if (zh) "聊天会话已创建" else "Chat session created" + refresh++ + }.onFailure { + actionText = "${if (zh) "创建聊天失败" else "Create chat failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun deleteSession(session: Models.ChatSession) { + if (deletingSessionId != null) return + deletingSessionId = session.id + actionText = null + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.archiveChatSession(workspaceId, session.id) } + } + deletingSessionId = null + result.onSuccess { + actionText = if (zh) "聊天会话已删除" else "Chat session deleted" + refresh++ + }.onFailure { + actionText = "${if (zh) "删除聊天失败" else "Delete chat failed"}: ${it.message ?: it.toString()}" + } + } + } + + val loaded = state + when { + loaded == null -> MulticaLoadingState(if (zh) "正在加载聊天..." else "Loading chat...") + loaded.isFailure -> MulticaErrorState( + message = "${if (zh) "聊天加载失败" else "Chat failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", + onRetry = { refresh++ }, + ) + else -> PilotListPage( + title = if (zh) "聊天" else "Chat", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + val data = loaded.getOrThrow() + val sessions = data.sessions + val selectedAgent = data.agents.firstOrNull { it.id == selectedAgentId } ?: data.agents.firstOrNull() + val agentNamesById = data.agents.associate { it.id to it.name } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Sessions Web Command Row" } + .padding(bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaPillButton( + text = if (showCreateForm) { + if (zh) "收起新聊天" else "Hide New Chat" + } else { + if (zh) "新聊天" else "New Chat" + }, + onClick = { + showCreateForm = !showCreateForm + actionText = null + }, + modifier = Modifier.weight(1f), + tone = if (showCreateForm) MulticaButtonTone.Secondary else MulticaButtonTone.Primary, + contentDescription = "Chat New Session Toggle", + ) + MulticaPillButton( + text = if (showAllSessions) { + if (zh) "仅活跃会话" else "Active Only" + } else { + if (zh) "全部会话" else "All Sessions" + }, + onClick = { + showAllSessions = !showAllSessions + actionText = null + }, + modifier = Modifier.weight(1f), + tone = if (showAllSessions) MulticaButtonTone.Secondary else MulticaButtonTone.Ghost, + contentDescription = "Chat Sessions History Toggle", + ) + } + } + if (showCreateForm) { + item { + SettingsSectionLabel(if (zh) "新聊天" else "New Chat") + ChatNewSessionWebPanel( + modifier = Modifier.semantics { contentDescription = "Chat New Session Web Panel" }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Chat New Session Web Form" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = title, + onValueChange = { title = it }, + label = if (zh) "标题" else "Title", + contentDescription = "Chat New Session Title", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + ) + ChatAgentPickerRow( + selectedAgent = selectedAgent, + expanded = agentPickerOpen, + zh = zh, + onClick = { + if (data.agents.isNotEmpty()) { + agentPickerOpen = !agentPickerOpen + actionText = null + } + }, + ) + } + if (agentPickerOpen) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + MulticaTextField( + value = agentQuery, + onValueChange = { agentQuery = it }, + label = if (zh) "搜索 Agent" else "Search Agents", + contentDescription = "Chat Agent Search", + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + val filteredAgents = data.agents.filter { agent -> + val haystack = "${agent.name}\n${agent.description}\n${agent.id}".lowercase() + haystack.contains(agentQuery.trim().lowercase()) + } + if (filteredAgents.isEmpty()) { + Text( + text = if (zh) "没有匹配的 Agent" else "No matching agents", + style = MaterialTheme.typography.bodyMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(vertical = 6.dp), + ) + } + filteredAgents.take(20).forEachIndexed { index, agent -> + ChatAgentWebDenseRow( + agent = agent, + selected = agent.id == selectedAgent?.id, + showDivider = index < filteredAgents.lastIndex && index < 19, + onClick = { + selectedAgentId = agent.id + agentPickerOpen = false + agentQuery = "" + actionText = null + }, + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.End, + ) { + MulticaPillButton( + text = if (creating) "..." else if (zh) "创建聊天" else "Create Chat", + onClick = { createSession(data) }, + tone = MulticaButtonTone.Primary, + ) + } + } + } + } + if (!actionText.isNullOrBlank()) { + item { + val isError = actionText!!.contains("failed", true) || actionText!!.contains("失败") + PilotInlineResultState( + message = actionText.orEmpty(), + isError = isError, + contentDescription = "Chat Sessions Result Web Banner Chat Session Row Delete Result Web Banner", + ) + } + } + if (sessions.isEmpty()) { + item { + ChatSessionsWebEmptyState( + hasAgents = data.agents.isNotEmpty(), + showAllSessions = showAllSessions, + zh = zh, + ) + } + } + if (sessions.isNotEmpty()) { + item { + ChatSessionListWebPanel( + modifier = Modifier.semantics { contentDescription = "Chat Session List Web Panel" }, + ) { + sessions.forEachIndexed { index, session -> + ChatSessionWebDenseRow( + session = session, + agentName = if (session.agentId.isBlank()) { + "" + } else { + (agentNamesById[session.agentId] ?: "").ifBlank { Models.shortId(session.agentId) } + }, + zh = zh, + showDivider = index < sessions.lastIndex, + deleting = deletingSessionId == session.id, + onClick = { onSessionClick(session) }, + onDelete = { deleteSession(it) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun ChatSessionsWebEmptyState( + hasAgents: Boolean, + showAllSessions: Boolean, + zh: Boolean, +) { + val WEB_SSOT_CHAT_WINDOW = "/Users/park0er/coding/multica-web-source/packages/views/chat/components/chat-window.tsx" + val title = when { + !hasAgents -> if (zh) "暂无智能体" else "No agents" + showAllSessions -> if (zh) "暂无历史对话" else "No previous chats" + else -> if (zh) "暂无进行中对话" else "No active chats" + } + val description = when { + !hasAgents -> if (zh) "需要先有一个智能体才能开始对话。" else "You need an agent to start chatting." + showAllSessions -> if (zh) "新建对话后会显示在这里。" else "New chats will appear here." + else -> if (zh) "归档会话可在全部会话中查看。" else "Archived chats are available from All Sessions." + } + + Box( + modifier = Modifier.semantics { + contentDescription = "Chat Sessions Web Empty State $WEB_SSOT_CHAT_WINDOW" + }, + ) { + MulticaEmptyState( + title = title, + description = description, + ) + } +} + +@Composable +private fun ChatAgentPickerRow( + selectedAgent: Models.Agent?, + expanded: Boolean, + zh: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Agent Picker Row" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Person, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = selectedAgent?.name ?: if (zh) "暂无 Agent" else "No agent available", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) "当前聊天 Agent" else "Chat agent", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + ) + } + Icon( + imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + } + } +} + +@Composable +private fun ChatAgentWebDenseRow( + agent: Models.Agent, + selected: Boolean, + showDivider: Boolean, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Chat Agent Web Dense Row ${agent.name}" }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(30.dp), + shape = RoundedCornerShape(999.dp), + color = if (selected) MulticaColors.AccentSoft else MulticaColors.Border.copy(alpha = 0.36f), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = agent.name.firstOrNull()?.uppercaseChar()?.toString() ?: "A", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = if (selected) MulticaColors.Accent else MulticaColors.Muted, + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = agent.name, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = clean(agent.description).ifBlank { Models.shortId(agent.id) }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(17.dp)) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 39.dp) + .height(0.75.dp) + .background(MulticaColors.Border.copy(alpha = 0.55f)), + ) + } + } +} + +@Composable +private fun ChatSessionWebDenseRow( + session: Models.ChatSession, + agentName: String, + zh: Boolean, + showDivider: Boolean, + deleting: Boolean, + onClick: () -> Unit, + onDelete: (Models.ChatSession) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Chat Session Web Dense Row ${session.id}" }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(32.dp), + shape = RoundedCornerShape(999.dp), + color = if (session.hasUnread) MulticaColors.AccentSoft else MulticaColors.Border.copy(alpha = 0.34f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Outlined.Archive, + contentDescription = null, + tint = if (session.hasUnread) MulticaColors.Accent else MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + if (session.hasUnread) { + Box( + modifier = Modifier + .size(6.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Accent), + ) + } + Text( + text = session.title.ifBlank { if (zh) "未命名聊天" else "Untitled chat" }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = if (session.hasUnread) FontWeight.SemiBold else FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = listOf( + if (agentName.isBlank()) null else "Agent · $agentName", + session.status.ifBlank { null }, + shortDate(session.updatedAt).ifBlank { null }, + ).filterNotNull().joinToString(" · "), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.Delete, + contentDescription = "Chat Session Delete Action", + enabled = !deleting, + onClick = { onDelete(session) }, + tone = MulticaButtonTone.Destructive, + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "Chat Session Delete Action" }, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 42.dp) + .height(0.75.dp) + .background(MulticaColors.Border.copy(alpha = 0.55f)), + ) + } + } +} + +@Composable +private fun PilotChatMessages( + api: ApiClient, + workspaceId: String, + session: Models.ChatSession, + zh: Boolean, + onBack: () -> Unit, + onArchived: () -> Unit, +) { + var state by remember(session.id) { mutableStateOf?>(null) } + var refresh by remember(session.id) { mutableIntStateOf(0) } + var draft by remember(session.id) { mutableStateOf("") } + var sending by remember(session.id) { mutableStateOf(false) } + var locallyPending by remember(session.id) { mutableStateOf(false) } + var sendError by remember(session.id) { mutableStateOf(null) } + var archiving by remember(session.id) { mutableStateOf(false) } + var cancelling by remember(session.id) { mutableStateOf(false) } + var headerMenuOpen by remember(session.id) { mutableStateOf(false) } + var actionError by remember(session.id) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val hazeState = rememberHazeState() + val haptic = LocalHapticFeedback.current + + LaunchedEffect(session.id, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + val messages = api.chatMessages(workspaceId, session.id) + val pending = runCatching { api.pendingChatTask(workspaceId, session.id) } + .getOrElse { Models.ChatPendingTask(org.json.JSONObject()) } + runCatching { api.markChatSessionRead(workspaceId, session.id) } + PilotChatMessagesData(messages, pending) + } + } + } + + LaunchedEffect(sending, state?.getOrNull()?.pendingTask?.taskId) { + val taskId = state?.getOrNull()?.pendingTask?.taskId.orEmpty() + if (!sending && taskId.isBlank()) return@LaunchedEffect + while (sending || taskId.isNotBlank()) { + delay(5_000) + refresh++ + } + } + + LaunchedEffect(state?.getOrNull()?.messages?.size, state?.getOrNull()?.pendingTask?.taskId) { + val loadedData = state?.getOrNull() ?: return@LaunchedEffect + if (loadedData.pendingTask.taskId.isNotBlank() || loadedData.messages.isNotEmpty()) { + locallyPending = false + } + } + + val loaded = state + val pendingTask = loaded?.getOrNull()?.pendingTask + + fun archiveSession() { + if (archiving) return + archiving = true + headerMenuOpen = false + actionError = null + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.archiveChatSession(workspaceId, session.id) } + } + archiving = false + result.onSuccess { onArchived() } + .onFailure { actionError = "${if (zh) "归档失败" else "Archive failed"}: ${it.message ?: it.toString()}" } + } + } + + fun cancelPendingTask(task: Models.ChatPendingTask?) { + if (task == null || task.taskId.isBlank() || cancelling) return + cancelling = true + headerMenuOpen = false + actionError = null + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.cancelTaskById(workspaceId, task.taskId) } + } + cancelling = false + result.onSuccess { refresh++ } + .onFailure { actionError = "${if (zh) "取消失败" else "Cancel failed"}: ${it.message ?: it.toString()}" } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding() + .padding(top = 8.dp) + .semantics { contentDescription = "Chat Detail Safe Area Container" }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp) + .semantics { contentDescription = "Chat Detail Header Safe Horizontal Insets" }, + ) { + PilotPageHeader( + title = session.title, + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Chat Header More Menu", + onClick = { headerMenuOpen = true }, + tone = MulticaButtonTone.Ghost, + ) + MulticaCupertinoActionSheet( + visible = headerMenuOpen, + title = if (zh) "会话操作" else "Session actions", + message = if (zh) "整理当前聊天会话。" else "Manage this chat session.", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (archiving) "..." else if (zh) "归档会话" else "Archive Session", + icon = Icons.Outlined.Archive, + enabled = !archiving, + contentDescription = "Chat Header Cupertino Action Sheet Archive Session", + onClick = { archiveSession() }, + ), + MulticaCupertinoActionSheetItem( + title = if (cancelling) "..." else if (zh) "取消任务" else "Cancel Task", + icon = Icons.Outlined.Close, + enabled = pendingTask?.taskId?.isNotBlank() == true && !cancelling, + contentDescription = "Chat Header Cupertino Action Sheet Cancel Task", + destructive = true, + onClick = { cancelPendingTask(pendingTask) }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { headerMenuOpen = false }, + ) + }, + ) + } + Text( + text = "${if (zh) "会话状态" else "Status"} · ${session.status}", + style = MaterialTheme.typography.bodyMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(horizontal = 18.dp), + ) + if (pendingTask != null && pendingTask.taskId.isNotBlank()) { + Text( + text = "${if (zh) "任务" else "Task"} · ${pendingTask.status.ifBlank { Models.shortId(pendingTask.taskId) }}", + style = MaterialTheme.typography.bodyMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(horizontal = 18.dp), + ) + } + if (!actionError.isNullOrBlank()) { + PilotInlineResultState( + message = actionError.orEmpty(), + isError = true, + contentDescription = "Chat Action Result Web Banner", + modifier = Modifier.padding(horizontal = 18.dp), + ) + } + when { + loaded == null -> Box( + modifier = Modifier + .weight(1f) + .hazeSource(hazeState), + contentAlignment = Alignment.Center, + ) { + ChatMessagesInlineLoadingIndicator(zh = zh) + } + loaded.isFailure -> Box( + modifier = Modifier + .weight(1f) + .hazeSource(hazeState), + contentAlignment = Alignment.Center, + ) { + MulticaErrorState( + message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", + onRetry = { refresh++ }, + ) + } + else -> LazyColumn( + modifier = Modifier + .weight(1f) + .hazeSource(hazeState), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val messages = loaded.getOrThrow().messages + val activePending = loaded.getOrThrow().pendingTask + if (sending || locallyPending || validChatTaskId(activePending.taskId)) { + item { + ChatPendingTimelineCard( + api = api, + workspaceId = workspaceId, + taskId = activePending.taskId, + status = activePending.status.ifBlank { if (sending || locallyPending) "queued" else "" }, + zh = zh, + ) + } + } + if (messages.isEmpty()) { + item { + ChatWindowWebEmptyState( + zh = zh, + onPromptClick = { prompt -> draft = prompt }, + ) + } + } + items(messages) { message -> + ChatMessageBubble(message = message, zh = zh) + if (validChatTaskId(message.taskId)) { + ChatTaskTimeline( + api = api, + workspaceId = workspaceId, + taskId = message.taskId, + zh = zh, + ) + } + } + } + } + PilotGlassBottomSlot(hazeState) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Chat Glass Bottom Composer" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!sendError.isNullOrBlank()) { + PilotInlineResultState( + message = sendError.orEmpty(), + isError = true, + contentDescription = "Chat Send Result Web Banner", + modifier = Modifier.padding(horizontal = 18.dp), + ) + } + // Chat Cupertino Composer: focus-within-style composer equivalent from Web chat-input.tsx. + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 10.dp), + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Surface.copy(alpha = 0.94f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.86f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom, + ) { + MulticaTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...", + contentDescription = "Chat Message Input", + minLines = 1, + maxLines = 4, + ) + MulticaIconPillButton( + icon = Icons.AutoMirrored.Outlined.Send, + contentDescription = if (zh) "发送消息" else "Send Message", + enabled = !sending && draft.trim().isNotEmpty(), + onClick = { + val content = draft.trim() + if (content.isEmpty() || sending) return@MulticaIconPillButton + sending = true + locallyPending = true + sendError = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.sendChatMessage(workspaceId, session.id, content) } + } + sending = false + result.onSuccess { + draft = "" + refresh++ + }.onFailure { + locallyPending = false + sendError = "${if (zh) "发送失败" else "Send failed"}: ${it.message ?: it.toString()}" + } + } + }, + tone = if (!sending && draft.trim().isNotEmpty()) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + ) + } + } + } + } + } +} + +@Composable +private fun ChatWindowWebEmptyState( + zh: Boolean, + onPromptClick: (String) -> Unit, +) { + val WEB_SSOT_CHAT_WINDOW = "/Users/park0er/coding/multica-web-source/packages/views/chat/components/chat-window.tsx" + val listOpenPrompt = if (zh) "按优先级列出我未完成的任务" else "List my open tasks by priority" + val summarizeTodayPrompt = if (zh) "总结一下我今天做了什么" else "Summarize what I did today" + val planNextPrompt = if (zh) "规划接下来该做什么" else "Plan what to work on next" + val prompts = listOf(listOpenPrompt, summarizeTodayPrompt, planNextPrompt) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .semantics { + contentDescription = "Chat Window Web Empty State $WEB_SSOT_CHAT_WINDOW" + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MulticaEmptyState( + title = if (zh) "欢迎使用 Multica" else "Welcome to Multica", + description = if (zh) "试试问" else "Try asking", + ) + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 340.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + prompts.forEach { prompt -> + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onPromptClick(prompt) } + .semantics { + contentDescription = "Chat Empty Starter Prompt $prompt" + }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Surface, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.72f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = prompt, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp, lineHeight = 18.sp), + color = MulticaColors.Text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun ChatMessageBubble( + message: Models.ChatMessage, + zh: Boolean, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + var moreOpen by remember(message.id) { mutableStateOf(false) } + val normalizedRole = clean(message.role).lowercase() + val isUser = normalizedRole == "user" + val failure = clean(message.failureReason) + val meta = if (failure.isNotBlank()) { + failure + } else { + listOf(roleLabel(message.role, zh), shortDate(message.createdAt)) + .filter { it.isNotBlank() } + .joinToString(" · ") + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.84f) + .semantics { contentDescription = "Chat Message Bubble ${if (isUser) "user" else "assistant"}" }, + shape = RoundedCornerShape(12.dp), + color = if (isUser) MulticaColors.Accent.copy(alpha = 0.14f) else MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .border( + 1.dp, + if (isUser) MulticaColors.Accent.copy(alpha = 0.20f) else MulticaColors.Border, + RoundedCornerShape(12.dp), + ) + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Box(modifier = Modifier.weight(1f)) { + MarkdownBlock(message.content) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Chat Message More Menu", + onClick = { moreOpen = true }, + tone = MulticaButtonTone.Ghost, + ) + MulticaCupertinoActionSheet( + visible = moreOpen, + title = if (zh) "消息操作" else "Message actions", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "复制" else "Copy", + icon = Icons.Outlined.ContentCopy, + contentDescription = "Chat Message Cupertino Action Sheet Copy", + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Multica chat message", message.content)) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { moreOpen = false }, + ) + } + if (meta.isNotBlank()) { + Text( + text = meta, + style = MaterialTheme.typography.labelSmall, + color = if (failure.isNotBlank()) MulticaColors.Danger else MulticaColors.Muted, + textAlign = if (isUser) TextAlign.End else TextAlign.Start, + ) + } + } + } + } +} + +@Composable +private fun ChatMessagesInlineLoadingIndicator(zh: Boolean) { + Row( + modifier = Modifier + .semantics { contentDescription = "Chat Messages Inline Loading Indicator Multica Cupertino Activity Indicator" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CupertinoActivityIndicator(modifier = Modifier.size(18.dp)) + Text( + text = if (zh) "加载消息" else "Loading messages", + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + ) + } +} + +@Composable +private fun ChatPendingTimelineCard( + api: ApiClient, + workspaceId: String, + taskId: String, + status: String, + zh: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Pending Timeline" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatTaskWebLiveBanner( + title = if (zh) "Agent 正在处理" else "Agent is running", + subtitle = listOf(status.ifBlank { if (zh) "等待任务创建" else "Waiting for task" }, Models.shortId(taskId)) + .filter { it.isNotBlank() } + .joinToString(" · "), + active = true, + ) + if (taskId.isNotBlank()) { + ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh) + } + } +} + +@Composable +private fun ChatTaskTimeline( + api: ApiClient, + workspaceId: String, + taskId: String, + zh: Boolean, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Task Timeline $taskId" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatTaskWebLiveBanner( + title = if (zh) "Agent 执行过程" else "Agent timeline", + subtitle = Models.shortId(taskId), + active = false, + ) + ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh) + } +} + +@Composable +private fun ChatTaskTimelineRows( + api: ApiClient, + workspaceId: String, + taskId: String, + zh: Boolean, +) { + var state by remember(taskId, workspaceId) { mutableStateOf>?>(null) } + LaunchedEffect(taskId, workspaceId) { + state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } } + } + when (val loaded = state) { + null -> ChatTaskWebEmptyRow( + title = if (zh) "加载执行过程..." else "Loading timeline...", + subtitle = if (zh) "正在拉取 Agent transcript" else "Fetching the agent transcript", + ) + else -> loaded.fold( + onSuccess = { messages -> + if (messages.isEmpty()) { + ChatTaskWebEmptyRow( + title = if (zh) "暂无执行消息" else "No task messages yet", + subtitle = if (zh) "Agent 开始输出后会显示在这里" else "Thinking, tools, output, and errors will appear here", + ) + } else { + messages.take(8).forEachIndexed { index, message -> + ChatTaskWebTimelineRow( + message = message, + zh = zh, + showDivider = index < messages.take(8).lastIndex || messages.size > 8, + ) + } + if (messages.size > 8) { + ChatTaskWebEmptyRow( + title = if (zh) "还有 ${messages.size - 8} 条执行消息" else "${messages.size - 8} more task messages", + subtitle = null, + ) + } + } + }, + onFailure = { + ChatTaskWebErrorRow( + title = if (zh) "执行过程加载失败" else "Timeline failed", + subtitle = it.message ?: it.toString(), + ) + }, + ) + } +} + +private fun validChatTaskId(taskId: String?): Boolean { + val value = clean(taskId) + return value.isNotBlank() && !value.equals("null", ignoreCase = true) +} + +@Composable +private fun ChatTaskWebLiveBanner( + title: String, + subtitle: String, + active: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Task Web Live Banner" }, + shape = RoundedCornerShape(12.dp), + color = if (active) MulticaColors.Accent.copy(alpha = 0.08f) else MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .border( + 1.dp, + if (active) MulticaColors.Accent.copy(alpha = 0.18f) else MulticaColors.Border, + RoundedCornerShape(12.dp), + ) + .padding(horizontal = 11.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Surface( + modifier = Modifier.size(24.dp), + shape = RoundedCornerShape(8.dp), + color = if (active) MulticaColors.Accent.copy(alpha = 0.14f) else MulticaColors.SurfaceElevated, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = if (active) MulticaColors.Accent else MulticaColors.Muted, + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun ChatTaskWebTimelineRow( + message: Models.TaskMessage, + zh: Boolean, + showDivider: Boolean, +) { + val tone = taskMessageTone(message) + val accentColor = when (tone) { + "danger" -> MulticaColors.Danger + "accent" -> MulticaColors.Accent + else -> MulticaColors.Muted + } + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Chat Task Web Timeline Row ${message.id}" }, + ) { + Row( + modifier = Modifier.padding(horizontal = 2.dp, vertical = 8.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Surface( + modifier = Modifier.size(22.dp), + shape = RoundedCornerShape(7.dp), + color = accentColor.copy(alpha = 0.11f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = "#${message.seq}", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, lineHeight = 11.sp, fontWeight = FontWeight.SemiBold), + color = accentColor, + textAlign = TextAlign.Center, + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = taskMessageKindLabel(message, zh), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val tool = clean(message.tool) + if (tool.isNotBlank()) { + Text( + text = tool, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 15.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + preview(messageSummary(message))?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 17.sp), + color = if (tone == "danger") MulticaColors.Danger else MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (showDivider) { + Box( + modifier = Modifier + .padding(start = 33.dp) + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border.copy(alpha = 0.65f)), + ) + } + } +} + +@Composable +private fun ChatTaskWebEmptyRow( + title: String, + subtitle: String?, +) { + ChatTaskWebStateRow( + contentDescription = "Chat Task Web Empty Row", + title = title, + subtitle = subtitle, + tone = "muted", + ) +} + +@Composable +private fun ChatTaskWebErrorRow( + title: String, + subtitle: String?, +) { + ChatTaskWebStateRow( + contentDescription = "Chat Task Web Error Row", + title = title, + subtitle = subtitle, + tone = "danger", + ) +} + +@Composable +private fun ChatTaskWebStateRow( + contentDescription: String, + title: String, + subtitle: String?, + tone: String, +) { + val accentColor = if (tone == "danger") MulticaColors.Danger else MulticaColors.Muted + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .border(1.dp, MulticaColors.Border, RoundedCornerShape(10.dp)) + .padding(horizontal = 11.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (tone == "danger") Icons.Outlined.Close else Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = accentColor, + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium), + color = if (tone == "danger") MulticaColors.Danger else MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +private fun taskMessageTitle(message: Models.TaskMessage, zh: Boolean): String { + val tool = clean(message.tool) + return listOf("#${message.seq}", taskMessageKindLabel(message, zh), tool).filter { it.isNotBlank() }.joinToString(" · ") +} + +private fun taskMessageKindLabel(message: Models.TaskMessage, zh: Boolean): String { + val type = clean(message.type).lowercase() + return when { + type.contains("tool") && type.contains("result") -> if (zh) "工具结果" else "Tool result" + type.contains("tool") -> if (zh) "工具调用" else "Tool call" + type.contains("thinking") -> if (zh) "思考" else "Thinking" + type.contains("error") -> if (zh) "错误" else "Error" + type.contains("text") || type.contains("message") -> if (zh) "输出" else "Output" + else -> clean(message.type).ifBlank { if (zh) "执行消息" else "Task message" } + } +} + +private fun taskMessageTone(message: Models.TaskMessage): String { + val type = clean(message.type).lowercase() + return when { + type.contains("error") -> "danger" + type.contains("tool") || type.contains("thinking") -> "accent" + else -> "muted" + } +} + +private enum class IssueSortOption { + Position, + Number, + Priority, + Updated, + Created, + Title, +} + +private enum class IssueAssigneeScope { + All, + Members, + Agents, +} + +private enum class MyIssueScope { + Assigned, + Created, + Agents, +} + +private val ISSUE_SORT_OPTIONS = listOf( + IssueSortOption.Position, + IssueSortOption.Number, + IssueSortOption.Priority, + IssueSortOption.Updated, + IssueSortOption.Created, + IssueSortOption.Title, +) + +private fun issueSortLabel(option: IssueSortOption, zh: Boolean): String = when (option) { + IssueSortOption.Position -> if (zh) "默认" else "Default" + IssueSortOption.Number -> if (zh) "编号" else "Number" + IssueSortOption.Priority -> if (zh) "优先级" else "Priority" + IssueSortOption.Updated -> if (zh) "更新时间" else "Updated" + IssueSortOption.Created -> if (zh) "创建时间" else "Created" + IssueSortOption.Title -> if (zh) "标题" else "Title" +} + +private fun issuePriorityRank(priority: String?): Int = when (priority) { + "urgent" -> 0 + "high" -> 1 + "medium" -> 2 + "low" -> 3 + "none", "no_priority" -> 4 + else -> 5 +} + +@Composable +private fun IssueWebFilterMenuPill( + text: String, + icon: ImageVector, + selected: Boolean, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + val shape = RoundedCornerShape(10.dp) + Surface( + modifier = modifier + .heightIn(min = 32.dp) + .clip(shape) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + .semantics(mergeDescendants = true) { + this.contentDescription = "$contentDescription Issue Web Filter Menu Pill" + }, + shape = shape, + color = if (selected) MulticaColors.AccentSoft else MulticaColors.SurfaceElevated, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = if (selected) MulticaColors.Accent.copy(alpha = 0.28f) else MulticaColors.Border, + shape = shape, + ) + .padding(horizontal = 9.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (selected) MulticaColors.Accent else MulticaColors.Muted, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (selected) MulticaColors.Accent else MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { this.contentDescription = "Issue Web Filter Menu Text" }, + ) + } + } +} + +@Composable +private fun SettingsWebFilterPill( + text: String, + selected: Boolean, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + val shape = RoundedCornerShape(10.dp) + Surface( + modifier = modifier + .heightIn(min = 32.dp) + .clip(shape) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + .semantics(mergeDescendants = true) { + this.contentDescription = "$contentDescription Settings Web Filter Pill" + }, + shape = shape, + color = if (selected) MulticaColors.AccentSoft else MulticaColors.SurfaceElevated, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (selected) MulticaColors.Accent else MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .border( + width = 1.dp, + color = if (selected) MulticaColors.Accent.copy(alpha = 0.26f) else MulticaColors.Border, + shape = shape, + ) + .padding(horizontal = 10.dp, vertical = 6.dp) + .semantics { this.contentDescription = "Settings Web Filter Text" }, + ) + } +} + +private fun issueStatusIcon(status: String?): ImageVector = when (status) { + "todo" -> Icons.Outlined.RadioButtonUnchecked + "in_progress" -> Icons.Outlined.Bolt + "in_review" -> Icons.Outlined.Flag + "done" -> Icons.Outlined.CheckCircle + "blocked", "cancelled" -> Icons.Outlined.Delete + else -> Icons.Outlined.Archive +} + +private fun sortIssues( + issues: List, + option: IssueSortOption, + descending: Boolean, +): List { + val sorted = when (option) { + IssueSortOption.Position -> issues.sortedWith( + compareBy { + Models.STATUS_VALUES.indexOf(it.status).let { index -> if (index >= 0) index else Int.MAX_VALUE } + }.thenBy { it.number } + ) + IssueSortOption.Number -> issues.sortedWith(compareBy { it.number }.thenByDescending { clean(it.updatedAt) }) + IssueSortOption.Priority -> issues.sortedWith(compareBy { issuePriorityRank(it.priority) }.thenByDescending { clean(it.updatedAt) }) + IssueSortOption.Updated -> issues.sortedBy { clean(it.updatedAt) } + IssueSortOption.Created -> issues.sortedBy { clean(it.createdAt) } + IssueSortOption.Title -> issues.sortedBy { clean(it.title).lowercase() } + } + return if (descending) sorted.asReversed() else sorted +} + +@Composable +private fun IssueSortMenuChip( + sortOption: IssueSortOption, + zh: Boolean, + onSortOptionChange: (IssueSortOption) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + IssueWebFilterMenuPill( + text = "${if (zh) "排序" else "Sort"}: ${issueSortLabel(sortOption, zh)}", + icon = Icons.Outlined.Sort, + selected = sortOption != IssueSortOption.Position, + contentDescription = "IssueSortMenu", + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "排序" else "Sort issues", + items = ISSUE_SORT_OPTIONS.map { option -> + MulticaCupertinoActionSheetItem( + title = issueSortLabel(option, zh), + icon = Icons.Outlined.Sort, + selected = option == sortOption, + contentDescription = "Issue Sort Cupertino Action Sheet ${issueSortLabel(option, zh)}", + onClick = { + onSortOptionChange(option) + }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueDirectionMenuChip( + descending: Boolean, + zh: Boolean, + onDirectionChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + IssueWebFilterMenuPill( + text = if (descending) if (zh) "降序" else "Descending" else if (zh) "升序" else "Ascending", + icon = if (descending) Icons.Outlined.ArrowDownward else Icons.Outlined.ArrowUpward, + selected = descending, + contentDescription = "IssueSortDirectionMenu", + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "排序方向" else "Sort direction", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "升序" else "Ascending", + icon = Icons.Outlined.ArrowUpward, + selected = !descending, + contentDescription = "Issue Sort Cupertino Action Sheet Ascending", + onClick = { onDirectionChange(false) }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "降序" else "Descending", + icon = Icons.Outlined.ArrowDownward, + selected = descending, + contentDescription = "Issue Sort Cupertino Action Sheet Descending", + onClick = { onDirectionChange(true) }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueClearFiltersChip( + enabled: Boolean, + zh: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + MulticaIconPillButton( + icon = Icons.Outlined.Tune, + contentDescription = "IssueClearFilters", + enabled = enabled, + onClick = onClick, + modifier = modifier.size(44.dp), + tone = if (enabled) MulticaButtonTone.Secondary else MulticaButtonTone.Ghost, + ) +} + +@Composable +private fun IssueToolbarIconPill( + icon: ImageVector, + contentDescription: String, + selected: Boolean = false, + onClick: () -> Unit, +) { + MulticaIconPillButton( + icon = icon, + contentDescription = contentDescription, + onClick = onClick, + tone = if (selected) MulticaButtonTone.Secondary else MulticaButtonTone.Ghost, + ) +} + +@Composable +private fun IssueSortIconMenu( + sortOption: IssueSortOption, + descending: Boolean, + zh: Boolean, + onSortOptionChange: (IssueSortOption) -> Unit, + onDirectionChange: (Boolean) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IssueToolbarIconPill( + icon = Icons.Outlined.Sort, + contentDescription = "IssueSortMenu", + selected = sortOption != IssueSortOption.Position || descending, + onClick = { expanded = true }, + ) + val directionTitle = if (descending) { + if (zh) "切换为升序" else "Switch to ascending" + } else { + if (zh) "切换为降序" else "Switch to descending" + } + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "排序" else "Sort issues", + items = ISSUE_SORT_OPTIONS.map { option -> + MulticaCupertinoActionSheetItem( + title = issueSortLabel(option, zh), + icon = Icons.Outlined.Sort, + selected = option == sortOption, + contentDescription = "Issue Sort Cupertino Action Sheet ${issueSortLabel(option, zh)}", + onClick = { onSortOptionChange(option) }, + ) + } + MulticaCupertinoActionSheetItem( + title = directionTitle, + icon = if (descending) Icons.Outlined.ArrowUpward else Icons.Outlined.ArrowDownward, + contentDescription = "Issue Sort Cupertino Action Sheet Direction", + onClick = { + onDirectionChange(!descending) + }, + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueCreateMenuButton( + zh: Boolean, + onManualCreate: () -> Unit, + onAgentQuickCreate: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IssueToolbarIconPill( + icon = Icons.Outlined.Edit, + contentDescription = if (zh) "新建 Issue" else "Create issue", + selected = true, + onClick = { expanded = true }, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "新建 Issue" else "Create issue", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "人工创建 Issue" else "Manual issue", + icon = Icons.Outlined.Edit, + contentDescription = "Issue Create Cupertino Action Sheet Manual", + onClick = { + onManualCreate() + }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "Agent 快速创建" else "Agent quick create", + icon = Icons.Outlined.Bolt, + contentDescription = "Issue Create Cupertino Action Sheet Agent Quick Create", + onClick = { + onAgentQuickCreate() + }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssuePinnedToolbar( + selectionActive: Boolean, + filtersActive: Boolean, + sortOption: IssueSortOption, + descending: Boolean, + boardMode: Boolean, + zh: Boolean, + onToggleSelection: () -> Unit, + onToggleFilters: () -> Unit, + onSortOptionChange: (IssueSortOption) -> Unit, + onDirectionChange: (Boolean) -> Unit, + onToggleBoardMode: () -> Unit, + onManualCreate: () -> Unit, + onAgentQuickCreate: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MulticaColors.Background) + .padding(horizontal = 26.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IssueToolbarIconPill( + icon = if (selectionActive) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = "Issue Selection Toggle", + selected = selectionActive, + onClick = onToggleSelection, + ) + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + IssueToolbarIconPill( + icon = Icons.Outlined.Tune, + contentDescription = "IssueFilterToggle", + selected = filtersActive, + onClick = onToggleFilters, + ) + Spacer(modifier = Modifier.width(8.dp)) + IssueSortIconMenu( + sortOption = sortOption, + descending = descending, + zh = zh, + onSortOptionChange = onSortOptionChange, + onDirectionChange = onDirectionChange, + ) + Spacer(modifier = Modifier.width(8.dp)) + IssueToolbarIconPill( + icon = if (boardMode) Icons.Outlined.ViewList else Icons.Outlined.Dashboard, + contentDescription = if (boardMode) { + if (zh) "切换到列表" else "Switch to list" + } else { + if (zh) "切换到看板" else "Switch to board" + }, + selected = boardMode, + onClick = onToggleBoardMode, + ) + Spacer(modifier = Modifier.width(8.dp)) + IssueCreateMenuButton( + zh = zh, + onManualCreate = onManualCreate, + onAgentQuickCreate = onAgentQuickCreate, + ) + } + } +} + +@Composable +private fun IssueWebContextCaption( + workspaceName: String, + personal: Boolean, + zh: Boolean, +) { + val title = if (personal) { + if (zh) "我的 Issues" else "My Issues" + } else { + if (zh) "Issues" else "Issues" + } + Text( + text = "${workspaceName.ifBlank { if (zh) "工作区" else "Workspace" }} › $title", + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = if (personal) { + "Issue Web Context Caption My Issues Web Personal Queue Caption" + } else { + "Issue Web Context Caption" + } + }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun IssueScopeSegmentedControl( + selected: IssueAssigneeScope, + zh: Boolean, + onSelected: (IssueAssigneeScope) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.SurfaceElevated.copy(alpha = 0.72f)) + .border(0.5.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(999.dp)) + .padding(3.dp) + .semantics { contentDescription = "Issue Scope Cupertino Segmented Control" }, + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf(IssueAssigneeScope.All, IssueAssigneeScope.Members, IssueAssigneeScope.Agents).forEach { scope -> + val active = selected == scope + val label = when (scope) { + IssueAssigneeScope.All -> if (zh) "全部" else "All" + IssueAssigneeScope.Members -> if (zh) "人类成员" else "Members" + IssueAssigneeScope.Agents -> if (zh) "Agents" else "Agents" + } + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(999.dp)) + .background(if (active) MulticaColors.Background else androidx.compose.ui.graphics.Color.Transparent) + .clickable { onSelected(scope) } + .semantics { contentDescription = "Issue Scope Segment $label" } + .padding(horizontal = 8.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, + ), + color = if (active) MulticaColors.Text else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun MyIssuesScopeSegmentedControl( + selected: MyIssueScope, + zh: Boolean, + onSelected: (MyIssueScope) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.SurfaceElevated.copy(alpha = 0.72f)) + .border(0.5.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(999.dp)) + .padding(3.dp) + .semantics { contentDescription = "My Issues Scope Cupertino Segmented Control" }, + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf(MyIssueScope.Assigned, MyIssueScope.Created, MyIssueScope.Agents).forEach { scope -> + val active = selected == scope + val label = when (scope) { + MyIssueScope.Assigned -> if (zh) "已分配" else "Assigned" + MyIssueScope.Created -> if (zh) "我创建的" else "Created" + MyIssueScope.Agents -> if (zh) "我的智能体" else "My Agents" + } + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(999.dp)) + .background(if (active) MulticaColors.Background else androidx.compose.ui.graphics.Color.Transparent) + .clickable { onSelected(scope) } + .semantics { contentDescription = "My Issues Scope Segment $label" } + .padding(horizontal = 8.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, + ), + color = if (active) MulticaColors.Text else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun IssuePriorityFilterMenuPill( + priority: String?, + zh: Boolean, + onPriorityChange: (String?) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + IssueWebFilterMenuPill( + text = "${if (zh) "优先级" else "Priority"}: ${priority?.let { Models.priorityLabel(it, zh) } ?: if (zh) "全部" else "All"}", + icon = Icons.Outlined.Flag, + selected = priority != null, + contentDescription = "IssuePriorityFilterMenu", + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "优先级" else "Priority", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "全部优先级" else "All priorities", + icon = Icons.Outlined.Flag, + selected = priority == null, + contentDescription = "Issue Priority Cupertino Action Sheet All", + onClick = { onPriorityChange(null) }, + ), + ) + Models.PRIORITY_VALUES.map { value -> + MulticaCupertinoActionSheetItem( + title = Models.priorityLabel(value, zh), + icon = Icons.Outlined.Flag, + selected = priority == value, + contentDescription = "Issue Priority Cupertino Action Sheet ${Models.priorityLabel(value, zh)}", + onClick = { onPriorityChange(value) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueStatusFilterMenuPill( + status: String?, + zh: Boolean, + onStatusChange: (String?) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + IssueWebFilterMenuPill( + text = "${if (zh) "状态" else "Status"}: ${status?.let { Models.statusLabel(it, zh) } ?: if (zh) "全部" else "All"}", + icon = status?.let { issueStatusIcon(it) } ?: Icons.Outlined.RadioButtonUnchecked, + selected = status != null, + contentDescription = "IssueStatusFilterMenu", + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "状态" else "Status", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "全部状态" else "All statuses", + icon = Icons.Outlined.RadioButtonUnchecked, + selected = status == null, + contentDescription = "Issue Status Cupertino Action Sheet All", + onClick = { onStatusChange(null) }, + ), + ) + Models.STATUS_VALUES.map { value -> + MulticaCupertinoActionSheetItem( + title = Models.statusLabel(value, zh), + icon = issueStatusIcon(value), + selected = status == value, + contentDescription = "Issue Status Cupertino Action Sheet ${Models.statusLabel(value, zh)}", + onClick = { onStatusChange(value) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueMaterialListRow( + issue: Models.Issue, + metadata: String, + childProgressMetadata: String, + labelsMetadata: String, + projectMetadata: String, + dueDateMetadata: String, + zh: Boolean, + onClick: () -> Unit, + rowSelectionDescription: String? = null, + onRowSelectionClick: (() -> Unit)? = null, + trailing: @Composable () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = false) { + if (rowSelectionDescription != null) { + contentDescription = rowSelectionDescription + if (onRowSelectionClick != null) { + semanticsOnClick(label = rowSelectionDescription) { + onRowSelectionClick() + true + } + } + } + }, + color = MulticaColors.Background, + tonalElevation = 0.dp, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Web Dense Row ${issue.identifier}" } + .padding(vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = issueStatusIcon(issue.status), + contentDescription = null, + tint = when (issue.status) { + "done" -> MulticaColors.Success + "in_progress" -> MulticaColors.Accent + "in_review" -> MulticaColors.Warning + "blocked", "cancelled" -> MulticaColors.Danger + else -> MulticaColors.Muted + }, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = issue.identifier, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(66.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = issue.title, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 19.sp, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val rowMetadata = listOf( + Models.priorityLabel(issue.priority, zh), + metadata, + childProgressMetadata, + labelsMetadata, + projectMetadata, + dueDateMetadata, + ).filter { it.isNotBlank() } + Text( + text = rowMetadata.joinToString(" · "), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = if (issue.priority == "urgent" || issue.priority == "high") { + MulticaColors.Warning.copy(alpha = 0.92f) + } else { + MulticaColors.Muted.copy(alpha = 0.72f) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { + contentDescription = listOf( + "Issue Child Progress Metadata $childProgressMetadata", + "Issue Labels Metadata $labelsMetadata", + "Issue Project Metadata $projectMetadata", + "Issue Due Date Metadata $dueDateMetadata", + ).joinToString(" ") + }, + ) + } + trailing() + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border.copy(alpha = 0.74f)) + ) + } + } +} + +@Composable +private fun IssueStatusGroupHeader( + status: String, + count: Int, + collapsed: Boolean, + zh: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 18.dp, bottom = 6.dp) + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = "IssueStatusSectionHeader-$status" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (collapsed) Icons.Outlined.KeyboardArrowRight else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + Icon( + imageVector = issueStatusIcon(status), + contentDescription = null, + tint = when (status) { + "done" -> MulticaColors.Success + "in_progress" -> MulticaColors.Accent + "in_review" -> MulticaColors.Warning + "blocked", "cancelled" -> MulticaColors.Danger + else -> MulticaColors.Muted + }, + modifier = Modifier.size(20.dp), + ) + Text( + text = "${Models.statusLabel(status, zh)} ($count)", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun IssueListSummaryPanel( + total: Int, + visible: Int, + groupedCount: Int, + activeFilters: Int, + personal: Boolean, + zh: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (personal) { + if (zh) "我的 Issues" else "My Issues" + } else { + if (zh) "Issues" else "Issues" + }, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IssueSummaryStatChip(if (zh) "$visible/$total" else "$visible/$total") + IssueSummaryStatChip(if (zh) "$groupedCount 组" else "$groupedCount groups") + if (activeFilters > 0) { + IssueSummaryStatChip(if (zh) "$activeFilters 筛选" else "$activeFilters filters") + } + } +} + +@Composable +private fun IssueSummaryStatChip(text: String) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp), + ) + } +} + +@Composable +private fun IssueListControls( + query: String, + onQueryChange: (String) -> Unit, + sortOption: IssueSortOption, + descending: Boolean, + priorityFilter: String?, + statusFilter: String?, + selectedCount: Int, + zh: Boolean, + onSortOptionChange: (IssueSortOption) -> Unit, + onDirectionChange: (Boolean) -> Unit, + onPriorityChange: (String?) -> Unit, + onStatusChange: (String?) -> Unit, + onClear: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + MulticaTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue List Web Search Field" }, + label = if (zh) "搜索 Issues" else "Search issues", + contentDescription = "Issue List Search", + singleLine = true, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IssueSortMenuChip( + sortOption = sortOption, + zh = zh, + onSortOptionChange = onSortOptionChange, + modifier = Modifier.weight(1f), + ) + IssueDirectionMenuChip( + descending = descending, + zh = zh, + onDirectionChange = onDirectionChange, + modifier = Modifier.weight(1f), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IssuePriorityFilterMenuPill( + priority = priorityFilter, + zh = zh, + onPriorityChange = onPriorityChange, + modifier = Modifier.weight(1f), + ) + IssueStatusFilterMenuPill( + status = statusFilter, + zh = zh, + onStatusChange = onStatusChange, + modifier = Modifier.weight(1f), + ) + if (selectedCount > 0 || query.isNotBlank() || priorityFilter != null || statusFilter != null || sortOption != IssueSortOption.Position || descending) { + IssueClearFiltersChip( + enabled = true, + zh = zh, + onClick = onClear, + ) + } + } + if (selectedCount > 0 || query.isNotBlank() || priorityFilter != null || statusFilter != null || sortOption != IssueSortOption.Position || descending) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (selectedCount > 0) { + if (zh) "已选择 $selectedCount 个 Issue" else "$selectedCount selected" + } else { + if (zh) "已应用筛选" else "Filters applied" + }, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun IssueBatchActionBar( + selectedCount: Int, + members: List, + agents: List, + batchMutating: Boolean, + confirmingDelete: Boolean, + zh: Boolean, + onStatus: (String) -> Unit, + onPriority: (String) -> Unit, + onAssignee: (String, String) -> Unit, + onClear: () -> Unit, + onDelete: () -> Unit, + onCancelDelete: () -> Unit, + onConfirmDelete: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 174.dp) + .semantics { contentDescription = "Issue Batch Haze Bottom Action Bar Web BatchActionToolbar" } + .border(1.dp, MulticaColors.Border, RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)), + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + color = MulticaColors.Surface, + tonalElevation = 3.dp, + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaIconPillButton( + icon = Icons.Outlined.Close, + contentDescription = if (zh) "清除选择" else "Clear Selection", + onClick = onClear, + tone = MulticaButtonTone.Ghost, + ) + Text( + text = if (zh) "$selectedCount 个" else "$selectedCount selected", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + modifier = Modifier.widthIn(min = 62.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (confirmingDelete) { + Text( + text = if (zh) "删除已选 Issue?" else "Delete selected issues?", + style = MaterialTheme.typography.bodyMedium, + color = MulticaColors.Muted, + modifier = Modifier.weight(1f), + ) + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancelDelete, + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (batchMutating) "..." else if (zh) "确认" else "Confirm", + onClick = onConfirmDelete, + tone = MulticaButtonTone.Destructive, + ) + } else { + IssueBatchStatusMenu(zh = zh, enabled = !batchMutating, onStatus = onStatus) + IssueBatchPriorityMenu(zh = zh, enabled = !batchMutating, onPriority = onPriority) + IssueBatchAssigneeMenu( + members = members, + agents = agents, + zh = zh, + enabled = !batchMutating, + onAssignee = onAssignee, + modifier = Modifier.weight(1f), + ) + MulticaIconPillButton( + icon = Icons.Outlined.Delete, + contentDescription = if (zh) "删除所选" else "Delete Selected", + onClick = onDelete, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun IssueBatchStatusMenu( + zh: Boolean, + enabled: Boolean, + onStatus: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + MulticaPillButton( + text = if (zh) "状态" else "Status", + icon = Icons.Outlined.RadioButtonUnchecked, + enabled = enabled, + onClick = { expanded = true }, + tone = MulticaButtonTone.Secondary, + contentDescription = "IssueBatchStatusMenu", + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "批量修改状态" else "Batch status", + items = Models.STATUS_VALUES.map { value -> + MulticaCupertinoActionSheetItem( + title = Models.statusLabel(value, zh), + icon = issueStatusIcon(value), + contentDescription = "Issue Batch Status ${Models.statusLabel(value, false)}", + onClick = { onStatus(value) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueBatchPriorityMenu( + zh: Boolean, + enabled: Boolean, + onPriority: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + MulticaPillButton( + text = if (zh) "优先级" else "Priority", + icon = Icons.Outlined.Flag, + enabled = enabled, + onClick = { expanded = true }, + tone = MulticaButtonTone.Secondary, + contentDescription = "IssueBatchPriorityMenu", + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "批量修改优先级" else "Batch priority", + items = Models.PRIORITY_VALUES.map { value -> + MulticaCupertinoActionSheetItem( + title = Models.priorityLabel(value, zh), + icon = Icons.Outlined.Flag, + contentDescription = "Issue Batch Priority ${Models.priorityLabel(value, false)}", + onClick = { onPriority(value) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueBatchAssigneeMenu( + members: List, + agents: List, + zh: Boolean, + enabled: Boolean, + onAssignee: (String, String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val assigneeItems = remember(members, agents, zh) { + buildList { + if (members.isEmpty() && agents.isEmpty()) { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "暂无负责人选项" else "No assignee options", + icon = Icons.Outlined.Person, + contentDescription = "Issue Batch Assignee Empty", + enabled = false, + onClick = {}, + ) + ) + } + members.forEach { member -> + val name = member.name.ifBlank { member.email.ifBlank { Models.shortId(member.userId) } } + add( + MulticaCupertinoActionSheetItem( + title = name, + subtitle = if (zh) "成员" else "Member", + icon = Icons.Outlined.Person, + contentDescription = "Issue Batch Assignee Member $name", + onClick = { onAssignee("member", member.userId) }, + ) + ) + } + agents.forEach { agent -> + val name = agent.name.ifBlank { Models.shortId(agent.id) } + add( + MulticaCupertinoActionSheetItem( + title = name, + subtitle = if (zh) "Agent" else "Agent", + icon = Icons.Outlined.Bolt, + contentDescription = "Issue Batch Assignee Agent $name", + onClick = { onAssignee("agent", agent.id) }, + ) + ) + } + } + } + Box(modifier = modifier) { + MulticaPillButton( + text = if (zh) "负责人" else "Assignee", + icon = Icons.Outlined.Person, + enabled = enabled, + onClick = { expanded = true }, + tone = MulticaButtonTone.Secondary, + contentDescription = "IssueBatchAssigneeMenu", + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "批量分配负责人" else "Batch assignee", + items = assigneeItems, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun PilotIssues( + api: ApiClient, + workspaceId: String, + workspaceName: String, + issues: List, + currentUser: Models.User, + personal: Boolean, + loading: Boolean, + selectedIds: Set, + zh: Boolean, + onSelectedIdsChange: (Set) -> Unit, + onIssueClick: (String) -> Unit, + onNewIssueClick: () -> Unit, + onRefresh: () -> Unit, +) { + val WEB_SSOT_MY_ISSUES_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/my-issues/components/my-issues-page.tsx" + val WEB_SSOT_MY_ISSUES_HEADER = "/Users/park0er/coding/multica-web-source/packages/views/my-issues/components/my-issues-header.tsx" + var boardMode by remember(personal) { mutableStateOf(false) } + var sortOption by remember(personal) { mutableStateOf(IssueSortOption.Position) } + var descending by remember(personal) { mutableStateOf(false) } + var assigneeScope by remember(personal, workspaceId) { mutableStateOf(IssueAssigneeScope.All) } + var myIssueScope by remember(personal, workspaceId) { mutableStateOf(MyIssueScope.Assigned) } + var personalScopedIssues by remember(personal, workspaceId) { mutableStateOf?>(null) } + var personalScopeLoading by remember(personal, workspaceId) { mutableStateOf(false) } + var personalScopeError by remember(personal, workspaceId) { mutableStateOf(null) } + var priorityFilter by remember(personal, workspaceId) { mutableStateOf(null) } + var statusFilter by remember(personal, workspaceId) { mutableStateOf(null) } + var collapsedStatusSections by remember(personal, workspaceId) { mutableStateOf>(emptySet()) } + var issueQuery by remember(personal, workspaceId) { mutableStateOf("") } + var selectionModeEnabled by remember(personal, workspaceId) { mutableStateOf(false) } + var showFilterPanel by remember(personal, workspaceId) { mutableStateOf(false) } + var batchMutating by remember(personal, workspaceId) { mutableStateOf(false) } + var batchMessage by remember(personal, workspaceId) { mutableStateOf(null) } + var confirmBatchDelete by remember(personal, workspaceId) { mutableStateOf(false) } + var quickCreateOpen by remember(personal, workspaceId) { mutableStateOf(false) } + var movingBoardIssueId by remember(personal, workspaceId) { mutableStateOf(null) } + var assigneeMembers by remember(workspaceId) { mutableStateOf>(emptyList()) } + var assigneeAgents by remember(workspaceId) { mutableStateOf>(emptyList()) } + val scope = rememberCoroutineScope() + + LaunchedEffect(personal, myIssueScope, issues) { + if (personal && myIssueScope == MyIssueScope.Assigned) { + personalScopedIssues = issues + personalScopeLoading = false + personalScopeError = null + } + } + LaunchedEffect(personal, myIssueScope, workspaceId, currentUser.id, assigneeAgents) { + if (!personal || myIssueScope == MyIssueScope.Assigned) return@LaunchedEffect + val myAgentIds = assigneeAgents + .filter { it.ownerId == currentUser.id } + .map { it.id } + .filter { it.isNotBlank() } + .sorted() + if (myIssueScope == MyIssueScope.Agents && myAgentIds.isEmpty()) { + personalScopedIssues = emptyList() + personalScopeLoading = false + personalScopeError = null + return@LaunchedEffect + } + personalScopeLoading = true + personalScopeError = null + val result = withContext(Dispatchers.IO) { + runCatching { + IssueBuckets.loadAllConcurrent( + { status, limit, offset -> + when (myIssueScope) { + MyIssueScope.Assigned -> api.issuesFiltered(workspaceId, limit, offset, currentUser.id, status, null, emptyList()) + MyIssueScope.Created -> { + val creatorId = currentUser.id + api.issuesFiltered(workspaceId, limit, offset, null, status, creatorId, emptyList()) + } + MyIssueScope.Agents -> { + val assigneeIds = myAgentIds + api.issuesFiltered(workspaceId, limit, offset, null, status, null, assigneeIds) + } + } + }, + 100, + ) + } + } + personalScopeLoading = false + result.onSuccess { + personalScopedIssues = it + }.onFailure { + personalScopeError = it.message ?: it.toString() + personalScopedIssues = emptyList() + } + } + + val effectiveIssues = if (personal) personalScopedIssues ?: issues else issues + val effectiveLoading = loading || (personal && personalScopeLoading) + val childCountByParent = remember(effectiveIssues) { + effectiveIssues.mapNotNull { issue -> + issue.parentIssueId?.takeIf { it.isNotBlank() } + }.groupingBy { it }.eachCount() + } + val visibleIssues = remember(effectiveIssues, issueQuery, assigneeScope, priorityFilter, statusFilter, sortOption, descending, zh) { + val query = issueQuery.trim() + val filtered = effectiveIssues + .asSequence() + .filter { issue -> + personal || when (assigneeScope) { + IssueAssigneeScope.All -> true + IssueAssigneeScope.Members -> issue.assigneeType == "member" + IssueAssigneeScope.Agents -> issue.assigneeType == "agent" || issue.assigneeType == "squad" + } + } + .filter { issue -> priorityFilter == null || issue.priority == priorityFilter } + .filter { issue -> statusFilter == null || issue.status == statusFilter } + .filter { issue -> + query.isBlank() + || issue.identifier.contains(query, ignoreCase = true) + || issue.title.contains(query, ignoreCase = true) + || clean(issue.description).contains(query, ignoreCase = true) + || Models.statusLabel(issue.status, zh).contains(query, ignoreCase = true) + || Models.priorityLabel(issue.priority, zh).contains(query, ignoreCase = true) + } + .toList() + sortIssues(filtered, sortOption, descending) + } + val grouped = remember(visibleIssues) { visibleIssues.groupBy { it.status } } + val activeFilterCount = listOf( + issueQuery.isNotBlank(), + !personal && assigneeScope != IssueAssigneeScope.All, + personal && myIssueScope != MyIssueScope.Assigned, + priorityFilter != null, + statusFilter != null, + sortOption != IssueSortOption.Position || descending, + ).count { it } + val issueListUnfilteredEmpty = effectiveIssues.isEmpty() && activeFilterCount == 0 + val issueIds = remember(effectiveIssues) { effectiveIssues.map { it.id }.toSet() } + LaunchedEffect(issueIds, effectiveLoading) { + if (!effectiveLoading) { + val filtered = selectedIds.filter { it in issueIds }.toSet() + if (filtered != selectedIds) onSelectedIdsChange(filtered) + } + } + LaunchedEffect(workspaceId) { + withContext(Dispatchers.IO) { + runCatching { + val members = api.members(workspaceId) + val agents = api.agents(workspaceId) + members to agents + } + }.onSuccess { (members, agents) -> + assigneeMembers = members + assigneeAgents = agents + } + } + + fun runBatchAction(action: suspend (List) -> Int, success: (Int) -> String, clearSelection: Boolean) { + val ids = selectedIds.toList() + if (ids.isEmpty() || batchMutating) return + batchMutating = true + batchMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { action(ids) } } + batchMutating = false + result.onSuccess { count -> + batchMessage = success(count) + if (clearSelection) onSelectedIdsChange(emptySet()) + confirmBatchDelete = false + onRefresh() + }.onFailure { + batchMessage = "${if (zh) "批量操作失败" else "Batch action failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun moveBoardIssue(issue: Models.Issue, direction: Int) { + if (movingBoardIssueId != null || direction == 0) return + val currentIndex = Models.STATUS_VALUES.indexOf(issue.status).takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + direction).coerceIn(0, Models.STATUS_VALUES.lastIndex) + if (targetIndex == currentIndex) return + val targetStatus = Models.STATUS_VALUES[targetIndex] + movingBoardIssueId = issue.id + batchMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.updateIssueStatus(workspaceId, issue.id, targetStatus) } + } + movingBoardIssueId = null + result.onSuccess { + batchMessage = if (zh) { + "已移动 ${issue.identifier} 到 ${Models.statusLabel(targetStatus, true)}" + } else { + "Moved ${issue.identifier} to ${Models.statusLabel(targetStatus, false)}" + } + onRefresh() + }.onFailure { + batchMessage = "${if (zh) "移动失败" else "Move failed"}: ${it.message ?: it.toString()}" + } + } + } + + val issueTitleActions: @Composable () -> Unit = { + Row( + modifier = Modifier.semantics { contentDescription = "Issue Compact Title Toolbar" }, + horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IssueToolbarIconPill( + icon = if (selectionModeEnabled || selectedIds.isNotEmpty()) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = "Issue Selection Toggle", + selected = selectionModeEnabled || selectedIds.isNotEmpty(), + onClick = { + selectionModeEnabled = !selectionModeEnabled + if (!selectionModeEnabled) { + onSelectedIdsChange(emptySet()) + confirmBatchDelete = false + } else { + boardMode = false + } + }, + ) + IssueToolbarIconPill( + icon = Icons.Outlined.Tune, + contentDescription = "IssueFilterToggle", + selected = showFilterPanel || activeFilterCount > 0, + onClick = { showFilterPanel = !showFilterPanel }, + ) + IssueSortIconMenu( + sortOption = sortOption, + descending = descending, + zh = zh, + onSortOptionChange = { sortOption = it }, + onDirectionChange = { descending = it }, + ) + IssueToolbarIconPill( + icon = if (boardMode) Icons.Outlined.ViewList else Icons.Outlined.Dashboard, + contentDescription = if (boardMode) { + if (zh) "切换到列表" else "Switch to list" + } else { + if (zh) "切换到看板" else "Switch to board" + }, + selected = boardMode, + onClick = { boardMode = !boardMode }, + ) + IssueCreateMenuButton( + zh = zh, + onManualCreate = onNewIssueClick, + onAgentQuickCreate = { + if (!personal) quickCreateOpen = !quickCreateOpen else onNewIssueClick() + }, + ) + } + } + + val issuePinnedHeader: @Composable () -> Unit = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + IssueWebContextCaption(workspaceName = workspaceName, personal = personal, zh = zh) + if (personal) { + Box( + modifier = Modifier.semantics { + contentDescription = "My Issues Web SSOT $WEB_SSOT_MY_ISSUES_PAGE $WEB_SSOT_MY_ISSUES_HEADER" + }, + ) { + MyIssuesScopeSegmentedControl( + selected = myIssueScope, + zh = zh, + onSelected = { + myIssueScope = it + onSelectedIdsChange(emptySet()) + confirmBatchDelete = false + }, + ) + } + } else { + IssueScopeSegmentedControl( + selected = assigneeScope, + zh = zh, + onSelected = { + assigneeScope = it + onSelectedIdsChange(emptySet()) + confirmBatchDelete = false + }, + ) + } + } + } + + PilotListPage( + title = if (personal) { + if (zh) "我的 Issues" else "My Issues" + } else { + "Issues" + }, + trailing = issueTitleActions, + pinnedHeader = issuePinnedHeader, + bottomContent = if (selectedIds.isNotEmpty()) { + { + IssueBatchActionBar( + selectedCount = selectedIds.size, + members = assigneeMembers, + agents = assigneeAgents, + batchMutating = batchMutating, + confirmingDelete = confirmBatchDelete, + zh = zh, + onStatus = { status -> + runBatchAction( + action = { ids -> api.batchUpdateIssues(workspaceId, ids, status, null) }, + success = { count -> if (zh) "已更新 $count 个 Issue" else "Updated $count issues" }, + clearSelection = true, + ) + }, + onPriority = { priority -> + runBatchAction( + action = { ids -> api.batchUpdateIssues(workspaceId, ids, null, priority) }, + success = { count -> if (zh) "已更新 $count 个 Issue" else "Updated $count issues" }, + clearSelection = true, + ) + }, + onAssignee = { type, id -> + runBatchAction( + action = { ids -> api.batchUpdateIssues(workspaceId, ids, null, null, type, id) }, + success = { count -> if (zh) "已分配 $count 个 Issue" else "Assigned $count issues" }, + clearSelection = true, + ) + }, + onClear = { + onSelectedIdsChange(emptySet()) + selectionModeEnabled = false + confirmBatchDelete = false + }, + onDelete = { confirmBatchDelete = true }, + onCancelDelete = { confirmBatchDelete = false }, + onConfirmDelete = { + runBatchAction( + action = { ids -> api.batchDeleteIssues(workspaceId, ids) }, + success = { count -> if (zh) "已删除 $count 个 Issue" else "Deleted $count issues" }, + clearSelection = true, + ) + }, + ) + } + } else { + null + }, + ) { + if (showFilterPanel || activeFilterCount > 0) { + item { + IssueListControls( + query = issueQuery, + onQueryChange = { issueQuery = it }, + sortOption = sortOption, + descending = descending, + priorityFilter = priorityFilter, + statusFilter = statusFilter, + selectedCount = selectedIds.size, + zh = zh, + onSortOptionChange = { sortOption = it }, + onDirectionChange = { descending = it }, + onPriorityChange = { priorityFilter = it }, + onStatusChange = { statusFilter = it }, + onClear = { + issueQuery = "" + assigneeScope = IssueAssigneeScope.All + myIssueScope = MyIssueScope.Assigned + priorityFilter = null + statusFilter = null + sortOption = IssueSortOption.Position + descending = false + showFilterPanel = false + }, + ) + } + } + if (issueQuery.isBlank() && selectedIds.isEmpty()) { + item { + IssueListSummaryPanel( + total = effectiveIssues.size, + visible = visibleIssues.size, + groupedCount = grouped.values.count { it.isNotEmpty() }, + activeFilters = activeFilterCount, + personal = personal, + zh = zh, + ) + } + } + if (quickCreateOpen && !personal) { + item { + PilotQuickCreateCard( + api = api, + workspaceId = workspaceId, + zh = zh, + onClose = { quickCreateOpen = false }, + ) + } + } + if (!batchMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = batchMessage.orEmpty(), + isError = pilotInlineResultIsError(batchMessage.orEmpty()), + contentDescription = "Issue Batch Result Web Banner", + ) + } + } + if (!personalScopeError.isNullOrBlank()) { + item { + PilotInlineResultState( + message = "${if (zh) "加载我的 Issues 失败" else "Failed to load My Issues"}: ${personalScopeError.orEmpty()}", + isError = true, + contentDescription = "My Issues Scope Result Web Banner", + ) + } + } + if (effectiveLoading) { + item { + MulticaLoadingState( + if (personal) { + if (zh) "正在加载我的 Issues..." else "Loading my issues..." + } else { + if (zh) "正在加载 Issues..." else "Loading issues..." + }, + ) + } + } else if (boardMode) { + item { + PilotIssueBoard( + grouped = grouped, + zh = zh, + movingIssueId = movingBoardIssueId, + onMoveIssue = ::moveBoardIssue, + onIssueClick = onIssueClick, + ) + } + } else { + if (visibleIssues.isEmpty()) { + item { + IssueListWebEmptyState( + personal = personal, + unfilteredEmpty = issueListUnfilteredEmpty, + zh = zh, + ) + } + } else { + Models.STATUS_VALUES.forEach { status -> + val bucket = grouped[status].orEmpty() + if (bucket.isEmpty()) return@forEach + val collapsed = status in collapsedStatusSections + item { + IssueStatusGroupHeader( + status = status, + count = bucket.size, + collapsed = collapsed, + zh = zh, + onClick = { + collapsedStatusSections = if (collapsed) { + collapsedStatusSections - status + } else { + collapsedStatusSections + status + } + }, + ) + } + if (!collapsed) { + items(bucket, key = { it.id }) { issue -> + val selected = issue.id in selectedIds + val selectionMode = selectionModeEnabled || selectedIds.isNotEmpty() || issueQuery.isNotBlank() + fun toggleIssueSelection() { + onSelectedIdsChange(if (selected) { + selectedIds - issue.id + } else { + selectedIds + issue.id + }) + confirmBatchDelete = false + } + IssueMaterialListRow( + issue = issue, + metadata = buildString { + if (issue.createdAt.isNotBlank()) { + append(if (zh) "创建 " else "Created ") + append(shortDate(issue.createdAt)) + } + if (!issue.assigneeId.isNullOrBlank()) { + val assigneeName = Models.issueAssigneeDisplayName( + issue, + assigneeMembers, + assigneeAgents, + currentUser, + ) + if (isNotBlank()) append(" · ") + append(assigneeName.ifBlank { Models.shortId(issue.assigneeId) }) + } + }, + childProgressMetadata = childCountByParent[issue.id]?.takeIf { it > 0 }?.let { + if (zh) "$it 个子 Issue" else "$it child issues" + }.orEmpty(), + labelsMetadata = issue.labels.take(2).joinToString(", ") { it.name }, + projectMetadata = issue.projectId?.takeIf { it.isNotBlank() }?.let { + if (zh) "项目 ${Models.shortId(it)}" else "Project ${Models.shortId(it)}" + }.orEmpty(), + dueDateMetadata = issue.dueDate.takeIf { it.isNotBlank() }?.let { + if (zh) "截止 ${shortDate(it)}" else "Due ${shortDate(it)}" + }.orEmpty(), + onClick = { + if (selectedIds.isNotEmpty() || issueQuery.isNotBlank()) { + toggleIssueSelection() + } else { + onIssueClick(issue.id) + } + }, + rowSelectionDescription = if (!selectionMode) { + "Open Issue ${issue.identifier}" + } else if (selected) { + if (zh) "已选择 ${issue.identifier}" else "Selected ${issue.identifier}" + } else { + if (zh) "选择 ${issue.identifier}" else "Select ${issue.identifier}" + }, + onRowSelectionClick = { + if (selectionMode) toggleIssueSelection() else onIssueClick(issue.id) + }, + zh = zh, + trailing = { + if (selectionMode) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (selected) { + Text( + text = if (zh) "已选择 ${selectedIds.size}" else "${selectedIds.size} selected", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = if (selected) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = if (selected) { + if (zh) "已选择 ${issue.identifier}" else "Selected ${issue.identifier}" + } else { + if (zh) "选择 ${issue.identifier}" else "Select ${issue.identifier}" + }, + onClick = { toggleIssueSelection() }, + tone = if (selected) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(44.dp) + .semantics(mergeDescendants = true) { + contentDescription = if (selected) { + if (zh) "已选择 ${issue.identifier}" else "Selected ${issue.identifier}" + } else { + if (zh) "选择 ${issue.identifier}" else "Select ${issue.identifier}" + } + }, + ) + } + } else { + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier + .size(22.dp) + .semantics { contentDescription = "Open Issue ${issue.identifier}" }, + ) + } + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun PilotQuickCreateCard( + api: ApiClient, + workspaceId: String, + zh: Boolean, + onClose: () -> Unit, +) { + var agentsState by remember(workspaceId) { mutableStateOf>?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var prompt by remember(workspaceId) { mutableStateOf("") } + var agentIndex by remember(workspaceId) { mutableIntStateOf(0) } + var submitting by remember(workspaceId) { mutableStateOf(false) } + var resultText by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(workspaceId, refresh) { + agentsState = null + agentsState = withContext(Dispatchers.IO) { + runCatching { + api.agents(workspaceId) + .filter { it.archivedAt.isBlank() } + .sortedBy { it.name.lowercase() } + } + } + agentIndex = 0 + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Quick Create Web Agent Panel" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.66f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "工作区 › Agent Issue" else "Workspace › Agent Issue", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (zh) "快速创建 Issue" else "Quick Create Issue", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.Close, + contentDescription = "Quick Create Close", + onClick = onClose, + tone = MulticaButtonTone.Ghost, + ) + } + when (val loaded = agentsState) { + null -> MulticaLoadingState(if (zh) "正在加载 Agent..." else "Loading agents...") + else -> loaded.fold( + onSuccess = { agents -> + val selectedAgent = agents.getOrNull(agentIndex.coerceIn(0, (agents.size - 1).coerceAtLeast(0))) + QuickCreateAgentPickerRow( + agents = agents, + selectedAgent = selectedAgent, + zh = zh, + onSelect = { index -> + agentIndex = index + resultText = null + }, + ) + MulticaTextField( + value = prompt, + onValueChange = { + prompt = it + resultText = null + }, + label = if (zh) "告诉 Agent 要创建什么 Issue" else "Tell the agent what issue to create", + contentDescription = "Quick Create Prompt Field", + minLines = 4, + maxLines = 8, + ) + if (!resultText.isNullOrBlank()) { + val isError = resultText!!.contains("failed", true) || + resultText!!.contains("失败") || + resultText!!.contains("请选择") + if (isError) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "Quick Create Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (zh) "Agent 会根据提示创建任务并回写 Issue。" else "The agent creates a task and files the issue from your prompt.", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + MulticaPillButton( + text = if (submitting) "..." else if (zh) "发送" else "Send", + icon = Icons.AutoMirrored.Outlined.Send, + contentDescription = "Quick Create Send", + onClick = { + val agent = selectedAgent + val promptText = prompt.trim() + if (agent == null || promptText.isBlank() || submitting) { + resultText = if (zh) "请选择 Agent 并填写提示词" else "Select an agent and enter a prompt" + return@MulticaPillButton + } + submitting = true + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.quickCreateIssue(workspaceId, agent.id, promptText) } + } + submitting = false + result.onSuccess { + prompt = "" + resultText = if (zh) "已创建任务 ${it.taskId}" else "Task created ${it.taskId}" + }.onFailure { + resultText = "${if (zh) "快速创建失败" else "Quick create failed"}: ${it.message ?: it.toString()}" + } + } + }, + tone = MulticaButtonTone.Primary, + ) + } + }, + onFailure = { + MulticaErrorState( + message = "${if (zh) "Agent 加载失败" else "Agents failed"}\n${it.message ?: it.toString()}", + onRetry = { refresh++ }, + ) + }, + ) + } + } + } +} + +@Composable +private fun QuickCreateAgentPickerRow( + agents: List, + selectedAgent: Models.Agent?, + zh: Boolean, + onSelect: (Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val items = remember(agents, selectedAgent, zh) { + if (agents.isEmpty()) { + listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "没有可用 Agent" else "No agent available", + icon = Icons.Outlined.Person, + contentDescription = "Quick Create Agent Empty", + enabled = false, + onClick = {}, + ) + ) + } else { + agents.mapIndexed { index, agent -> + MulticaCupertinoActionSheetItem( + title = agent.name, + subtitle = agent.visibility.takeIf { it.isNotBlank() }, + icon = Icons.Outlined.Person, + selected = agent.id == selectedAgent?.id, + contentDescription = "Quick Create Agent ${agent.name}", + onClick = { onSelect(index) }, + ) + } + } + } + Box { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Quick Create Actor Picker" } + .clickable(enabled = agents.isNotEmpty()) { expanded = true }, + shape = RoundedCornerShape(9.dp), + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(9.dp)) + .padding(horizontal = 11.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Person, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "Created by" else "Created by", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + selectedAgent?.name ?: if (zh) "没有可用 Agent" else "No agent available", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + selectedAgent?.visibility?.takeIf { it.isNotBlank() }?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Icon(Icons.Outlined.KeyboardArrowDown, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(18.dp)) + } + } + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "选择 Agent" else "Choose agent", + items = items, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueListWebEmptyState( + personal: Boolean, + unfilteredEmpty: Boolean, + zh: Boolean, +) { + val WEB_SSOT_ISSUES_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/issues-page.tsx" + val WEB_SSOT_MY_ISSUES_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/my-issues/components/my-issues-page.tsx" + val sourcePath = if (personal) WEB_SSOT_MY_ISSUES_PAGE else WEB_SSOT_ISSUES_PAGE + val title = if (unfilteredEmpty) { + if (personal) { + if (zh) "没有分配给你的 issue" else "No issues assigned to you" + } else { + if (zh) "还没有 issue" else "No issues yet" + } + } else { + if (zh) "没有匹配的 Issue" else "No matching issues" + } + val description = if (unfilteredEmpty) { + if (personal) { + if (zh) "你创建或被分配到的 issue 会显示在这里。" else "Issues you create or are assigned to will appear here." + } else { + if (zh) "创建一个 issue 开始使用。" else "Create an issue to get started." + } + } else { + if (zh) "清除搜索或筛选后再试。" else "Clear search or filters and try again." + } + + Box( + modifier = Modifier.semantics { + contentDescription = "Issue List Web Empty State $sourcePath" + }, + ) { + MulticaEmptyState( + title = title, + description = description, + ) + } +} + +@Composable +private fun PilotIssueBoard( + grouped: Map>, + zh: Boolean, + movingIssueId: String?, + onMoveIssue: (Models.Issue, Int) -> Unit, + onIssueClick: (String) -> Unit, +) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(Models.STATUS_VALUES) { status -> + val bucket = grouped[status].orEmpty() + Surface( + modifier = Modifier + .width(236.dp) + .semantics { contentDescription = "Issue Web Board Column $status" }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.74f)), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = issueStatusIcon(status), + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(15.dp), + ) + Text( + text = Models.statusLabel(status, zh), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = bucket.size.toString(), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + ) + } + if (bucket.isEmpty()) { + Text( + text = if (zh) "暂无问题" else "No issues", + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(vertical = 14.dp), + ) + } + bucket.take(20).forEachIndexed { index, issue -> + var horizontalDrag by remember(issue.id) { mutableStateOf(0f) } + val isMoving = movingIssueId == issue.id + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Board Issue ${issue.identifier}" + } + .graphicsLayer { + translationX = horizontalDrag.coerceIn(-44f, 44f) + } + .pointerInput(issue.id, issue.status, movingIssueId) { + detectHorizontalDragGestures( + onDragCancel = { horizontalDrag = 0f }, + onDragEnd = { + val direction = when { + horizontalDrag > 92f -> 1 + horizontalDrag < -92f -> -1 + else -> 0 + } + horizontalDrag = 0f + if (!isMoving && direction != 0) onMoveIssue(issue, direction) + }, + onHorizontalDrag = { change, dragAmount -> + if (!isMoving) { + horizontalDrag += dragAmount + change.consume() + } + }, + ) + } + .clickable(enabled = !isMoving) { onIssueClick(issue.id) }, + shape = RoundedCornerShape(8.dp), + color = if (isMoving) MulticaColors.Accent.copy(alpha = 0.10f) else MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.52f)), + ) { + Column(modifier = Modifier.padding(horizontal = 9.dp, vertical = 8.dp)) { + Text( + text = issue.title, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 18.sp, + ), + color = MulticaColors.Text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = listOf( + issue.identifier, + Models.priorityLabel(issue.priority, zh), + if (isMoving) if (zh) "移动中..." else "Moving..." else "", + ).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (index < bucket.lastIndex && index < 19) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IssueFormDetailsWebPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(MulticaVisualTokens.CardRadius) + Surface( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Form Details Web Panel" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.75.dp, MulticaColors.Border.copy(alpha = 0.46f)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + content = content, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IssueFormWebRow( + eyebrow: String, + title: String, + subtitle: String?, + contentDescription: String, + onClick: (() -> Unit)? = null, + trailingIcon: ImageVector? = null, +) { + val haptic = LocalHapticFeedback.current + val clickableModifier = if (onClick == null) { + Modifier + } else { + Modifier.clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + } + Surface( + modifier = Modifier + .fillMaxWidth() + .then(clickableModifier) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + }, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(horizontal = 6.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.Caption, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = MulticaTypeScale.FormRowTitle, lineHeight = 20.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelMedium.copy(fontSize = MulticaTypeScale.DetailMeta, lineHeight = 17.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailingIcon != null) { + Icon( + imageVector = trailingIcon, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +@Composable +private fun IssueFormMenuRow( + eyebrow: String, + title: String, + subtitle: String?, + contentDescription: String, + options: List, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IssueFormWebRow( + eyebrow = eyebrow, + title = title, + subtitle = subtitle, + contentDescription = "$contentDescription Issue Form Web Menu Row", + onClick = { expanded = true }, + trailingIcon = Icons.Outlined.KeyboardArrowRight, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = eyebrow, + message = title, + items = options.map { option -> + MulticaCupertinoActionSheetItem( + title = option.label, + contentDescription = "$contentDescription ${option.label}", + selected = option.selected, + onClick = option.onClick, + ) + }, + cancelText = "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +private data class IssueFormMenuOption( + val label: String, + val selected: Boolean, + val onClick: () -> Unit, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PilotIssueForm( + api: ApiClient, + session: PilotSession, + issue: Models.Issue?, + parentIssueId: String?, + parentIssueIdentifier: String?, + zh: Boolean, + onCancel: () -> Unit, + onSaved: (Models.Issue) -> Unit, +) { + val editing = issue != null + var optionsState by remember(session.workspace.id) { mutableStateOf?>(null) } + var optionsRefresh by remember(session.workspace.id) { mutableIntStateOf(0) } + var saveError by remember(issue?.id) { mutableStateOf(null) } + var saving by remember(issue?.id) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(session.workspace.id, optionsRefresh) { + optionsState = null + optionsState = withContext(Dispatchers.IO) { + runCatching { + PilotIssueFormOptions( + projects = api.projects(session.workspace.id, 500, 0).items, + members = api.members(session.workspace.id), + agents = api.agents(session.workspace.id), + ) + } + } + } + + val loaded = optionsState + when { + loaded == null -> MulticaLoadingState(if (zh) "正在加载表单..." else "Loading form...") + loaded.isFailure -> MulticaErrorState( + message = "${if (zh) "表单加载失败" else "Form failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", + onRetry = { optionsRefresh++ }, + ) + else -> { + val options = loaded.getOrThrow() + val assignees = Models.issueAssignees( + true, + if (zh) "未分配" else "Unassigned", + session.user, + options.members, + options.agents, + ).map { PilotAssigneeOption(it.id, it.type, it.name) } + var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) } + var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) } + var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) } + var dueDateEnabled by remember(issue?.id) { mutableStateOf(issue?.dueDate?.isNotBlank() == true) } + var showDueDatePicker by remember(issue?.id) { mutableStateOf(false) } + var projectIndex by remember(issue?.id, options.projects.size) { + mutableIntStateOf((options.projects.indexOfFirst { it.id == issue?.projectId } + 1).coerceAtLeast(0)) + } + var statusIndex by remember(issue?.id) { + mutableIntStateOf(Models.STATUS_VALUES.indexOf(issue?.status ?: "todo").coerceAtLeast(0)) + } + var priorityIndex by remember(issue?.id) { + mutableIntStateOf(Models.PRIORITY_VALUES.indexOf(issue?.priority ?: "medium").coerceAtLeast(0)) + } + var assigneeIndex by remember(issue?.id, assignees.size) { + mutableIntStateOf(assignees.indexOfFirst { + it.id == issue?.assigneeId && (issue?.assigneeType == null || it.type == issue.assigneeType) + }.coerceAtLeast(0)) + } + val agentCreateOptions = options.agents + .filter { it.archivedAt.isBlank() } + .sortedBy { it.name.lowercase() } + var agentCreateIndex by remember(issue?.id, agentCreateOptions.size) { mutableIntStateOf(0) } + var agentCreatePrompt by remember(issue?.id) { mutableStateOf("") } + var agentCreateSubmitting by remember(issue?.id) { mutableStateOf(false) } + var agentCreateResult by remember(issue?.id) { mutableStateOf(null) } + val selectedCreateAgent = agentCreateOptions.getOrNull( + agentCreateIndex.coerceIn(0, (agentCreateOptions.size - 1).coerceAtLeast(0)) + ) + + fun cycleProject() { + projectIndex = (projectIndex + 1) % (options.projects.size + 1) + } + + fun cycleStatus() { + statusIndex = (statusIndex + 1) % Models.STATUS_VALUES.size + } + + fun cyclePriority() { + priorityIndex = (priorityIndex + 1) % Models.PRIORITY_VALUES.size + } + + fun cycleAssignee() { + assigneeIndex = (assigneeIndex + 1) % assignees.size + } + + fun cycleCreateAgent() { + if (agentCreateOptions.isNotEmpty()) { + agentCreateIndex = (agentCreateIndex + 1) % agentCreateOptions.size + } + } + + fun ensureDueDateValue() { + if (dueDate.isBlank()) { + dueDate = formatDueDateMillis(System.currentTimeMillis()) + } + } + + fun agentCreatePromptText(): String { + val customPrompt = agentCreatePrompt.trim() + if (customPrompt.isNotEmpty()) return customPrompt + val titleText = title.trim() + val descriptionText = description.trim() + return listOf( + titleText.takeIf { it.isNotEmpty() }?.let { "Title: $it" }, + descriptionText.takeIf { it.isNotEmpty() }?.let { "Description: $it" }, + ).filterNotNull().joinToString("\n") + } + + fun saveIssue() { + val titleText = title.trim() + if (titleText.isEmpty() || saving) { + saveError = if (zh) "标题不能为空" else "Title is required" + return + } + val projectId = if (projectIndex == 0) null else options.projects[projectIndex - 1].id + val dueDateText = if (dueDateEnabled) dueDate.trim() else "" + val status = Models.STATUS_VALUES[statusIndex] + val priority = Models.PRIORITY_VALUES[priorityIndex] + val selectedAssignee = assignees[assigneeIndex] + val assignee = if (selectedAssignee.id == null) null else { + Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name) + } + saving = true + saveError = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + if (issue == null) { + api.createIssue(titleText, description, session.workspace.id, projectId, status, priority, assignee, dueDateText, parentIssueId) + } else { + api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText) + } + } + } + saving = false + result.onSuccess(onSaved).onFailure { + saveError = "${if (zh) "保存失败" else "Save failed"}: ${it.message ?: it.toString()}" + } + } + } + + MulticaCupertinoActionSheet( + visible = showDueDatePicker, + title = if (zh) "设置到期时间" else "Set due date", + message = if (zh) "也可以在下方直接输入日期值" else "You can also type a date value below", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "今天" else "Today", + icon = Icons.Outlined.CalendarMonth, + contentDescription = "Issue Due Date Today", + onClick = { + dueDate = formatDueDateMillis(System.currentTimeMillis()) + dueDateEnabled = true + }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "明天" else "Tomorrow", + icon = Icons.Outlined.CalendarMonth, + contentDescription = "Issue Due Date Tomorrow", + onClick = { + dueDate = formatDueDateMillis(System.currentTimeMillis() + 86_400_000L) + dueDateEnabled = true + }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "下周" else "Next week", + icon = Icons.Outlined.CalendarMonth, + contentDescription = "Issue Due Date Next Week", + onClick = { + dueDate = formatDueDateMillis(System.currentTimeMillis() + 7 * 86_400_000L) + dueDateEnabled = true + }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "清除截止日期" else "Clear due date", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Issue Due Date Clear", + onClick = { + dueDate = "" + dueDateEnabled = false + }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { showDueDatePicker = false }, + ) + + val WEB_SSOT_CREATE_ISSUE = "/Users/park0er/coding/multica-web-source/packages/views/modals/create-issue.tsx" + val WEB_SSOT_CREATE_ISSUE_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/modals/create-issue-dialog.tsx" + val WEB_SSOT_ISSUE_PROPERTY_PICKER = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/pickers/property-picker.tsx" + val formTitle = if (editing) { + if (zh) "编辑 Issue" else "Edit Issue" + } else if (!parentIssueId.isNullOrBlank()) { + if (zh) "新建子 Issue" else "New Child Issue" + } else { + if (zh) "新建 Issue" else "New Issue" + } + + PilotListPage( + title = formTitle, + compactHeader = true, + leading = { + MulticaIconPillButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = if (zh) "返回" else "Back", + onClick = onCancel, + tone = MulticaButtonTone.Secondary, + ) + }, + bottomContent = { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Form Bottom Haze Save Bar $WEB_SSOT_CREATE_ISSUE $WEB_SSOT_CREATE_ISSUE_DIALOG $WEB_SSOT_ISSUE_PROPERTY_PICKER" } + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (saving) "..." else if (editing) { + if (zh) "保存" else "Save" + } else { + if (zh) "创建" else "Create" + }, + onClick = { saveIssue() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + }, + ) { + if (!saveError.isNullOrBlank()) { + item { + PilotInlineResultState( + message = saveError.orEmpty(), + isError = true, + contentDescription = "Issue Form Save Result Web Banner", + ) + } + } + if (!parentIssueId.isNullOrBlank()) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Form Parent Context Capsule" }, + shape = RoundedCornerShape(999.dp), + color = MulticaColors.AccentSoft, + tonalElevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.ArrowUpward, + contentDescription = null, + tint = MulticaColors.Accent, + modifier = Modifier.size(16.dp), + ) + Text( + text = if (zh) { + "子 Issue of ${parentIssueIdentifier ?: Models.shortId(parentIssueId)}" + } else { + "Sub-issue of ${parentIssueIdentifier ?: Models.shortId(parentIssueId)}" + }, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + item { + MulticaTextField( + value = title, + onValueChange = { title = it }, + label = if (zh) "标题" else "Title", + contentDescription = "Issue Form Title Field", + singleLine = true, + ) + } + item { + MulticaTextField( + value = description, + onValueChange = { description = it }, + label = if (zh) "描述" else "Description", + contentDescription = "Issue Form Description Field", + minLines = 4, + maxLines = 8, + ) + } + item { + SectionHeader(if (zh) "详情" else "Details") + } + item { + IssueFormDetailsWebPanel( + modifier = Modifier.semantics { contentDescription = "Issue Form Details Web Panel" }, + ) { + IssueFormMenuRow( + eyebrow = if (zh) "项目" else "Project", + title = if (projectIndex == 0) { + if (zh) "不关联项目" else "No project" + } else { + options.projects[projectIndex - 1].name + }, + subtitle = null, + contentDescription = "Issue Form Project", + options = listOf( + IssueFormMenuOption( + label = if (zh) "不关联项目" else "No project", + selected = projectIndex == 0, + onClick = { projectIndex = 0 }, + ) + ) + options.projects.mapIndexed { index, project -> + IssueFormMenuOption( + label = project.name, + selected = projectIndex == index + 1, + onClick = { projectIndex = index + 1 }, + ) + }, + ) + IssueFormMenuRow( + eyebrow = if (zh) "负责人" else "Assignee", + title = assignees[assigneeIndex].name, + subtitle = null, + contentDescription = "Issue Form Assignee", + options = assignees.mapIndexed { index, assignee -> + IssueFormMenuOption( + label = assignee.name, + selected = assigneeIndex == index, + onClick = { assigneeIndex = index }, + ) + }, + ) + IssueFormMenuRow( + eyebrow = if (zh) "优先级" else "Priority", + title = Models.priorityLabel(Models.PRIORITY_VALUES[priorityIndex], zh), + subtitle = null, + contentDescription = "Issue Form Priority", + options = Models.PRIORITY_VALUES.mapIndexed { index, priority -> + IssueFormMenuOption( + label = Models.priorityLabel(priority, zh), + selected = priorityIndex == index, + onClick = { priorityIndex = index }, + ) + }, + ) + IssueFormMenuRow( + eyebrow = if (zh) "状态" else "Status", + title = Models.statusLabel(Models.STATUS_VALUES[statusIndex], zh), + subtitle = null, + contentDescription = "Issue Form Status", + options = Models.STATUS_VALUES.mapIndexed { index, status -> + IssueFormMenuOption( + label = Models.statusLabel(status, zh), + selected = statusIndex == index, + onClick = { statusIndex = index }, + ) + }, + ) + IssueFormWebRow( + eyebrow = if (zh) "标签" else "Labels", + title = issue?.labels?.takeIf { it.isNotEmpty() }?.joinToString(", ") { it.name } + ?: if (zh) "不设置标签" else "No labels", + subtitle = if (zh) "标签选择器稍后接入" else "Label picker coming soon", + contentDescription = "Issue Form Labels Web Row", + trailingIcon = Icons.Outlined.Label, + ) + IssueFormWebRow( + eyebrow = if (zh) "截止日期" else "Due Date", + title = if (dueDateEnabled) shortDate(dueDate) else if (zh) "不设置截止日期" else "No due date", + subtitle = if (dueDateEnabled) dueDate else null, + contentDescription = "Issue Form Due Date Toggle Issue Form Due Date Web Row", + onClick = { + dueDateEnabled = !dueDateEnabled + if (dueDateEnabled) ensureDueDateValue() + }, + trailingIcon = Icons.Outlined.KeyboardArrowRight, + ) + if (dueDateEnabled) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + MulticaPillButton( + text = if (zh) "选择日期" else "Select Date", + onClick = { + ensureDueDateValue() + showDueDatePicker = true + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (zh) "清除" else "Clear", + onClick = { + dueDateEnabled = false + dueDate = "" + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + } + MulticaTextField( + value = dueDate, + onValueChange = { + dueDate = it + dueDateEnabled = it.isNotBlank() + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Form Web Due Date Field" }, + label = if (zh) "截止日期值" else "Due date value", + contentDescription = "Issue Form Due Date Value", + singleLine = true, + ) + } + } + } + } + if (!editing) { + item { + SectionHeader(if (zh) "使用 Agent 创建" else "Create with Agent") + } + item { + IssueFormAgentCreatePanel( + agents = agentCreateOptions, + selectedAgent = selectedCreateAgent, + selectedAgentIndex = agentCreateIndex, + prompt = agentCreatePrompt, + agentCreateResult = agentCreateResult, + submitting = agentCreateSubmitting, + zh = zh, + onAgentSelected = { + agentCreateIndex = it + agentCreateResult = null + }, + onPromptChange = { + agentCreatePrompt = it + agentCreateResult = null + }, + onSend = { + val agent = selectedCreateAgent + val promptText = agentCreatePromptText() + if (agent == null || promptText.isBlank() || agentCreateSubmitting) { + agentCreateResult = if (zh) "请选择 Agent,并填写标题、描述或提示词" else "Select an agent and enter a title, description, or prompt" + return@IssueFormAgentCreatePanel + } + agentCreateSubmitting = true + agentCreateResult = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.quickCreateIssue(session.workspace.id, agent.id, promptText) } + } + agentCreateSubmitting = false + result.onSuccess { + agentCreatePrompt = "" + agentCreateResult = if (zh) "已创建任务 ${it.taskId}" else "Task created ${it.taskId}" + }.onFailure { + agentCreateResult = "${if (zh) "发送给 Agent 失败" else "Send to Agent failed"}: ${it.message ?: it.toString()}" + } + } + }, + ) + } + } + } + } + } +} + +@Composable +private fun IssueDetailWebPropertyPill( + text: String, + icon: ImageVector, + selected: Boolean, + enabled: Boolean, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + val shape = RoundedCornerShape(10.dp) + Surface( + modifier = modifier + .heightIn(min = 32.dp) + .widthIn(min = 86.dp) + .clip(shape) + .clickable(enabled = enabled) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + .semantics(mergeDescendants = true) { + this.contentDescription = "$contentDescription Issue Detail Web Property Pill" + }, + shape = shape, + color = when { + !enabled -> MulticaColors.Surface + selected -> MulticaColors.AccentSoft + else -> MulticaColors.SurfaceElevated + }, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .border( + width = 1.dp, + color = if (selected) MulticaColors.Accent.copy(alpha = 0.24f) else MulticaColors.Border, + shape = shape, + ) + .padding(horizontal = 9.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = when { + !enabled -> MulticaColors.Muted.copy(alpha = 0.54f) + selected -> MulticaColors.Accent + else -> MulticaColors.Muted + }, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (enabled) MulticaColors.TextPrimary else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { this.contentDescription = "Issue Detail Web Property Text" }, + ) + } + } +} + +@Composable +private fun IssueFormAgentCreatePanel( + agents: List, + selectedAgent: Models.Agent?, + selectedAgentIndex: Int, + prompt: String, + agentCreateResult: String?, + submitting: Boolean, + zh: Boolean, + onAgentSelected: (Int) -> Unit, + onPromptChange: (String) -> Unit, + onSend: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Form Web Agent Panel" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.66f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = 0.58f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Outlined.Send, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(17.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "切换到 Agent 创建" else "Switch to agent create", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + if (zh) "沿用上方标题/描述,也可补充 Agent 提示词。" else "Use the title/description above, or add an agent prompt.", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + QuickCreateAgentPickerRow( + agents = agents, + selectedAgent = selectedAgent, + zh = zh, + onSelect = onAgentSelected, + ) + MulticaTextField( + value = prompt, + onValueChange = onPromptChange, + label = if (zh) "Agent 提示词" else "Agent prompt", + contentDescription = "Issue Form Agent Prompt Field", + minLines = 3, + maxLines = 6, + ) + if (!agentCreateResult.isNullOrBlank()) { + val isError = agentCreateResult.contains("failed", true) || + agentCreateResult.contains("失败") || + agentCreateResult.contains("请选择") + if (isError) { + PilotInlineResultState( + message = agentCreateResult, + isError = true, + contentDescription = "Issue Form Agent Result Web Banner", + ) + } else { + MulticaSuccessState(agentCreateResult.orEmpty()) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + selectedAgent?.name?.let { + if (zh) "将发送给 $it" else "Will send to $it" + } ?: if (zh) "请选择 Agent" else "Select an agent", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + MulticaPillButton( + text = if (submitting) "..." else if (zh) "发送" else "Send", + icon = Icons.AutoMirrored.Outlined.Send, + contentDescription = "Issue Form Send to Agent", + onClick = onSend, + tone = MulticaButtonTone.Primary, + ) + } + } + } +} + +@Composable +private fun IssueDetailStatusMenu( + status: String, + updating: Boolean, + zh: Boolean, + onStatusChange: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IssueDetailWebPropertyPill( + text = Models.statusLabel(status, zh), + icon = issueStatusIcon(status), + selected = true, + enabled = !updating, + contentDescription = "IssueDetailStatusMenu", + onClick = { expanded = true }, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "修改状态" else "Change status", + items = Models.STATUS_VALUES.map { value -> + MulticaCupertinoActionSheetItem( + title = Models.statusLabel(value, zh), + icon = issueStatusIcon(value), + selected = value == status, + contentDescription = "Issue Detail Status ${Models.statusLabel(value, false)}", + onClick = { if (value != status) onStatusChange(value) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueDetailPriorityMenu( + priority: String, + updating: Boolean, + zh: Boolean, + onPriorityChange: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IssueDetailWebPropertyPill( + text = Models.priorityLabel(priority, zh), + icon = Icons.Outlined.Flag, + selected = priority != "none" && priority != "no_priority", + enabled = !updating, + contentDescription = "IssueDetailPriorityMenu", + onClick = { expanded = true }, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "修改优先级" else "Change priority", + items = Models.PRIORITY_VALUES.map { value -> + MulticaCupertinoActionSheetItem( + title = Models.priorityLabel(value, zh), + icon = Icons.Outlined.Flag, + selected = value == priority, + contentDescription = "Issue Detail Priority ${Models.priorityLabel(value, false)}", + onClick = { if (value != priority) onPriorityChange(value) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun IssueDetailHeroCard( + issue: Models.Issue, + updatingField: String?, + zh: Boolean, + onStatusChange: (String) -> Unit, + onPriorityChange: (String) -> Unit, + onEdit: () -> Unit, + onCopyLink: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = issue.identifier, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + Text( + text = issue.title, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 27.sp, + lineHeight = 33.sp, + ), + color = MulticaColors.Text, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + item { + IssueDetailStatusMenu( + status = issue.status, + updating = updatingField == "status", + zh = zh, + onStatusChange = onStatusChange, + ) + } + item { + IssueDetailPriorityMenu( + priority = issue.priority, + updating = updatingField == "priority", + zh = zh, + onPriorityChange = onPriorityChange, + ) + } + } + } +} + +@Composable +private fun IssueDetailCompactMetadata( + issue: Models.Issue, + issueLabels: List, + assignee: String, + project: String, + zh: Boolean, +) { + val labelSummary = issueLabels.joinToString(", ") { it.name } + .ifBlank { if (zh) "无 Labels" else "No labels" } + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 10.dp) + .semantics(mergeDescendants = false) { contentDescription = "Issue Detail Compact Metadata" }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText( + label = if (zh) "负责人" else "Assignee", + value = assignee.ifBlank { if (zh) "未分配" else "Unassigned" }, + modifier = Modifier.weight(1f), + contentDescription = "Issue Detail Assignee Metadata Row", + ) + CompactMetaText( + label = if (zh) "项目" else "Project", + value = project, + modifier = Modifier.weight(1f), + contentDescription = "Issue Detail Project Metadata Row", + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText( + label = if (zh) "优先级" else "Priority", + value = Models.priorityLabel(issue.priority, zh), + modifier = Modifier.weight(1f), + contentDescription = "Issue Detail Priority Due Date Metadata Row", + ) + CompactMetaText( + label = if (zh) "到期" else "Due", + value = issue.dueDate?.takeIf { it.isNotBlank() }?.let { shortDate(it) } ?: "-", + modifier = Modifier.weight(1f), + contentDescription = "Issue Detail Priority Due Date Metadata Row", + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText( + label = if (zh) "Labels" else "Labels", + value = labelSummary, + modifier = Modifier.weight(1f), + contentDescription = "Issue Detail Labels Metadata Row", + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText( + label = if (zh) "创建" else "Created", + value = issue.createdAt.takeIf { it.isNotBlank() }?.let { shortDate(it) } ?: "-", + modifier = Modifier.weight(1f), + ) + CompactMetaText( + label = if (zh) "更新" else "Updated", + value = issue.updatedAt.takeIf { it.isNotBlank() }?.let { shortDate(it) } ?: "-", + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun CompactInfoPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(MulticaVisualTokens.CardRadius) + Surface( + modifier = modifier, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.75.dp, MulticaColors.Border.copy(alpha = 0.46f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(MulticaVisualTokens.SectionGap), + content = content, + ) + } +} + +@Composable +private fun CompactMetaText( + label: String, + value: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + Column( + modifier = modifier.then( + if (contentDescription == null) Modifier else Modifier.semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + } + ), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = MulticaTypeScale.Meta, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = value, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = MulticaTypeScale.RowTitle, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun CompactIssueRow( + eyebrow: String, + title: String, + trailing: String? = null, + showDivider: Boolean = false, + contentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .then(if (contentDescription == null) Modifier else Modifier.semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + if (onClick != null) { + semanticsOnClick(label = title) { + onClick() + true + } + } + }), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MulticaVisualTokens.RowMinHeight) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.Meta, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = MulticaTypeScale.RowTitle, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (!trailing.isNullOrBlank()) { + Text( + text = trailing, + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.Caption, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (onClick != null) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun CompactUsageMetric( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.Meta, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = MulticaTypeScale.RowTitle, + lineHeight = 19.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun IssueDetailSubIssuesCompactList( + children: List, + zh: Boolean, + onOpenIssue: (String) -> Unit, + onCreateChildIssue: () -> Unit, +) { + val doneChildren = children.count { it.status == "done" } + var subIssuesExpanded by remember(children.joinToString("|") { it.id }) { mutableStateOf(false) } + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Issue Sub Issues Integrated Panel" }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (zh) "子 Issue" else "Sub-issues", + style = MaterialTheme.typography.labelMedium.copy( + fontSize = MulticaTypeScale.SectionTitle, + lineHeight = 19.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + modifier = Modifier.weight(1f), + ) + Text( + text = if (zh) "$doneChildren/${children.size} 完成" else "$doneChildren/${children.size} done", + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.DetailMeta, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = "Issue Sub Issues Header Create Child Button", + onClick = onCreateChildIssue, + tone = MulticaButtonTone.Primary, + modifier = Modifier + .size(44.dp) + .semantics { contentDescription = "Issue Sub Issues Header Create Child Button" }, + ) + } + if (children.isEmpty()) { + CompactIssueRow( + eyebrow = if (zh) "空列表" else "Empty", + title = if (zh) "暂无子 Issue" else "No sub-issues", + ) + } else { + val visibleChildren = if (subIssuesExpanded) children else children.take(4) + visibleChildren.forEachIndexed { index, child -> + CompactIssueRow( + eyebrow = listOf(child.identifier, Models.statusLabel(child.status, zh)).joinToString(" · "), + title = child.title, + trailing = if (child.assigneeType == "agent") "Agent" else null, + showDivider = index < visibleChildren.lastIndex || children.size > visibleChildren.size, + contentDescription = "Issue Detail Child ${child.id}", + onClick = { onOpenIssue(child.id) }, + ) + } + if (!subIssuesExpanded && children.size > visibleChildren.size) { + CompactIssueRow( + eyebrow = if (zh) "更多" else "More", + title = if (zh) "还有 ${children.size - visibleChildren.size} 个子 Issue" else "${children.size - visibleChildren.size} more sub-issues", + contentDescription = "Issue Sub Issues Show More", + onClick = { subIssuesExpanded = true }, + ) + } else if (subIssuesExpanded && children.size > 4) { + CompactIssueRow( + eyebrow = if (zh) "收起" else "Collapse", + title = if (zh) "收起子 Issue" else "Collapse sub-issues", + contentDescription = "Issue Sub Issues Collapse", + onClick = { subIssuesExpanded = false }, + ) + } + } + } +} + +@Composable +private fun IssueDetailSubscribersCompact( + subscribers: List, + members: List, + agents: List, + currentUser: Models.User, + mutating: Boolean, + zh: Boolean, + onToggleSelf: (Boolean) -> Unit, +) { + val currentMemberSubscribed = subscribers.any { it.userType == "member" && it.userId == currentUser.id } + val subscriberNames = subscribers.take(5).joinToString(" · ") { + Models.issueSubscriberDisplayName(it, members, agents, currentUser) + } + CompactInfoPanel { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 1.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = if (subscribers.isEmpty()) { + if (zh) "暂无订阅者" else "No subscribers" + } else { + if (zh) "${subscribers.size} 个订阅者" else "${subscribers.size} subscribers" + }, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (subscribers.isEmpty()) { + if (zh) "点右侧小按钮订阅" else "Use the small action to subscribe" + } else { + subscriberNames + }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = if (currentMemberSubscribed) Icons.Outlined.CheckCircle else Icons.Outlined.Add, + contentDescription = "Issue Detail Subscriber Compact Action", + onClick = { onToggleSelf(currentMemberSubscribed) }, + enabled = !mutating, + tone = if (currentMemberSubscribed) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "Issue Detail Subscriber Compact Action" }, + ) + } + } +} + +@Composable +private fun IssueDetailUsageCompact(usage: Models.IssueUsage?, zh: Boolean) { + CompactInfoPanel( + modifier = Modifier.semantics(mergeDescendants = false) { + contentDescription = "Issue Detail Compact Usage" + }, + ) { + if (usage == null) { + CompactIssueRow( + eyebrow = "Usage", + title = if (zh) "用量暂不可用" else "Usage unavailable", + contentDescription = "Issue Usage Unavailable", + ) + } else if (usage.taskCount <= 0) { + CompactIssueRow( + eyebrow = "Usage", + title = if (zh) "暂无用量" else "No usage yet", + contentDescription = "Issue Usage Empty State", + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactUsageMetric( + label = if (zh) "总 Tokens" else "Tokens", + value = usage.totalTokens().toString(), + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Issue Usage Tokens" }, + ) + CompactUsageMetric( + label = if (zh) "运行次数" else "Runs", + value = usage.taskCount.toString(), + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactUsageMetric( + label = if (zh) "输入" else "Input", + value = usage.inputTokens.toString(), + modifier = Modifier.weight(1f), + ) + CompactUsageMetric( + label = if (zh) "输出" else "Output", + value = usage.outputTokens.toString(), + modifier = Modifier.weight(1f), + ) + } + if (usage.cacheReadTokens > 0 || usage.cacheWriteTokens > 0) { + CompactIssueRow( + eyebrow = if (zh) "缓存" else "Cache", + title = if (zh) { + "读 ${usage.cacheReadTokens} · 写 ${usage.cacheWriteTokens}" + } else { + "read ${usage.cacheReadTokens} · write ${usage.cacheWriteTokens}" + }, + ) + } + } + } +} + +@Composable +private fun IssueTimelineWebDenseRow( + title: String, + subtitle: String, + kind: String, + highlighted: Boolean, + showDivider: Boolean = false, + contentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .then(Modifier.semantics(mergeDescendants = true) { + this.contentDescription = contentDescription ?: "Issue Timeline Web Dense Row $kind" + if (onClick != null) { + semanticsOnClick(label = title) { + onClick() + true + } + } + }), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MulticaVisualTokens.RowMinHeight) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .padding(top = 3.dp) + .size(10.dp) + .clip(RoundedCornerShape(999.dp)) + .background(if (highlighted) MulticaColors.Accent else MulticaColors.Border), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = kind.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (highlighted) MulticaColors.Accent else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (highlighted) { + Text( + text = "PINNED", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onClick != null) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(17.dp), + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun IssueDetailWebActionRow( + eyebrow: String, + title: String, + subtitle: String?, + icon: ImageVector, + contentDescription: String, + tone: MulticaButtonTone = MulticaButtonTone.Ghost, + expanded: Boolean? = null, + showDivider: Boolean = false, + onClick: (() -> Unit)? = null, +) { + val tint = when (tone) { + MulticaButtonTone.Primary -> MulticaColors.Accent + MulticaButtonTone.Destructive -> MulticaColors.Danger + MulticaButtonTone.Secondary -> MulticaColors.Success + MulticaButtonTone.Ghost -> MulticaColors.Muted + } + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + if (onClick != null) { + semanticsOnClick(label = title) { + onClick() + true + } + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(999.dp), + color = tint.copy(alpha = 0.12f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(18.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = eyebrow.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onClick != null) { + Icon( + imageVector = when (expanded) { + true -> Icons.Outlined.KeyboardArrowDown + false -> Icons.Outlined.KeyboardArrowRight + null -> Icons.Outlined.KeyboardArrowRight + }, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(17.dp), + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun IssueDeleteConfirmWebPanel( + issue: Models.Issue, + zh: Boolean, + isDeleting: Boolean, + icon: ImageVector, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Delete Web Alert Panel ${issue.identifier}" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = if (zh) "删除这个 Issue?" else "Delete this issue?", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + text = issue.identifier.ifBlank { Models.shortId(issue.id) }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = if (zh) { + "此操作会删除当前 Issue,并从列表、项目和关系视图中移除。" + } else { + "This deletes the current issue and removes it from lists, projects, and relationship views." + }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.08f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Danger, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (isDeleting) "..." else if (zh) "删除" else "Delete", + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Issue Delete Confirm Destructive" }, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun IssueLabelWebRow( + eyebrow: String, + title: String, + subtitle: String?, + color: String, + contentDescription: String, + tone: MulticaButtonTone = MulticaButtonTone.Ghost, + showDivider: Boolean = false, + onClick: (() -> Unit)? = null, +) { + val tint = when (tone) { + MulticaButtonTone.Primary -> MulticaColors.Accent + MulticaButtonTone.Destructive -> MulticaColors.Danger + MulticaButtonTone.Secondary -> MulticaColors.Success + MulticaButtonTone.Ghost -> MulticaColors.Muted + } + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + if (onClick != null) { + semanticsOnClick(label = title) { + onClick() + true + } + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MulticaVisualTokens.RowMinHeight) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LabelColorSwatch(color, "$contentDescription Swatch") + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = eyebrow.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onClick != null) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = tint, + modifier = Modifier.size(17.dp), + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun IssueDetailProgressCard( + title: String, + subtitle: String?, + icon: ImageVector, + tone: MulticaButtonTone, + onClick: (() -> Unit)? = null, +) { + val tint = when (tone) { + MulticaButtonTone.Primary -> MulticaColors.Accent + MulticaButtonTone.Destructive -> MulticaColors.Danger + MulticaButtonTone.Secondary -> MulticaColors.Success + MulticaButtonTone.Ghost -> MulticaColors.Muted + } + val shape = RoundedCornerShape(MulticaVisualTokens.CardRadius) + val clickModifier = if (onClick != null) Modifier.clickable { onClick() } else Modifier + Surface( + modifier = Modifier + .fillMaxWidth() + .then(clickModifier) + .clip(shape) + .border(0.75.dp, MulticaColors.Border.copy(alpha = 0.52f), shape), + shape = shape, + color = MulticaColors.Surface.copy(alpha = 0.72f), + ) { + Row( + modifier = Modifier + .heightIn(min = 64.dp) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(17.dp), + color = tint.copy(alpha = 0.12f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(18.dp), + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy(fontSize = MulticaTypeScale.RowTitle, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.DetailMeta, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onClick != null) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +@Composable +private fun IssueCommentSortWebPill( + commentsDescending: Boolean, + zh: Boolean, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IssueWebFilterMenuPill( + text = if (commentsDescending) if (zh) "最新优先" else "Newest" else if (zh) "最早优先" else "Oldest", + icon = if (commentsDescending) Icons.Outlined.ArrowDownward else Icons.Outlined.ArrowUpward, + selected = commentsDescending, + contentDescription = contentDescription, + onClick = onClick, + modifier = modifier.widthIn(min = 116.dp, max = 152.dp), + ) +} + +@Composable +private fun MulticaRemoteAvatarImage( + avatarUrl: String, + size: Dp, + shape: RoundedCornerShape = RoundedCornerShape(999.dp), + contentDescription: String? = null, +) { + if (avatarUrl.isNotBlank()) { + val context = LocalContext.current + val cookieHeader = remember(context) { AuthStore(context.applicationContext).cloudFrontCookieHeader() } + val imageRequest = remember(context, avatarUrl, cookieHeader) { + ImageRequest.Builder(context) + .data(avatarUrl) + .crossfade(true) + .apply { + if (cookieHeader.isNotBlank() && avatarUrl.contains("static.multica.ai")) { + addHeader("Cookie", cookieHeader) + } + } + .build() + } + AsyncImage( + model = imageRequest, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size) + .clip(shape), + ) + } +} + +@Composable +private fun IssueActorAvatar( + actorName: String, + avatarUrl: String, + actorType: String, + agentStatus: String?, + modifier: Modifier = Modifier, + size: Dp = MulticaVisualTokens.AvatarMedium, +) { + val isAgent = actorType == "agent" + val initials = actorInitials(actorName, actorType) + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(999.dp)) + .background(if (isAgent) MulticaColors.Accent.copy(alpha = 0.13f) else MulticaColors.SurfaceElevated) + .border(0.75.dp, MulticaColors.Border.copy(alpha = 0.68f), RoundedCornerShape(999.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = MulticaTypeScale.AvatarInitials, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (isAgent) MulticaColors.Accent else MulticaColors.Text, + maxLines = 1, + ) + MulticaRemoteAvatarImage( + avatarUrl = avatarUrl, + size = size, + contentDescription = if (isAgent) "Agent Remote Avatar" else "Member Remote Avatar", + ) + } + if (isAgent) { + val dotColor = when (agentStatus.orEmpty().lowercase()) { + "online", "running", "active", "available", "ready" -> MulticaColors.Success + "busy", "working" -> MulticaColors.Accent + "paused", "archived", "offline" -> MulticaColors.Muted + else -> MulticaColors.Accent + } + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(8.dp) + .clip(RoundedCornerShape(999.dp)) + .background(dotColor) + .border(1.dp, MulticaColors.Surface, RoundedCornerShape(999.dp)), + ) + } + } +} + +private fun actorInitials(actorName: String, actorType: String): String { + val cleaned = actorName.trim() + if (cleaned.isEmpty()) return if (actorType == "agent") "A" else "M" + val parts = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() } + val initials = if (parts.size >= 2) { + "${parts[0].first()}${parts[1].first()}" + } else { + cleaned.take(2) + } + return initials.uppercase(Locale.getDefault()) +} + +private fun commentAuthorAgentStatus(comment: Models.Comment, agents: List): String? { + if (comment.authorType != "agent") return null + return agents.firstOrNull { it.id == comment.authorId }?.status +} + +@Composable +private fun IssueCommentCard( + comment: Models.Comment, + authorName: String, + authorAvatarUrl: String = "", + authorAgentStatus: String? = null, + expandedActions: Boolean, + contentExpanded: Boolean, + highlighted: Boolean, + zh: Boolean, + onToggleActions: () -> Unit, + onToggleContent: () -> Unit, + onReplyAction: () -> Unit, + onEditAction: () -> Unit, + onDeleteAction: () -> Unit, +) { + val content = clean(comment.content) + val collapseContent = shouldCollapseIssueComment(content) + val renderedContent = if (collapseContent && !contentExpanded) { + issueCommentPreview(comment.content) + } else { + comment.content + } + val shape = RoundedCornerShape(MulticaVisualTokens.CardRadius) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + .semantics(mergeDescendants = false) { + contentDescription = if (highlighted) { + "Issue Comment Web Highlighted ${comment.id}" + } else { + "Issue Comment Web Thread Row ${comment.id}" + } + }, + shape = shape, + color = if (highlighted) MulticaColors.AccentSoft.copy(alpha = 0.36f) else MulticaColors.Surface.copy(alpha = 0.74f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke( + width = if (highlighted) 1.25.dp else 0.75.dp, + color = if (highlighted) MulticaColors.Accent.copy(alpha = 0.74f) else MulticaColors.Border.copy(alpha = 0.52f), + ), + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.Top, + ) { + IssueActorAvatar( + actorName = authorName.ifBlank { comment.authorType }, + avatarUrl = authorAvatarUrl, + actorType = comment.authorType, + agentStatus = authorAgentStatus, + modifier = Modifier.semantics { contentDescription = "Issue Comment Actor Avatar ${comment.id}" }, + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = authorName.ifBlank { if (zh) "评论者" else "Commenter" }, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = MulticaTypeScale.RowTitle, + lineHeight = 19.sp, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + text = shortDate(comment.createdAt), + style = MaterialTheme.typography.labelSmall.copy(fontSize = MulticaTypeScale.Caption, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (content.isBlank()) { + Text( + text = if (zh) "空评论" else "Empty comment", + style = MaterialTheme.typography.bodySmall.copy(fontSize = MulticaTypeScale.Body, lineHeight = 20.sp), + color = MulticaColors.Muted, + ) + } else { + MarkdownBlock(renderedContent) + if (collapseContent) { + MulticaPillButton( + text = if (contentExpanded) { + if (zh) "收起" else "Collapse" + } else { + if (zh) "展开" else "Expand" + }, + onClick = onToggleContent, + modifier = Modifier.heightIn(min = 30.dp), + tone = MulticaButtonTone.Ghost, + ) + } + } + } + IssueCommentWebActionsMenu( + commentId = comment.id, + expanded = expandedActions, + zh = zh, + onToggle = onToggleActions, + onReply = onReplyAction, + onEdit = onEditAction, + onDelete = onDeleteAction, + ) { + } + } + } + } +} + +@Composable +private fun IssueCommentWebActionsMenu( + commentId: String, + expanded: Boolean, + zh: Boolean, + onToggle: () -> Unit, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + anchor: @Composable () -> Unit = {}, +) { + Box( + modifier = Modifier.semantics(mergeDescendants = false) { + contentDescription = "Issue Comment Web Actions Menu $commentId" + }, + ) { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = if (zh) "评论操作" else "Comment actions", + onClick = onToggle, + tone = if (expanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier.size(34.dp), + ) + anchor() + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "评论操作" else "Comment actions", + items = buildList { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "回复" else "Reply", + icon = Icons.Outlined.Add, + contentDescription = "Issue Comment Reply", + onClick = onReply, + ) + ) + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "编辑" else "Edit", + icon = Icons.Outlined.Edit, + contentDescription = "Issue Comment Edit", + onClick = onEdit, + ) + ) + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "删除" else "Delete", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Issue Comment Delete", + onClick = onDelete, + ) + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = onToggle, + ) + } +} + +@Composable +private fun CommentAttachmentWebDenseRow( + attachment: Models.Attachment, + zh: Boolean, + showDivider: Boolean, + onOpen: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .semantics(mergeDescendants = true) { + contentDescription = "Comment Attachment Web Dense Row ${attachment.id}" + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MulticaVisualTokens.RowMinHeight) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Link, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = attachment.filename.ifBlank { if (zh) "未命名附件" else "Untitled attachment" }, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = listOf(attachment.contentType, formatBytes(attachment.sizeBytes)) + .filter { it.isNotBlank() } + .joinToString(" · ") + .ifBlank { if (zh) "点击打开" else "Tap to open" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = if (zh) "打开" else "Open", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun IssueAttachmentWebDenseRow( + attachment: Models.Attachment, + zh: Boolean, + deleting: Boolean, + showDivider: Boolean, + onOpen: () -> Unit, + onDelete: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = "Issue Attachment Web Dense Row ${attachment.id}" + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MulticaVisualTokens.RowMinHeight) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Link, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = attachment.filename.ifBlank { if (zh) "未命名附件" else "Untitled attachment" }, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = listOf(attachment.contentType, formatBytes(attachment.sizeBytes), shortDate(attachment.createdAt)) + .filter { it.isNotBlank() } + .joinToString(" · ") + .ifBlank { if (zh) "文件附件" else "File attachment" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = if (zh) "打开" else "Open", + modifier = Modifier.clickable(onClick = onOpen), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (deleting) { + if (zh) "删除中" else "Deleting" + } else { + if (zh) "删除" else "Delete" + }, + modifier = Modifier.clickable(enabled = !deleting, onClick = onDelete), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Danger, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border.copy(alpha = 0.36f)), + ) + } + } +} + +@Composable +private fun IssueAttachmentsWebPanel( + attachments: List, + deletingAttachmentId: String?, + zh: Boolean, + onOpen: (Models.Attachment) -> Unit, + onDelete: (Models.Attachment) -> Unit, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_ISSUE_ATTACHMENT_LIST = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/comment-card.tsx" + CompactInfoPanel( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = "Issue Attachments Web Panel $WEB_SSOT_ISSUE_ATTACHMENT_LIST" + }, + ) { + if (attachments.isNotEmpty()) { + attachments.forEach { attachment -> + IssueAttachmentWebDenseRow( + attachment = attachment, + zh = zh, + deleting = deletingAttachmentId == attachment.id, + showDivider = true, + onOpen = { onOpen(attachment) }, + onDelete = { onDelete(attachment) }, + ) + } + } else { + IssueDetailWebActionRow( + eyebrow = if (zh) "附件" else "Files", + title = if (zh) "暂无附件" else "No attachments", + subtitle = if (zh) "通过右上角更多按钮上传文件" else "Use the top-right more button to upload files", + icon = Icons.Outlined.Folder, + contentDescription = "Issue Attachments Empty Web Row", + ) + } + } +} + +private fun shouldCollapseIssueComment(content: String): Boolean { + if (content.contains("```")) return false + if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false + if (content.length > 420) return true + return content.count { it == '\n' } > 6 +} + +private fun issueCommentPreview(markdown: String): String { + val lineLimited = markdown + .lines() + .take(6) + .joinToString("\n") + .trimEnd() + val charLimited = if (lineLimited.length > 420) lineLimited.take(420).trimEnd() else lineLimited + return "$charLimited\n\n..." +} + +@Composable +private fun PilotIssueDetail( + api: ApiClient, + workspaceId: String, + workspaceSlug: String, + issueId: String, + currentUser: Models.User, + zh: Boolean, + onBack: () -> Unit, + onOpenIssue: (String) -> Unit, + onEditIssue: (Models.Issue) -> Unit, + onCreateChildIssue: (Models.Issue) -> Unit, + onDeleted: () -> Unit, + highlightCommentId: String? = null, + inboxItemId: String? = null, + inboxNextIssueId: String? = null, + onInboxDone: ((String?) -> Unit)? = null, +) { + var state by remember(issueId) { mutableStateOf?>(null) } + var detailRefresh by remember(issueId) { mutableIntStateOf(0) } + var showRuns by remember(issueId) { mutableStateOf(false) } + var selectedRun by remember(issueId) { mutableStateOf(null) } + var editingCommentId by remember(issueId) { mutableStateOf(null) } + var editingCommentContent by remember(issueId) { mutableStateOf("") } + var pendingDeleteComment by remember(issueId) { mutableStateOf(null) } + var replyingCommentId by remember(issueId) { mutableStateOf(null) } + var replyDraft by remember(issueId) { mutableStateOf("") } + var sendingReply by remember(issueId) { mutableStateOf(false) } + var replyMessage by remember(issueId) { mutableStateOf(null) } + var mutatingComment by remember(issueId) { mutableStateOf(false) } + var commentMessage by remember(issueId) { mutableStateOf(null) } + var expandedCommentActionsId by remember(issueId) { mutableStateOf(null) } + var expandedCommentContentIds by remember(issueId) { mutableStateOf>(emptySet()) } + var showExpandedCommentHistory by remember(issueId) { mutableStateOf(false) } + var commentsDescending by remember(issueId) { mutableStateOf(true) } + var mutatingSubscriber by remember(issueId) { mutableStateOf(false) } + var subscriberMessage by remember(issueId) { mutableStateOf(null) } + var uploadingAttachment by remember(issueId) { mutableStateOf(false) } + var deletingAttachmentId by remember(issueId) { mutableStateOf(null) } + var attachmentMessage by remember(issueId) { mutableStateOf(null) } + var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) } + var deletingIssue by remember(issueId) { mutableStateOf(false) } + var issueMutationMessage by remember(issueId) { mutableStateOf(null) } + var updatingIssueField by remember(issueId) { mutableStateOf(null) } + var copyLinkMessage by remember(issueId) { mutableStateOf(null) } + var issueMoreMenuOpen by remember(issueId) { mutableStateOf(false) } + var newCommentDraft by remember(issueId) { mutableStateOf("") } + var pendingCommentAttachments by remember(issueId) { mutableStateOf>(emptyList()) } + var uploadingCommentAttachment by remember(issueId) { mutableStateOf(false) } + var sendingNewComment by remember(issueId) { mutableStateOf(false) } + var newCommentMessage by remember(issueId) { mutableStateOf(null) } + var olderTimelineEntries by remember(issueId) { mutableStateOf>(emptyList()) } + var timelineNextCursor by remember(issueId) { mutableStateOf("") } + var timelineHasMoreBefore by remember(issueId) { mutableStateOf(false) } + var timelineInitialized by remember(issueId) { mutableStateOf(false) } + var loadingOlderTimeline by remember(issueId) { mutableStateOf(false) } + var timelineMessage by remember(issueId) { mutableStateOf(null) } + var cancellingIssueTaskId by remember(issueId) { mutableStateOf(null) } + var pendingTerminateTask by remember(issueId) { mutableStateOf(null) } + var issueTaskMessage by remember(issueId) { mutableStateOf(null) } + var showAgentLiveSection by remember(issueId) { mutableStateOf(true) } + var showPullRequestsSection by remember(issueId) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val activeRun = selectedRun + if (activeRun != null) { + PilotAgentTranscript( + api = api, + workspaceId = workspaceId, + run = activeRun, + zh = zh, + onBack = { selectedRun = null }, + ) + return + } + + LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) { + state = null + val coreResult = withContext(Dispatchers.IO) { + runCatching { + IssueDetailLoader.load(issueId, workspaceId, object : IssueDetailLoader.Fetcher { + override fun issue(requestedIssueId: String, requestedWorkspaceId: String): Models.Issue { + return api.issue(requestedIssueId, requestedWorkspaceId) + } + + override fun comments(requestedIssueId: String, requestedWorkspaceId: String): List { + return api.comments(requestedIssueId, requestedWorkspaceId, 100, 0).items + } + + override fun runs(requestedIssueId: String, requestedWorkspaceId: String): List { + return api.agentRuns(requestedIssueId, requestedWorkspaceId) + } + + override fun projects(): List = emptyList() + + override fun members(): List = api.members(workspaceId) + + override fun agents(): List = api.agents(workspaceId) + }, 5) + } + } + val core = coreResult.getOrElse { error -> + state = Result.failure(error) + return@LaunchedEffect + } + val initialData = PilotIssueDetailData( + core.issue, + core.comments, + core.runs, + core.members, + core.agents, + issueLabels = emptyList(), + allLabels = emptyList(), + subscribers = emptyList(), + attachments = emptyList(), + children = emptyList(), + usage = null, + timeline = null, + activeTasks = emptyList(), + ) + state = Result.success(initialData) + val secondaryData = withContext(Dispatchers.IO) { + val issueLabels = runCatching { api.issueLabels(workspaceId, issueId) }.getOrElse { emptyList() } + val allLabels = runCatching { api.labels(workspaceId) }.getOrElse { emptyList() } + val subscribers = runCatching { api.issueSubscribers(workspaceId, issueId) }.getOrElse { emptyList() } + val attachments = runCatching { api.issueAttachments(workspaceId, issueId) }.getOrElse { emptyList() } + val children = runCatching { api.childIssues(workspaceId, issueId) }.getOrElse { emptyList() } + val usage = runCatching { api.issueUsage(workspaceId, issueId) }.getOrNull() + val timelineAnchor = highlightCommentId ?: core.comments.lastOrNull()?.id + val timeline = runCatching { api.issueTimeline(workspaceId, issueId, 10, null, null, timelineAnchor) }.getOrNull() + val activeTasks = runCatching { api.activeIssueTasks(workspaceId, issueId) }.getOrElse { emptyList() } + initialData.copy( + issueLabels = issueLabels, + allLabels = allLabels, + subscribers = subscribers, + attachments = attachments, + children = children, + usage = usage, + timeline = timeline, + activeTasks = activeTasks, + ) + } + state = Result.success(secondaryData) + } + + val loaded = state + when { + loaded == null -> MulticaLoadingState(if (zh) "正在加载详情..." else "Loading issue...") + loaded.isFailure -> MulticaErrorState( + message = "${if (zh) "加载失败" else "Load failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", + onRetry = { detailRefresh++ }, + ) + else -> { + val data = loaded.getOrThrow() + LaunchedEffect(issueId, workspaceId, data.issue.id) { + while (true) { + delay(2500) + val activeTasks = withContext(Dispatchers.IO) { + runCatching { api.activeIssueTasks(workspaceId, issueId) }.getOrNull() + } ?: continue + state = state?.map { current -> current.copy(activeTasks = activeTasks) } + } + } + LaunchedEffect(data.timeline) { + val timeline = data.timeline ?: return@LaunchedEffect + if (!timelineInitialized) { + timelineNextCursor = timeline.nextCursor + timelineHasMoreBefore = timeline.hasMoreBefore + timelineInitialized = true + } + } + + fun loadOlderTimeline() { + val cursor = timelineNextCursor + if (cursor.isBlank() || loadingOlderTimeline) return + loadingOlderTimeline = true + timelineMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.issueTimeline(workspaceId, issueId, 10, cursor, null) } + } + result.onSuccess { page -> + olderTimelineEntries = olderTimelineEntries + page.entries + timelineNextCursor = page.nextCursor + timelineHasMoreBefore = page.hasMoreBefore + }.onFailure { error -> + timelineMessage = if (zh) "加载更早时间线失败:${error.message}" else "Load older timeline failed: ${error.message}" + } + loadingOlderTimeline = false + } + } + + fun runCommentMutation(action: suspend () -> Unit) { + if (mutatingComment) return + mutatingComment = true + commentMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { action() } } + mutatingComment = false + result.onSuccess { + editingCommentId = null + editingCommentContent = "" + pendingDeleteComment = null + commentMessage = if (zh) "评论已更新" else "Comment updated" + detailRefresh++ + }.onFailure { + commentMessage = "${if (zh) "评论操作失败" else "Comment action failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun runSubscriberMutation(successMessage: String, action: suspend () -> Unit) { + if (mutatingSubscriber) return + mutatingSubscriber = true + subscriberMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { action() } } + mutatingSubscriber = false + result.onSuccess { + subscriberMessage = successMessage + detailRefresh++ + }.onFailure { + subscriberMessage = "${if (zh) "订阅者操作失败" else "Subscriber action failed"}: ${it.message ?: it.toString()}" + } + } + } + + val attachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri == null || uploadingAttachment) return@rememberLauncherForActivityResult + uploadingAttachment = true + attachmentMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + api.uploadIssueAttachment( + workspaceId, + issueId, + displayNameForUri(context, uri), + context.contentResolver.getType(uri) ?: "application/octet-stream", + bytes, + ) + } + } + uploadingAttachment = false + result.onSuccess { + attachmentMessage = if (zh) "附件已上传" else "Attachment uploaded" + detailRefresh++ + }.onFailure { + attachmentMessage = "${if (zh) "附件上传失败" else "Attachment upload failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun deleteAttachment(attachment: Models.Attachment) { + if (deletingAttachmentId != null) return + deletingAttachmentId = attachment.id + attachmentMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteAttachment(workspaceId, attachment.id) } + } + deletingAttachmentId = null + result.onSuccess { + attachmentMessage = if (zh) "附件已删除" else "Attachment deleted" + detailRefresh++ + }.onFailure { + attachmentMessage = "${if (zh) "附件删除失败" else "Attachment delete failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun openAttachment(attachment: Models.Attachment) { + val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) } + if (target.isBlank()) { + attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL" + return + } + val result = runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(target)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + attachmentMessage = result.fold( + onSuccess = { if (zh) "正在打开附件" else "Opening attachment" }, + onFailure = { error -> + "${if (zh) "无法打开附件" else "Open attachment failed"}: ${error.message ?: error.toString()}" + }, + ) + } + + fun deleteCurrentIssue() { + if (deletingIssue) return + deletingIssue = true + issueMutationMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteIssue(workspaceId, issueId) } + } + deletingIssue = false + result.onSuccess { onDeleted() } + .onFailure { + issueMutationMessage = "${if (zh) "删除 Issue 失败" else "Delete issue failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun updateIssueStatusFromDetail(status: String) { + if (updatingIssueField != null) return + updatingIssueField = "status" + issueMutationMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.updateIssueStatus(workspaceId, issueId, status) } + } + updatingIssueField = null + result.onSuccess { + issueMutationMessage = if (zh) "状态已更新" else "Status updated" + detailRefresh++ + }.onFailure { + issueMutationMessage = "${if (zh) "状态更新失败" else "Status update failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun updateIssuePriorityFromDetail(priority: String) { + if (updatingIssueField != null) return + updatingIssueField = "priority" + issueMutationMessage = null + val assignee = data.issue.assigneeId?.let { assigneeId -> + Models.Assignee( + assigneeId, + data.issue.assigneeType ?: "member", + Models.issueAssigneeDisplayName(data.issue, data.members, data.agents, currentUser), + ) + } + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + api.updateIssue( + data.issue, + data.issue.title, + data.issue.description, + data.issue.projectId, + data.issue.status, + priority, + assignee, + data.issue.dueDate, + ) + } + } + updatingIssueField = null + result.onSuccess { + issueMutationMessage = if (zh) "优先级已更新" else "Priority updated" + detailRefresh++ + }.onFailure { + issueMutationMessage = "${if (zh) "优先级更新失败" else "Priority update failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun copyIssueLink() { + val slug = workspaceSlug.ifBlank { workspaceId } + val url = "https://app.multica.ai/$slug/issues/${data.issue.id}" + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Multica issue link", url)) + copyLinkMessage = if (zh) "链接已复制" else "Link copied" + } + + val commentAttachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri == null || uploadingCommentAttachment) return@rememberLauncherForActivityResult + uploadingCommentAttachment = true + newCommentMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + api.uploadIssueAttachment( + workspaceId, + issueId, + displayNameForUri(context, uri), + context.contentResolver.getType(uri) ?: "application/octet-stream", + bytes, + ) + } + } + uploadingCommentAttachment = false + result.onSuccess { attachment -> + pendingCommentAttachments = pendingCommentAttachments + attachment + val markdown = "[${attachment.filename}](${attachment.url})" + newCommentDraft = newCommentDraft.trimEnd() + .let { if (it.isBlank()) markdown else "$it\n\n$markdown" } + newCommentMessage = if (zh) "评论附件已添加" else "Comment attachment added" + }.onFailure { + newCommentMessage = "${if (zh) "评论附件上传失败" else "Comment attachment upload failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun sendNewComment() { + val content = newCommentDraft.trim() + if (sendingNewComment || content.isEmpty() && pendingCommentAttachments.isEmpty()) return + sendingNewComment = true + newCommentMessage = null + val attachmentIds = pendingCommentAttachments.map { it.id } + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.addComment(issueId, workspaceId, content, attachmentIds) } + } + sendingNewComment = false + result.onSuccess { + newCommentDraft = "" + pendingCommentAttachments = emptyList() + newCommentMessage = if (zh) "评论已发送" else "Comment sent" + detailRefresh++ + }.onFailure { + newCommentMessage = "${if (zh) "评论发送失败" else "Comment send failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun sendReply(parentId: String) { + val content = replyDraft.trim() + if (sendingReply || content.isEmpty()) return + sendingReply = true + replyMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.addComment(issueId, workspaceId, content, emptyList(), parentId) } + } + sendingReply = false + result.onSuccess { + replyDraft = "" + replyingCommentId = null + replyMessage = if (zh) "回复已发送" else "Reply sent" + detailRefresh++ + }.onFailure { + replyMessage = "${if (zh) "回复发送失败" else "Reply send failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun cancelIssueTask(task: Models.AgentTask) { + if (cancellingIssueTaskId != null) return + cancellingIssueTaskId = task.id + pendingTerminateTask = null + issueTaskMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.cancelIssueTask(workspaceId, issueId, task.id) } + } + cancellingIssueTaskId = null + result.onSuccess { + issueTaskMessage = if (zh) "任务已取消" else "Task cancelled" + detailRefresh++ + }.onFailure { + issueTaskMessage = "${if (zh) "取消任务失败" else "Cancel task failed"}: ${it.message ?: it.toString()}" + } + } + } + + val WEB_SSOT_ISSUE_DETAIL = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/issue-detail.tsx" + val WEB_SSOT_COMMENT_CARD = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/comment-card.tsx" + val WEB_SSOT_COMMENT_INPUT = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/comment-input.tsx" + val WEB_SSOT_EXECUTION_LOG_SECTION = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/execution-log-section.tsx" + val WEB_SSOT_PULL_REQUEST_LIST = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/pull-request-list.tsx" + val WEB_SSOT_TERMINATE_TASK_CONFIRM_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/terminate-task-confirm-dialog.tsx" + + PilotListPage( + title = "${data.issue.identifier} · ${Models.statusLabel(data.issue.status, zh)}", + compactHeader = true, + leading = { + Row( + modifier = Modifier.semantics { + contentDescription = "Issue Detail Secondary Navigation Bar $WEB_SSOT_ISSUE_DETAIL $WEB_SSOT_COMMENT_CARD $WEB_SSOT_COMMENT_INPUT $WEB_SSOT_EXECUTION_LOG_SECTION $WEB_SSOT_PULL_REQUEST_LIST $WEB_SSOT_TERMINATE_TASK_CONFIRM_DIALOG" + }, + ) { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = if (zh) "返回" else "Back", + onClick = onBack, + tone = MulticaButtonTone.Secondary, + ) + } + }, + trailing = { + Row( + modifier = Modifier.semantics { contentDescription = "Issue Detail Header Icon Actions" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Issue Detail More Button", + onClick = { issueMoreMenuOpen = true }, + tone = if (issueMoreMenuOpen) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + ) + MulticaIconPillButton( + icon = Icons.Outlined.Edit, + contentDescription = "Issue Detail Edit Button", + onClick = { onEditIssue(data.issue) }, + tone = MulticaButtonTone.Ghost, + ) + } + }, + bottomContent = { + Box( + modifier = Modifier.semantics { + contentDescription = "Issue Comment Bottom Haze Composer $WEB_SSOT_COMMENT_INPUT" + }, + ) { + IssueCommentInputBar( + draft = newCommentDraft, + pendingAttachments = pendingCommentAttachments, + agents = data.agents, + uploadingAttachment = uploadingCommentAttachment, + sending = sendingNewComment, + zh = zh, + onDraftChange = { newCommentDraft = it }, + onAttach = { if (!uploadingCommentAttachment) commentAttachmentLauncher.launch("*/*") }, + onSend = { sendNewComment() }, + ) + } + }, + ) { + item { + IssueDetailHeroCard( + issue = data.issue, + updatingField = updatingIssueField, + zh = zh, + onStatusChange = { updateIssueStatusFromDetail(it) }, + onPriorityChange = { updateIssuePriorityFromDetail(it) }, + onEdit = { onEditIssue(data.issue) }, + onCopyLink = { copyIssueLink() }, + ) + } + item { + IssueDetailCompactMetadata( + issue = data.issue, + issueLabels = data.issueLabels, + assignee = Models.issueAssigneeDisplayName(data.issue, data.members, data.agents, currentUser), + project = data.issue.projectId?.let { Models.shortId(it) } ?: if (zh) "无项目" else "No project", + zh = zh, + ) + } + if (!issueMutationMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = issueMutationMessage.orEmpty(), + isError = pilotInlineResultIsError(issueMutationMessage.orEmpty()), + contentDescription = "Issue Detail Mutation Result Web Banner", + ) + } + } + if (!copyLinkMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = copyLinkMessage.orEmpty(), + isError = false, + contentDescription = "Issue Detail Copy Link Result Web Banner", + ) + } + } + if (!data.issue.description.isNullOrBlank()) { + item { + MarkdownBlock(data.issue.description) + } + } + item { + SectionHeader(if (zh) "Agent 最新进展" else "Agent Latest Progress") + } + item { + IssueDetailWebActionRow( + eyebrow = if (zh) "Agent Live" else "Agent live", + title = if (showAgentLiveSection) { + if (zh) "收起实时进展" else "Hide live progress" + } else { + if (zh) "展开实时进展" else "Show live progress" + }, + subtitle = if (data.activeTasks.isNotEmpty()) { + if (zh) "${data.activeTasks.size} 个任务正在运行" else "${data.activeTasks.size} active task(s)" + } else if (data.runs.isNotEmpty()) { + if (zh) "${data.runs.size} 条 Agent 运行记录" else "${data.runs.size} agent run(s)" + } else { + if (zh) "Agent live card 对齐 Web activity header" else "Agent live card aligned with the Web activity header" + }, + icon = Icons.Outlined.Bolt, + expanded = showAgentLiveSection, + tone = if (data.activeTasks.isNotEmpty()) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + contentDescription = "Issue Agent Live Web Toggle $WEB_SSOT_EXECUTION_LOG_SECTION", + onClick = { showAgentLiveSection = !showAgentLiveSection }, + ) + } + if (showAgentLiveSection) { + item { + when { + data.activeTasks.isNotEmpty() -> { + val task = data.activeTasks.first() + IssueDetailProgressCard( + title = if (zh) "活跃 Agent 任务" else "Active agent task", + subtitle = listOf(task.status, clean(task.triggerSummary).ifBlank { Models.shortId(task.id) }) + .filter { it.isNotBlank() } + .joinToString(" · "), + icon = Icons.Outlined.Bolt, + tone = MulticaButtonTone.Primary, + ) + } + data.runs.isNotEmpty() -> { + val run = data.runs.first() + IssueDetailProgressCard( + title = if (zh) "最近 Agent 运行" else "Latest agent run", + subtitle = listOf(run.status, shortDate(run.startedAt), clean(run.error)) + .filter { it.isNotBlank() } + .joinToString(" · "), + icon = Icons.Outlined.Bolt, + tone = if (run.status == "failed") MulticaButtonTone.Destructive else MulticaButtonTone.Primary, + onClick = { selectedRun = run }, + ) + } + !data.timeline?.entries.isNullOrEmpty() -> { + val entry = data.timeline!!.entries.first() + IssueDetailProgressCard( + title = clean(entry.action).ifBlank { if (zh) "最近活动" else "Latest activity" }, + subtitle = clean(entry.content).ifBlank { shortDate(entry.createdAt) }, + icon = Icons.Outlined.CalendarMonth, + tone = MulticaButtonTone.Ghost, + ) + } + else -> { + IssueDetailProgressCard( + title = if (zh) "暂无进展更新" else "No progress updates yet", + subtitle = if (zh) "Agent 运行、评论和活动会优先显示在这里" else "Agent runs, comments, and activity will surface here first", + icon = Icons.Outlined.RadioButtonUnchecked, + tone = MulticaButtonTone.Ghost, + ) + } + } + } + } + item { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(MulticaVisualTokens.SectionTop) + .semantics { contentDescription = "Issue Detail Section Rhythm Agent Progress To Sub Issues" }, + ) + } + item { + IssueDetailSubIssuesCompactList( + children = data.children, + zh = zh, + onOpenIssue = onOpenIssue, + onCreateChildIssue = { onCreateChildIssue(data.issue) }, + ) + } + item { + SectionHeader(if (zh) "订阅者" else "Subscribers") + } + item { + IssueDetailSubscribersCompact( + subscribers = data.subscribers, + members = data.members, + agents = data.agents, + currentUser = currentUser, + mutating = mutatingSubscriber, + zh = zh, + onToggleSelf = { currentlySubscribed -> + runSubscriberMutation( + if (currentlySubscribed) { + if (zh) "已取消订阅" else "Unsubscribed" + } else { + if (zh) "已订阅" else "Subscribed" + } + ) { + if (currentlySubscribed) { + api.unsubscribeFromIssue(workspaceId, issueId, currentUser.id, "member") + } else { + api.subscribeToIssue(workspaceId, issueId, currentUser.id, "member") + } + } + }, + ) + } + if (!subscriberMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = subscriberMessage.orEmpty(), + isError = pilotInlineResultIsError(subscriberMessage.orEmpty()), + contentDescription = "Issue Detail Subscriber Result Web Banner", + ) + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 18.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (zh) "评论" else "Comments", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 19.sp, + ), + color = MulticaColors.Muted, + modifier = Modifier.weight(1f), + ) + IssueCommentSortWebPill( + commentsDescending = commentsDescending, + zh = zh, + contentDescription = "IssueCommentSortPicker Issue Comment Sort Web Pill", + onClick = { commentsDescending = !commentsDescending }, + ) + } + } + if (!newCommentMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = newCommentMessage.orEmpty(), + isError = pilotInlineResultIsError(newCommentMessage.orEmpty()), + contentDescription = "Issue Detail New Comment Result Web Banner", + ) + } + } + val comments = if (commentsDescending) { + data.comments.sortedByDescending { it.createdAt } + } else { + data.comments.sortedBy { it.createdAt } + } + if (comments.isEmpty()) { + item { IssueDetailCommentsWebEmptyState(zh = zh) } + } + val baseVisibleComments = comments.take(if (showExpandedCommentHistory) 8 else 2) + val highlightedComment = highlightCommentId?.let { targetId -> + comments.firstOrNull { it.id == targetId || it.parentId == targetId } + } + val visibleComments = if (highlightedComment == null || baseVisibleComments.any { it.id == highlightedComment.id }) { + baseVisibleComments + } else { + listOf(highlightedComment) + baseVisibleComments + } + items(visibleComments) { comment -> + val content = clean(comment.content) + val authorName = Models.commentAuthorDisplayName(comment, data.members, data.agents, currentUser) + val authorAvatarUrl = Models.commentAuthorAvatarUrl(comment, data.members, data.agents, currentUser) + IssueCommentCard( + comment = comment, + authorName = authorName, + authorAvatarUrl = authorAvatarUrl, + authorAgentStatus = commentAuthorAgentStatus(comment, data.agents), + expandedActions = expandedCommentActionsId == comment.id, + contentExpanded = comment.id in expandedCommentContentIds, + highlighted = comment.id == highlightCommentId, + zh = zh, + onToggleActions = { + expandedCommentActionsId = if (expandedCommentActionsId == comment.id) null else comment.id + }, + onToggleContent = { + expandedCommentContentIds = if (comment.id in expandedCommentContentIds) { + expandedCommentContentIds - comment.id + } else { + expandedCommentContentIds + comment.id + } + }, + onReplyAction = { + replyingCommentId = if (replyingCommentId == comment.id) null else comment.id + replyDraft = "" + editingCommentId = null + editingCommentContent = "" + pendingDeleteComment = null + replyMessage = null + }, + onEditAction = { + editingCommentId = comment.id + editingCommentContent = comment.content + pendingDeleteComment = null + replyingCommentId = null + replyDraft = "" + }, + onDeleteAction = { + pendingDeleteComment = comment + editingCommentId = null + editingCommentContent = "" + replyingCommentId = null + replyDraft = "" + }, + ) + if (replyingCommentId == comment.id) { + IssueCommentReplyBox( + parentId = comment.id, + draft = replyDraft, + agents = data.agents, + sending = sendingReply, + zh = zh, + onDraftChange = { replyDraft = it }, + onCancel = { + replyingCommentId = null + replyDraft = "" + replyMessage = null + }, + onSend = { sendReply(comment.id) }, + ) + } + if (!replyMessage.isNullOrBlank() && replyingCommentId == comment.id) { + PilotInlineResultState( + message = replyMessage.orEmpty(), + isError = pilotInlineResultIsError(replyMessage.orEmpty()), + contentDescription = "Issue Detail Reply Result Web Banner", + ) + } + if (comment.attachments.isNotEmpty()) { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .padding(start = 38.dp, bottom = 8.dp), + ) { + comment.attachments.forEachIndexed { index, attachment -> + CommentAttachmentWebDenseRow( + attachment = attachment, + zh = zh, + showDivider = index < comment.attachments.lastIndex, + onOpen = { openAttachment(attachment) }, + ) + } + } + } + if (editingCommentId == comment.id) { + MulticaTextField( + value = editingCommentContent, + onValueChange = { editingCommentContent = it }, + label = if (zh) "编辑评论" else "Edit comment", + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Comment Edit Field ${comment.id}" }, + minLines = 3, + maxLines = 8, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + editingCommentId = null + editingCommentContent = "" + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (zh) "保存评论" else "Save Comment", + onClick = { + val nextContent = editingCommentContent.trim() + if (nextContent.isEmpty()) return@MulticaPillButton + runCommentMutation { api.updateComment(workspaceId, comment.id, nextContent) } + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + } + if (pendingDeleteComment?.id == comment.id) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 38.dp, bottom = 8.dp) + .semantics(mergeDescendants = true) { + contentDescription = "Issue Comment Delete Cupertino Alert Preview Row ${comment.id}" + }, + ) + } + if (!commentMessage.isNullOrBlank()) { + PilotInlineResultState( + message = commentMessage.orEmpty(), + isError = pilotInlineResultIsError(commentMessage.orEmpty()), + contentDescription = "Issue Detail Comment Result Web Banner", + ) + } + } + if (comments.size > visibleComments.size) { + item { + IssueDetailWebActionRow( + eyebrow = if (zh) "评论历史" else "Comment history", + title = if (zh) { + if (showExpandedCommentHistory) { + "已显示最新 ${visibleComments.size} 条,点此收起" + } else { + "已优先显示最新 ${visibleComments.size} 条,点此查看更多" + } + } else { + if (showExpandedCommentHistory) { + "Showing latest ${visibleComments.size}; tap to collapse" + } else { + "Showing latest ${visibleComments.size}; tap for more" + } + }, + subtitle = if (zh) { + if (showExpandedCommentHistory) { + "还有 ${comments.size - visibleComments.size} 条更早评论" + } else { + "还有 ${comments.size - visibleComments.size} 条历史评论" + } + } else { + if (showExpandedCommentHistory) { + "${comments.size - visibleComments.size} older comments" + } else { + "${comments.size - visibleComments.size} older comments" + } + }, + icon = if (showExpandedCommentHistory) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + expanded = showExpandedCommentHistory, + contentDescription = "Issue Comment History Web Row", + onClick = { + showExpandedCommentHistory = !showExpandedCommentHistory + }, + ) + } + } + if (data.runs.isNotEmpty()) { + item { + SectionHeader(if (zh) "Agent 工作详情" else "Agent Work Details") + } + item { + IssueDetailWebActionRow( + eyebrow = if (zh) "Agent 工作" else "Agent Work", + title = if (zh) { + "${data.runs.size} 条运行记录" + } else { + "${data.runs.size} agent runs" + }, + subtitle = data.runs.firstOrNull()?.let { run -> + listOf(run.status, shortDate(run.startedAt), clean(run.error)) + .filter { it.isNotBlank() } + .joinToString(" · ") + }, + icon = Icons.Outlined.Bolt, + expanded = showRuns, + tone = MulticaButtonTone.Primary, + contentDescription = "Issue Agent Runs Web Toggle", + onClick = { showRuns = !showRuns }, + ) + } + if (showRuns) { + items(data.runs) { run -> + IssueDetailWebActionRow( + eyebrow = shortDate(run.startedAt), + title = "Agent run · ${run.status}", + subtitle = clean(run.error).ifBlank { null }, + icon = Icons.Outlined.Bolt, + tone = if (run.status == "failed") MulticaButtonTone.Destructive else MulticaButtonTone.Ghost, + contentDescription = "Issue Agent Run Web Row ${run.id}", + onClick = { selectedRun = run }, + ) + } + } + } + item { + IssueDetailWebActionRow( + eyebrow = if (zh) "Pull Requests" else "Pull requests", + title = if (showPullRequestsSection) { + if (zh) "收起 PR" else "Hide pull requests" + } else { + if (zh) "展开 PR" else "Show pull requests" + }, + subtitle = if (zh) "对齐 Web PullRequestList 的可折叠侧栏信息" else "Collapsible section aligned with Web PullRequestList", + icon = Icons.Outlined.Link, + expanded = showPullRequestsSection, + contentDescription = "Issue Pull Requests Web Toggle $WEB_SSOT_PULL_REQUEST_LIST", + onClick = { showPullRequestsSection = !showPullRequestsSection }, + ) + } + if (showPullRequestsSection) { + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { + contentDescription = "Issue Pull Requests Web Section $WEB_SSOT_PULL_REQUEST_LIST" + }, + ) { + IssueDetailWebActionRow( + eyebrow = if (zh) "PR" else "PR", + title = if (zh) "暂无 Pull Requests" else "No pull requests", + subtitle = if (zh) "Android 端目前保留 Web 信息层级入口" else "Android keeps the Web information hierarchy entry point", + icon = Icons.Outlined.Link, + contentDescription = "Issue Pull Request Web Empty Row", + ) + } + } + } + item { + SectionHeader(if (zh) "附件" else "Attachments") + } + item { + IssueAttachmentsWebPanel( + attachments = data.attachments, + deletingAttachmentId = deletingAttachmentId, + zh = zh, + onOpen = { openAttachment(it) }, + onDelete = { deleteAttachment(it) }, + ) + } + if (!attachmentMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = attachmentMessage.orEmpty(), + isError = pilotInlineResultIsError(attachmentMessage.orEmpty()), + contentDescription = "Issue Detail Attachment Result Web Banner", + ) + } + } + item { + SectionHeader(if (zh) "Issue 任务" else "Issue Tasks") + } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Issue Tasks Web Group" }, + ) { + if (data.activeTasks.isEmpty()) { + IssueDetailWebActionRow( + eyebrow = if (zh) "执行" else "Execution", + title = if (zh) "暂无活跃任务" else "No active tasks", + subtitle = if (zh) "运行中、排队中和派发中的任务会显示在这里" else "Running, queued, and dispatched tasks will appear here", + icon = Icons.Outlined.Bolt, + contentDescription = "Issue Task Web Empty Row", + showDivider = data.runs.isNotEmpty(), + ) + } else { + data.activeTasks.forEachIndexed { index, task -> + val summary = clean(task.triggerSummary) + .ifBlank { clean(task.failureReason) } + .ifBlank { clean(task.workDir) } + .ifBlank { Models.shortId(task.id) } + IssueDetailWebActionRow( + eyebrow = if (zh) "活跃任务" else "Active task", + title = task.status.ifBlank { Models.shortId(task.id) }, + subtitle = summary, + icon = Icons.Outlined.Bolt, + tone = MulticaButtonTone.Primary, + contentDescription = "Issue Task Web Active Row ${task.id}", + showDivider = true, + ) + IssueDetailWebActionRow( + eyebrow = if (zh) "操作" else "Action", + title = if (cancellingIssueTaskId == task.id) { + if (zh) "取消中..." else "Cancelling..." + } else { + if (zh) "取消任务" else "Cancel Task" + }, + subtitle = if (zh) "终止这个仍在运行或排队的任务" else "Terminate this running or queued task", + icon = Icons.Outlined.Close, + tone = MulticaButtonTone.Destructive, + contentDescription = "Issue Task Web Cancel Row ${task.id}", + showDivider = index < data.activeTasks.lastIndex || data.runs.isNotEmpty(), + onClick = { pendingTerminateTask = task }, + ) + } + } + if (data.runs.isNotEmpty()) { + IssueDetailWebActionRow( + eyebrow = if (zh) "历史" else "History", + title = if (zh) "任务历史" else "Task history", + subtitle = if (zh) "${data.runs.size} 条历史运行" else "${data.runs.size} historical runs", + icon = Icons.Outlined.Dashboard, + contentDescription = "Issue Task Web History Row", + ) + } + } + } + if (!issueTaskMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = issueTaskMessage.orEmpty(), + isError = pilotInlineResultIsError(issueTaskMessage.orEmpty()), + contentDescription = "Issue Detail Task Result Web Banner", + ) + } + } + item { + SectionHeader(if (zh) "用量" else "Usage") + } + item { + IssueDetailUsageCompact(data.usage, zh) + } + item { + SectionHeader(if (zh) "时间线" else "Timeline") + } + item { + val entries = data.timeline?.entries.orEmpty() + olderTimelineEntries + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Issue Timeline Web Dense Row Group" }, + ) { + if (data.timeline == null) { + IssueTimelineWebDenseRow( + title = if (zh) "时间线暂不可用" else "Timeline unavailable", + subtitle = if (zh) "稍后重试或查看网页版" else "Try again later or check the web app", + kind = if (zh) "状态" else "Status", + highlighted = false, + ) + } else if (entries.isEmpty()) { + IssueTimelineWebDenseRow( + title = if (zh) "暂无时间线记录" else "No timeline events", + subtitle = if (zh) "评论和活动会显示在这里" else "Comments and activity will appear here", + kind = if (zh) "空" else "Empty", + highlighted = false, + ) + } else { + val visibleTimelineEntries = entries.take(30) + visibleTimelineEntries.forEachIndexed { index, entry -> + val isComment = entry.type == "comment" + val highlighted = index == data.timeline.targetIndex + val title = if (isComment) { + clean(entry.content).ifBlank { if (zh) "评论" else "Comment" } + } else { + clean(entry.action).ifBlank { if (zh) "活动" else "Activity" } + } + val subtitle = if (isComment) { + listOf(shortDate(entry.createdAt), clean(entry.action)) + .filter { it.isNotBlank() } + .joinToString(" · ") + } else { + clean(entry.content).ifBlank { shortDate(entry.createdAt) } + } + IssueTimelineWebDenseRow( + title = title, + subtitle = subtitle, + kind = if (isComment) { + if (zh) "评论" else "Comment" + } else { + if (zh) "活动" else "Activity" + }, + highlighted = highlighted, + showDivider = index < visibleTimelineEntries.lastIndex || timelineHasMoreBefore, + ) + } + if (timelineHasMoreBefore && timelineNextCursor.isNotBlank()) { + IssueTimelineWebDenseRow( + title = if (loadingOlderTimeline) { + if (zh) "加载中..." else "Loading..." + } else { + if (zh) "加载更早时间线" else "Load older timeline" + }, + subtitle = if (zh) "继续读取更早的活动记录" else "Fetch older activity entries", + kind = if (zh) "更多" else "More", + highlighted = false, + contentDescription = "Issue Timeline Load Older", + onClick = { loadOlderTimeline() }, + ) + } + } + } + } + if (!timelineMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = timelineMessage.orEmpty(), + isError = pilotInlineResultIsError(timelineMessage.orEmpty()), + contentDescription = "Issue Detail Timeline Result Web Banner", + ) + } + } + } + MulticaCupertinoActionSheet( + visible = issueMoreMenuOpen, + title = if (zh) "Issue 操作" else "Issue actions", + message = data.issue.identifier.ifBlank { data.issue.title }, + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "上传附件" else "Upload attachment", + subtitle = if (zh) "从设备选择文件并附加到这个 Issue" else "Choose a file from this device", + icon = Icons.Outlined.Folder, + contentDescription = "Issue Detail More Actions Sheet Issue Detail More Upload Attachment Action", + enabled = !uploadingAttachment, + onClick = { + if (!uploadingAttachment) attachmentLauncher.launch("*/*") + }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "复制 Issue 链接" else "Copy Issue link", + subtitle = if (zh) "复制网页版详情地址" else "Copy the web detail URL", + icon = Icons.Outlined.Link, + contentDescription = "Issue Detail More Actions Sheet Issue Detail More Copy Link Action", + onClick = { copyIssueLink() }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "删除 Issue" else "Delete Issue", + subtitle = if (zh) "永久删除评论、活动和附件" else "Permanently remove comments, activity, and files", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Issue Detail More Actions Sheet Issue Detail More Delete Action", + enabled = !deletingIssue, + onClick = { confirmingDeleteIssue = true }, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { issueMoreMenuOpen = false }, + ) + pendingDeleteComment?.let { comment -> + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "删除评论?" else "Delete comment?", + message = if (zh) { + "这会永久删除这条评论:${preview(clean(comment.content))}" + } else { + "This permanently deletes this comment: ${preview(clean(comment.content))}" + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (mutatingComment) "..." else if (zh) "删除" else "Delete", + destructive = true, + contentDescription = "Issue Comment Delete Cupertino Alert ${comment.id}", + onDismissRequest = { + if (!mutatingComment) pendingDeleteComment = null + }, + onConfirm = { + if (!mutatingComment) runCommentMutation { api.deleteComment(workspaceId, comment.id) } + }, + ) + } + pendingTerminateTask?.let { task -> + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "终止任务?" else "Terminate task?", + message = if (zh) { + "这会终止当前正在运行或排队的 Agent 任务。${clean(task.triggerSummary).ifBlank { Models.shortId(task.id) }}" + } else { + "This terminates the running or queued agent task. ${clean(task.triggerSummary).ifBlank { Models.shortId(task.id) }}" + }, + cancelText = if (zh) "保留" else "Keep Running", + confirmText = if (cancellingIssueTaskId == task.id) "..." else if (zh) "终止" else "Terminate", + destructive = true, + contentDescription = "Issue Task Terminate Cupertino Alert ${task.id} $WEB_SSOT_TERMINATE_TASK_CONFIRM_DIALOG", + onDismissRequest = { + if (cancellingIssueTaskId == null) pendingTerminateTask = null + }, + onConfirm = { + if (cancellingIssueTaskId == null) cancelIssueTask(task) + }, + ) + } + if (confirmingDeleteIssue) { + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "删除 Issue?" else "Delete issue?", + message = if (zh) { + "这会永久删除 ${data.issue.identifier} 以及相关评论、活动和附件。" + } else { + "This permanently deletes ${data.issue.identifier}, including related comments, activity, and files." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (deletingIssue) "..." else if (zh) "删除" else "Delete", + destructive = true, + contentDescription = "Issue Delete Cupertino Alert ${data.issue.identifier}", + onDismissRequest = { + if (!deletingIssue) confirmingDeleteIssue = false + }, + onConfirm = { + if (!deletingIssue) deleteCurrentIssue() + }, + ) + } + } + } +} + +@Composable +private fun IssueDetailCommentsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_ISSUE_DETAIL = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/issue-detail.tsx" + val WEB_SSOT_ISSUE_COMMENT_INPUT = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/comment-input.tsx" + Box( + modifier = modifier.semantics { + contentDescription = "Issue Detail Comments Web Empty State $WEB_SSOT_ISSUE_DETAIL $WEB_SSOT_ISSUE_COMMENT_INPUT" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有评论" else "No comments yet", + description = if (zh) "留下评论..." else "Leave a comment...", + ) + } +} + +private suspend fun attachIssueLabelWithRetry( + api: ApiClient, + workspaceId: String, + issueId: String, + labelId: String, +): List { + var lastError: Throwable? = null + repeat(4) { attempt -> + try { + return api.attachIssueLabel(workspaceId, issueId, labelId) + } catch (error: Throwable) { + lastError = error + if (attempt < 3) delay(650L * (attempt + 1)) + } + } + throw lastError ?: IllegalStateException("Label attach failed") +} + +@Composable +private fun IssueCommentReplyBox( + parentId: String, + draft: String, + agents: List, + sending: Boolean, + zh: Boolean, + onDraftChange: (String) -> Unit, + onCancel: () -> Unit, + onSend: () -> Unit, +) { + var mentionPickerOpen by remember(parentId) { mutableStateOf(false) } + var mentionQuery by remember(parentId) { mutableStateOf("") } + val mentionAgents = remember(agents, mentionQuery) { + val query = mentionQuery.trim().lowercase() + agents + .filter { it.archivedAt.isBlank() } + .filter { agent -> + query.isBlank() || + agent.name.lowercase().contains(query) || + agent.description.lowercase().contains(query) || + agent.id.lowercase().contains(query) + } + .sortedBy { it.name.lowercase() } + .take(8) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(start = 38.dp, bottom = 8.dp) + .semantics(mergeDescendants = false) { contentDescription = "Issue Comment Web Reply Editor $parentId" }, + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Surface.copy(alpha = 0.82f), + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.76f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = if (zh) "回复评论" else "Reply to comment", + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + ) + MulticaTextField( + value = draft, + onValueChange = onDraftChange, + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = "Issue Comment Reply Input $parentId" }, + label = if (zh) "写回复..." else "Write a reply...", + minLines = 2, + maxLines = 5, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaPillButton( + text = "@", + onClick = { mentionPickerOpen = !mentionPickerOpen }, + tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, + contentDescription = "Issue Comment Reply Agent Mention", + ) + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + tone = MulticaButtonTone.Ghost, + modifier = Modifier.weight(1f), + ) + MulticaPillButton( + text = if (sending) "..." else if (zh) "发送回复" else "Send Reply", + onClick = onSend, + tone = MulticaButtonTone.Primary, + enabled = !sending && draft.trim().isNotEmpty(), + modifier = Modifier.weight(1f), + ) + } + if (mentionPickerOpen) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Comment Reply Mention Web Picker" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (zh) "选择 Agent" else "Mention agent", + modifier = Modifier.semantics { contentDescription = "Issue Comment Mention Web Header" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + MulticaTextField( + value = mentionQuery, + onValueChange = { mentionQuery = it }, + modifier = Modifier.fillMaxWidth(), + contentDescription = "Issue Comment Reply Mention Search", + label = if (zh) "搜索 Agent" else "Search agents", + maxLines = 1, + ) + if (mentionAgents.isEmpty()) { + Text( + text = if (zh) "没有可提及的 Agent" else "No agents available", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + ) + } else { + mentionAgents.forEach { agent -> + IssueMentionAgentWebRow( + agent = agent, + contentDescription = "Issue Comment Reply Mention Agent Row ${agent.id}", + onClick = { + val insertion = AgentMentionMarkdown.markdown(agent.name, agent.id) + val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " + onDraftChange(draft + separator + insertion + " ") + mentionPickerOpen = false + mentionQuery = "" + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun IssueMentionAgentWebRow( + agent: Models.Agent, + contentDescription: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + semanticsOnClick(label = agent.name.ifBlank { Models.shortId(agent.id) }) { + onClick() + true + } + }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.SurfaceElevated.copy(alpha = 0.78f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(27.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = 0.24f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = agent.name.take(1).uppercase().ifBlank { "A" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = agent.name.ifBlank { Models.shortId(agent.id) }, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = AgentMentionMarkdown.markdown(agent.name, agent.id), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.TextTertiary, + modifier = Modifier.size(17.dp), + ) + } + } +} + +@Composable +private fun IssueCommentInputBar( + draft: String, + pendingAttachments: List, + agents: List, + uploadingAttachment: Boolean, + sending: Boolean, + zh: Boolean, + onDraftChange: (String) -> Unit, + onAttach: () -> Unit, + onSend: () -> Unit, +) { + var mentionPickerOpen by remember { mutableStateOf(false) } + var mentionQuery by remember { mutableStateOf("") } + val mentionAgents = remember(agents, mentionQuery) { + val query = mentionQuery.trim().lowercase() + agents + .filter { it.archivedAt.isBlank() } + .filter { agent -> + query.isBlank() || + agent.name.lowercase().contains(query) || + agent.description.lowercase().contains(query) || + agent.id.lowercase().contains(query) + } + .sortedBy { it.name.lowercase() } + .take(12) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .imePadding(), + color = MulticaColors.Background.copy(alpha = 0f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + if (pendingAttachments.isNotEmpty()) { + Text( + text = if (zh) { + "待发送附件 ${pendingAttachments.size}" + } else { + "${pendingAttachments.size} attachment(s) pending" + }, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Issue Comment Cupertino Composer mirrors Web comment-input.tsx's rounded editor surface. + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.86f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.Bottom, + ) { + MulticaPillButton( + text = if (uploadingAttachment) "..." else "+", + onClick = onAttach, + tone = MulticaButtonTone.Secondary, + contentDescription = "Issue Comment Attach", + ) + MulticaPillButton( + text = "@", + onClick = { mentionPickerOpen = !mentionPickerOpen }, + tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, + contentDescription = "Issue Comment Agent Mention", + ) + MulticaTextField( + value = draft, + onValueChange = onDraftChange, + modifier = Modifier.weight(1f), + label = if (zh) "写评论..." else "Write a comment...", + contentDescription = "Issue Comment Input", + minLines = 1, + maxLines = 4, + ) + MulticaIconPillButton( + icon = Icons.AutoMirrored.Outlined.Send, + contentDescription = if (zh) "发送评论" else "Send Comment", + enabled = !sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty()), + onClick = onSend, + tone = MulticaButtonTone.Primary, + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(999.dp)) + .background( + if (!sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty())) { + MulticaColors.Accent + } else { + MulticaColors.Border.copy(alpha = 0.42f) + } + ), + ) + } + } + if (mentionPickerOpen) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Issue Comment Mention Web Picker" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (zh) "选择 Agent" else "Mention agent", + modifier = Modifier.semantics { contentDescription = "Issue Comment Mention Web Header" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + MulticaTextField( + value = mentionQuery, + onValueChange = { mentionQuery = it }, + modifier = Modifier.fillMaxWidth(), + contentDescription = "Issue Comment Mention Search", + label = if (zh) "搜索 Agent" else "Search agents", + singleLine = true, + ) + if (mentionAgents.isEmpty()) { + Text( + text = if (zh) "没有可提及的 Agent" else "No agents available", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + ) + } else { + mentionAgents.forEach { agent -> + IssueMentionAgentWebRow( + agent = agent, + contentDescription = "Issue Comment Mention Agent Row ${agent.id}", + onClick = { + val insertion = AgentMentionMarkdown.markdown(agent.name, agent.id) + val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " + onDraftChange(draft + separator + insertion + " ") + mentionPickerOpen = false + mentionQuery = "" + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun PilotAgentTranscript( + api: ApiClient, + workspaceId: String, + run: Models.AgentTask, + zh: Boolean, + onBack: () -> Unit, +) { + var state by remember(run.id) { mutableStateOf>?>(null) } + var refresh by remember(run.id) { mutableIntStateOf(0) } + val context = LocalContext.current + val WEB_SSOT_AGENT_TRANSCRIPT_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/agent-transcript-dialog.tsx" + val WEB_SSOT_AGENT_TRANSCRIPT_BUILD_TIMELINE = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/build-timeline.ts" + val WEB_SSOT_AGENT_TRANSCRIPT_REDACT = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/redact.ts" + LaunchedEffect(run.id, workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { api.runMessages(run.id, workspaceId) } + } + } + + val loaded = state + val loadedMessages = loaded?.getOrNull().orEmpty() + fun copyTranscriptMessages(messages: List) { + val text = messages.joinToString("\n") { message -> + val label = listOf(message.type, message.tool).filter { clean(it).isNotBlank() }.joinToString(" · ") + .ifBlank { "Event" } + val body = TranscriptRedactor.redactSecrets(messageSummary(message)) + "[${message.seq}] [$label] $body" + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Multica agent transcript", text)) + } + PilotListPage( + title = if (zh) "Agent 记录" else "Agent Transcript", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + if (loadedMessages.isNotEmpty()) { + MulticaPillButton( + text = if (zh) "复制" else "Copy", + icon = Icons.Outlined.ContentCopy, + contentDescription = "Agent Transcript Copy All $WEB_SSOT_AGENT_TRANSCRIPT_DIALOG $WEB_SSOT_AGENT_TRANSCRIPT_BUILD_TIMELINE $WEB_SSOT_AGENT_TRANSCRIPT_REDACT", + onClick = { copyTranscriptMessages(loadedMessages) }, + tone = MulticaButtonTone.Ghost, + ) + } + }, + ) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = "Agent Transcript Fullscreen Secondary Page Agent Transcript Web Hero $WEB_SSOT_AGENT_TRANSCRIPT_DIALOG $WEB_SSOT_AGENT_TRANSCRIPT_BUILD_TIMELINE $WEB_SSOT_AGENT_TRANSCRIPT_REDACT" + }, + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.72f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Accent.copy(alpha = 0.12f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = null, + tint = MulticaColors.Accent, + modifier = Modifier.size(18.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "Agent run · ${run.status}", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = listOf(shortDate(run.startedAt), clean(run.error)) + .filter { it.isNotBlank() } + .joinToString(" · ") + .ifBlank { if (zh) "执行记录" else "Execution transcript" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + when { + loaded == null -> { + item { + AgentTranscriptTimelineRow( + seq = null, + type = if (zh) "加载中" else "Loading", + tool = "", + title = if (zh) "正在加载 Agent 记录..." else "Loading transcript...", + subtitle = if (zh) "详情页和返回入口会保持可用" else "The transcript shell and Back action remain available", + detail = null, + timestamp = "", + contentDescription = "Agent Transcript Web Loading Row", + ) + } + } + loaded.isFailure -> { + item { + MulticaErrorState( + message = "${if (zh) "Agent 记录加载失败" else "Transcript failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", + onRetry = { refresh++ }, + ) + } + } + else -> { + val messages = loaded.getOrThrow() + if (messages.isEmpty()) { + item { AgentTranscriptWebEmptyState(zh = zh) } + } + items(messages) { message -> + val redacted = TranscriptRedactor.redactSecrets(messageSummary(message)) + AgentTranscriptTimelineRow( + seq = message.seq, + type = message.type, + tool = message.tool, + title = redacted.lineSequence().firstOrNull().orEmpty().ifBlank { + if (zh) "消息" else "Message" + }, + subtitle = preview(redacted), + detail = redacted, + timestamp = message.createdAt, + contentDescription = "Agent Transcript Web Timeline Row ${message.seq}", + ) + } + } + } + } +} + +@Composable +private fun AgentTranscriptWebEmptyState( + zh: Boolean, + live: Boolean = false, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_AGENT_TRANSCRIPT_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/agent-transcript-dialog.tsx" + val WEB_SSOT_AGENT_TRANSCRIPT_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/agents.json" + Box( + modifier = modifier.semantics { + contentDescription = "Agent Transcript Web Empty State $WEB_SSOT_AGENT_TRANSCRIPT_DIALOG $WEB_SSOT_AGENT_TRANSCRIPT_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (live) { + if (zh) "等待事件中..." else "Waiting for events..." + } else { + if (zh) "未记录执行数据。" else "No execution data recorded." + }, + description = if (zh) "智能体执行记录" else "Agent Execution Transcript", + ) + } +} + +@Composable +private fun AgentTranscriptTimelineRow( + seq: Int?, + type: String, + tool: String, + title: String, + subtitle: String?, + detail: String?, + timestamp: String, + contentDescription: String, +) { + var expanded by remember(seq, detail) { mutableStateOf(false) } + val context = LocalContext.current + val safeDetail = TranscriptRedactor.redactSecrets(detail.orEmpty()) + val canExpand = safeDetail.length > 180 || safeDetail.lineSequence().count() > 3 + val displayTimestamp = timestamp.takeIf { it.isNotBlank() }?.let { shortDate(it) } ?: seq?.let { "#$it" }.orEmpty() + val accent = when { + type.contains("error", ignoreCase = true) -> MulticaColors.Danger + type.contains("tool", ignoreCase = true) -> MulticaColors.Accent + type.contains("thinking", ignoreCase = true) -> MulticaColors.Success + else -> MulticaColors.Muted + } + val typeIcon = transcriptTypeIcon(type) + fun copyRow() { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val label = listOf(type, tool).filter { it.isNotBlank() }.joinToString(" · ").ifBlank { "Event" } + clipboard.setPrimaryClip(ClipData.newPlainText("Multica transcript row", "[$label] $safeDetail")) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .then(if (canExpand) Modifier.clickable { expanded = !expanded } else Modifier) + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface.copy(alpha = 0.82f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.64f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.Top, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Surface( + modifier = Modifier.size(24.dp), + shape = RoundedCornerShape(999.dp), + color = accent.copy(alpha = 0.12f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = typeIcon, + contentDescription = "Agent Transcript Event Type Icon", + tint = accent, + modifier = Modifier.size(14.dp), + ) + } + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = listOf(type, tool).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.SemiBold, + ), + color = accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (expanded && safeDetail.isNotBlank()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .semantics { this.contentDescription = "Agent Transcript Detail Monospace" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.SurfaceElevated.copy(alpha = 0.72f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.58f)), + ) { + Text( + text = if (safeDetail.length > 4000) safeDetail.take(4000) + "\n... (truncated)" else safeDetail, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + lineHeight = 13.sp, + fontFamily = FontFamily.Monospace, + ), + color = if (type.contains("error", ignoreCase = true)) MulticaColors.Danger else MulticaColors.Muted, + ) + } + } + } + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = displayTimestamp, + modifier = Modifier.semantics { this.contentDescription = "Agent Transcript Event Timestamp" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, lineHeight = 11.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.TextTertiary, + maxLines = 1, + ) + MulticaIconPillButton( + icon = Icons.Outlined.ContentCopy, + contentDescription = "Agent Transcript Row Copy Action", + onClick = { copyRow() }, + tone = MulticaButtonTone.Ghost, + ) + } + } + } +} + +private fun transcriptTypeIcon(type: String): ImageVector { + return when { + type.contains("tool", ignoreCase = true) -> Icons.Outlined.Bolt + type.contains("result", ignoreCase = true) -> Icons.Outlined.CheckCircle + type.contains("thinking", ignoreCase = true) -> Icons.Outlined.Dashboard + type.contains("error", ignoreCase = true) -> Icons.Outlined.Close + else -> Icons.Outlined.ChatBubbleOutline + } +} + +@Composable +private fun PilotAgentTranscriptPreview() { + val WEB_SSOT_AGENT_TRANSCRIPT_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/agent-transcript-dialog.tsx" + val WEB_SSOT_AGENT_TRANSCRIPT_BUILD_TIMELINE = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/build-timeline.ts" + val WEB_SSOT_AGENT_TRANSCRIPT_REDACT = "/Users/park0er/coding/multica-web-source/packages/views/common/task-transcript/redact.ts" + PilotListPage( + title = "Agent Transcript", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + trailing = { + MulticaPillButton( + text = "Copy", + icon = Icons.Outlined.ContentCopy, + contentDescription = "Agent Transcript Copy All QA Preview", + onClick = {}, + tone = MulticaButtonTone.Ghost, + ) + }, + ) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = "Agent Transcript QA Preview Agent Transcript Fullscreen Secondary Page Agent Transcript Web Hero $WEB_SSOT_AGENT_TRANSCRIPT_DIALOG $WEB_SSOT_AGENT_TRANSCRIPT_BUILD_TIMELINE $WEB_SSOT_AGENT_TRANSCRIPT_REDACT" + }, + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.72f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Accent.copy(alpha = 0.12f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = null, + tint = MulticaColors.Accent, + modifier = Modifier.size(18.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "Agent run · completed", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "May 15, 2026 · Execution transcript", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + item { + AgentTranscriptTimelineRow( + seq = 7, + type = "tool_result", + tool = "Bash", + title = "token=[REDACTED API KEY]", + subtitle = "Long log stays collapsed; tap to inspect monospace detail.", + detail = """ + command output + TOKEN=sk-preview_12345678901234567890 + Bearer abc.def-123 + ghp_abcdefghijklmnopqrstuvwxyz1234567890 + Long log content stays collapsed until opened, matching the Web transcript density model. + """.trimIndent(), + timestamp = "2026-05-15T08:30:00Z", + contentDescription = "Agent Transcript Web Timeline Row Preview", + ) + } + item { + AgentTranscriptWebEmptyState(zh = false) + } + } +} + +@Composable +private fun MarkdownBlock(markdown: String) { + val textColor = MulticaColors.Text.toArgb() + val mutedColor = MulticaColors.Muted.toArgb() + val borderColor = MulticaColors.Border.toArgb() + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + }, + update = { view -> + view.removeAllViews() + MarkdownRenderer.render( + view.context, + view, + markdown, + textColor, + mutedColor, + borderColor, + ) + }, + ) +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + lineHeight = 20.sp, + ), + color = MulticaColors.Muted, + modifier = Modifier.padding(top = MulticaVisualTokens.SectionTop, bottom = MulticaVisualTokens.SectionBottom), + ) +} + +private fun projectProgressLabel(project: Models.Project, zh: Boolean): String { + val done = project.doneCount.coerceAtLeast(0) + val total = project.issueCount.coerceAtLeast(0) + return if (zh) "$done/$total 完成" else "$done/$total Done" +} + +private fun projectLeadLabel(project: Models.Project, zh: Boolean): String? { + val type = project.leadType + val id = project.leadId + if (type.isBlank() || id.isBlank()) return null + val label = when (type) { + "member" -> if (zh) "成员" else "Member" + "agent" -> "Agent" + else -> type.replaceFirstChar { it.uppercaseChar() } + } + return "$label ${Models.shortId(id)}" +} + +@Composable +private fun PilotProjectDetail( + api: ApiClient, + workspaceId: String, + project: Models.Project, + zh: Boolean, + onBack: () -> Unit, + onIssueClick: (String) -> Unit, +) { + var state by remember(project.id, workspaceId) { mutableStateOf?>(null) } + var refresh by remember(project.id, workspaceId) { mutableIntStateOf(0) } + var resourceUrl by remember(project.id, workspaceId) { mutableStateOf("") } + var addResourceOpen by remember(project.id, workspaceId) { mutableStateOf(false) } + var expandedResourceActionsId by remember(project.id, workspaceId) { mutableStateOf(null) } + var pendingDeleteResource by remember(project.id, workspaceId) { mutableStateOf(null) } + var mutatingResource by remember(project.id, workspaceId) { mutableStateOf(false) } + var resourceResult by remember(project.id, workspaceId) { mutableStateOf(null) } + var mutatingPin by remember(project.id, workspaceId) { mutableStateOf(false) } + var pinResult by remember(project.id, workspaceId) { mutableStateOf(null) } + var currentPinned by remember(project.id, workspaceId) { mutableStateOf(null) } + var projectActionsOpen by remember(project.id, workspaceId) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(project.id, workspaceId, refresh) { + state = null + val loaded = withContext(Dispatchers.IO) { + runCatching { + val projectIssues = Models.STATUS_VALUES.flatMap { status -> + api.projectIssues(workspaceId, project.id, status, 80, 0).items + }.distinctBy { it.id } + val resources = api.projectResources(workspaceId, project.id).items.sortedBy { it.position } + val pinned = api.pins(workspaceId).any { it.itemType == "project" && it.itemId == project.id } + PilotProjectDetailData(project, projectIssues, resources, pinned) + } + } + state = loaded + loaded.onSuccess { currentPinned = it.isPinned } + } + + fun toggleProjectPin(isPinned: Boolean) { + if (mutatingPin) return + mutatingPin = true + pinResult = null + val nextPinned = !isPinned + scope.launch { + val result = withContext(Dispatchers.IO) { + if (nextPinned) runCatching { api.createPin(workspaceId, "project", project.id) } + else runCatching { api.deletePin(workspaceId, "project", project.id) } + } + mutatingPin = false + result.onSuccess { + currentPinned = nextPinned + refresh++ + pinResult = if (nextPinned) { + if (zh) "项目已置顶" else "Project pinned" + } else { + if (zh) "项目已取消置顶" else "Project unpinned" + } + }.onFailure { + pinResult = "${if (zh) "置顶操作失败" else "Pin failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun copyProjectLink() { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Multica project link", "multica://projects/${project.id}")) + pinResult = if (zh) "项目链接已复制" else "Project link copied" + } + + fun deleteProjectResource(resource: Models.ProjectResource) { + if (mutatingResource) return + mutatingResource = true + resourceResult = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteProjectResource(workspaceId, project.id, resource.id) } + } + mutatingResource = false + result.onSuccess { + pendingDeleteResource = null + resourceResult = if (zh) "资源已删除" else "Resource deleted" + refresh++ + }.onFailure { + resourceResult = "${if (zh) "删除失败" else "Delete failed"}: ${it.message ?: it.toString()}" + } + } + } + + val loadedData = state?.getOrNull() + val headerPinned = currentPinned ?: loadedData?.isPinned ?: false + + PilotListPage( + title = project.name.ifBlank { if (zh) "项目" else "Project" }, + leading = { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = if (zh) "返回" else "Back", + onClick = onBack, + tone = MulticaButtonTone.Ghost, + ) + }, + trailing = { + Box(modifier = Modifier.semantics { contentDescription = "Project Detail Cupertino More Menu" }) { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Project Detail More Actions", + onClick = { projectActionsOpen = true }, + tone = MulticaButtonTone.Ghost, + ) + MulticaCupertinoActionSheet( + visible = projectActionsOpen, + title = project.name.ifBlank { if (zh) "项目" else "Project" }, + message = if (zh) "项目操作" else "Project actions", + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { projectActionsOpen = false }, + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (headerPinned) { + if (zh) "取消置顶项目" else "Unpin Project" + } else { + if (zh) "置顶项目" else "Pin Project" + }, + subtitle = if (zh) "保存到工作区置顶项" else "Save this project in pinned workspace items", + contentDescription = "Project Detail More Pin Action ${project.name}", + selected = headerPinned, + onClick = { + projectActionsOpen = false + toggleProjectPin(headerPinned) + }, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "复制项目链接" else "Copy Project Link", + subtitle = if (zh) "复制可回到此项目的链接" else "Copy a link back to this project", + contentDescription = "Project Detail More Copy Link Action ${project.name}", + onClick = { + projectActionsOpen = false + copyProjectLink() + }, + ), + ), + ) + } + }, + ) { + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载项目..." else "Loading project...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "项目加载失败" else "Project failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { data -> + item { + ProjectDetailWebHero( + project = data.project, + issueCount = data.issues.size, + resourceCount = data.resources.size, + isPinned = headerPinned, + zh = zh, + ) + } + item { + SettingsWebTabGroup( + title = if (zh) "属性" else "Properties", + subtitle = if (zh) "状态、优先级、负责人" else "Status, priority, and lead", + contentDescription = "Project Detail Web Properties", + ) { + ProjectPropertyWebRow( + label = if (zh) "状态" else "Status", + value = Models.projectStatusLabel(data.project.status, zh), + icon = Icons.Outlined.CheckCircle, + ) + ProjectPropertyWebRow( + label = if (zh) "优先级" else "Priority", + value = Models.priorityLabel(data.project.priority, zh), + icon = Icons.Outlined.Flag, + ) + ProjectPropertyWebRow( + label = if (zh) "负责人" else "Lead", + value = projectLeadLabel(data.project, zh) ?: if (zh) "未设置" else "No lead", + icon = Icons.Outlined.Person, + ) + ProjectPropertyWebRow( + label = if (zh) "创建时间" else "Created", + value = shortDate(data.project.createdAt).ifBlank { Models.shortId(data.project.id) }, + icon = Icons.Outlined.CalendarMonth, + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "进度" else "Progress", + subtitle = projectProgressLabel(data.project, zh), + contentDescription = "Project Detail Web Progress", + ) { + ProjectProgressWebRow(data.project, data.issues.size, zh) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "描述" else "Description", + subtitle = if (data.project.description.isBlank()) { + if (zh) "暂无项目描述" else "No project description" + } else { + preview(data.project.description).orEmpty() + }, + contentDescription = "Project Detail Web Description", + ) { + Text( + text = preview(data.project.description) + ?: if (zh) "暂无项目描述" else "No project description", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 17.sp), + color = if (data.project.description.isBlank()) MulticaColors.Muted else MulticaColors.Text, + ) + } + } + if (!pinResult.isNullOrBlank()) { + item { + PilotInlineResultState( + message = pinResult.orEmpty(), + isError = pilotInlineResultIsError(pinResult.orEmpty()), + contentDescription = "Project Pin Result Web Banner", + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "资源 (${data.resources.size})" else "Resources (${data.resources.size})", + subtitle = if (zh) "GitHub 仓库引用与补充上下文" else "GitHub repository references and project context", + contentDescription = "Project Detail Web Resources", + ) { + ProjectResourceWebAddRow( + expanded = addResourceOpen, + zh = zh, + onClick = { addResourceOpen = !addResourceOpen }, + ) + if (addResourceOpen) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = resourceUrl, + onValueChange = { resourceUrl = it }, + label = if (zh) "GitHub 资源 URL" else "Resource URL", + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = "Project Resource URL Field" + }, + singleLine = true, + ) + val canAddResource = resourceUrl.trim().isNotEmpty() && !mutatingResource + MulticaPillButton( + text = if (mutatingResource) "..." else if (zh) "添加资源" else "Add Resource", + onClick = { + val url = resourceUrl.trim() + if (url.isEmpty() || mutatingResource) return@MulticaPillButton + mutatingResource = true + resourceResult = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.createProjectResource(workspaceId, project.id, "github_repo", url) } + } + mutatingResource = false + result.onSuccess { + resourceUrl = "" + addResourceOpen = false + resourceResult = if (zh) "资源已添加" else "Resource added" + refresh++ + }.onFailure { + resourceResult = "${if (zh) "添加失败" else "Add failed"}: ${it.message ?: it.toString()}" + } + } + }, + tone = MulticaButtonTone.Primary, + enabled = canAddResource, + contentDescription = "Project Resource Add Button", + ) + } + } + } + } + if (!resourceResult.isNullOrBlank()) { + item { + PilotInlineResultState( + message = resourceResult.orEmpty(), + isError = pilotInlineResultIsError(resourceResult.orEmpty()), + contentDescription = "Project Resource Result Web Banner", + ) + } + } + if (data.resources.isEmpty()) { + item { + ProjectDetailWebEmptyState( + type = ProjectDetailEmptyStateType.Resources, + zh = zh, + ) + } + } else { + items(data.resources) { resource -> + Column(modifier = Modifier.fillMaxWidth()) { + ProjectResourceWebDenseRow( + resource = resource, + expanded = expandedResourceActionsId == resource.id, + zh = zh, + onToggleActions = { + expandedResourceActionsId = if (expandedResourceActionsId == resource.id) null else resource.id + pendingDeleteResource = null + }, + onDelete = { + pendingDeleteResource = resource + expandedResourceActionsId = null + }, + ) + if (pendingDeleteResource?.id == resource.id) { + ProjectResourceDeleteConfirmWebPanel( + resource = resource, + zh = zh, + isDeleting = mutatingResource, + onCancel = { pendingDeleteResource = null }, + onConfirm = { deleteProjectResource(resource) }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } + } + } + } + item { SectionHeader(if (zh) "Issues (${data.issues.size})" else "Issues (${data.issues.size})") } + if (data.issues.isEmpty()) { + item { + ProjectDetailWebEmptyState( + type = ProjectDetailEmptyStateType.Issues, + zh = zh, + ) + } + } else { + items(data.issues) { issue -> + ProjectDetailIssueWebDenseRow( + issue = issue, + zh = zh, + onClick = { onIssueClick(issue.id) }, + ) + } + } + } + } + } + } +} + +private enum class ProjectDetailEmptyStateType { + Resources, + Issues, +} + +@Composable +private fun ProjectDetailWebEmptyState( + type: ProjectDetailEmptyStateType, + zh: Boolean, +) { + val WEB_SSOT_PROJECT_DETAIL = "/Users/park0er/coding/multica-web-source/packages/views/projects/components/project-detail.tsx" + val WEB_SSOT_PROJECT_RESOURCES_SECTION = "/Users/park0er/coding/multica-web-source/packages/views/projects/components/project-resources-section.tsx" + val title = when (type) { + ProjectDetailEmptyStateType.Resources -> if (zh) "还没有关联资源。" else "No resources attached." + ProjectDetailEmptyStateType.Issues -> if (zh) "还没有关联的 issue" else "No issues linked" + } + val description = when (type) { + ProjectDetailEmptyStateType.Resources -> null + ProjectDetailEmptyStateType.Issues -> if (zh) "新建一个 issue,或把已有的 issue 关联到这个项目。" else "Create a new issue or assign existing ones to this project." + } + val marker = when (type) { + ProjectDetailEmptyStateType.Resources -> "Project Detail Resource Empty State $WEB_SSOT_PROJECT_RESOURCES_SECTION" + ProjectDetailEmptyStateType.Issues -> "Project Detail Issues Empty State $WEB_SSOT_PROJECT_DETAIL" + } + + Box( + modifier = Modifier.semantics { + contentDescription = marker + }, + ) { + MulticaEmptyState( + title = title, + description = description, + ) + } +} + +@Composable +private fun ProjectDetailWebHero( + project: Models.Project, + issueCount: Int, + resourceCount: Int, + isPinned: Boolean, + zh: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Detail Web Hero" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 13.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = clean(project.icon).ifBlank { project.name.take(1).uppercase() }, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 17.sp, lineHeight = 20.sp), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + project.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 17.sp, + lineHeight = 21.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (isPinned) { + Text( + if (zh) "已置顶" else "Pinned", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Accent, + maxLines = 1, + ) + } + } + Text( + preview(project.description) ?: if (zh) "暂无项目描述" else "No project description", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.TextTertiary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + ProjectMetricCapsule( + text = Models.projectStatusLabel(project.status, zh), + icon = Icons.Outlined.CheckCircle, + contentDescription = "Project Detail Status Capsule", + tone = MulticaColors.Accent, + modifier = Modifier.weight(1f), + ) + ProjectMetricCapsule( + text = Models.priorityLabel(project.priority, zh), + icon = Icons.Outlined.Flag, + contentDescription = "Project Detail Priority Capsule", + tone = if (project.priority == "urgent" || project.priority == "high") MulticaColors.Warning else MulticaColors.Muted, + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + ProjectMetricCapsule( + text = if (zh) "$issueCount 个 Issue" else "$issueCount issues", + icon = Icons.Outlined.CheckCircle, + contentDescription = "Project Detail Issue Count Capsule", + tone = MulticaColors.Success, + modifier = Modifier.weight(1f), + ) + ProjectMetricCapsule( + text = if (zh) "$resourceCount 个资源" else "$resourceCount resources", + icon = Icons.Outlined.Link, + contentDescription = "Project Detail Resource Count Capsule", + tone = MulticaColors.Muted, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun ProjectPropertyWebRow( + label: String, + value: String, + icon: ImageVector, + destructive: Boolean = false, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = if (destructive) MulticaColors.Danger else MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + Text( + label, + modifier = Modifier.width(78.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + value, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = if (destructive) MulticaColors.Danger else MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ProjectProgressWebRow( + project: Models.Project, + loadedIssueCount: Int, + zh: Boolean, +) { + val total = project.issueCount.coerceAtLeast(loadedIssueCount).coerceAtLeast(0) + val done = project.doneCount.coerceIn(0, total.coerceAtLeast(project.doneCount)) + val progress = if (total > 0) (done.toFloat() / total.toFloat()).coerceIn(0f, 1f) else 0f + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Detail Web Progress" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .weight(1f) + .height(6.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Border.copy(alpha = 0.72f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(6.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Success) + ) + } + Text( + text = if (total > 0) "$done/$total" else "--", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + ) + } + Text( + text = if (zh) "完成度会随项目 Issue 变化自动更新" else "Completion follows project issue status", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ProjectResourceWebAddRow( + expanded: Boolean, + zh: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = "Project Resource Web Add Row Project Add Resource Toggle" + semanticsOnClick(label = if (zh) "添加资源" else "Add Resource") { + onClick() + true + } + } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Add, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = if (expanded) { + if (zh) "收起添加资源" else "Hide Add Resource" + } else { + if (zh) "添加资源" else "Add Resource" + }, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) "GitHub repository URL" else "GitHub repository URL", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun ProjectResourceWebDenseRow( + resource: Models.ProjectResource, + expanded: Boolean, + zh: Boolean, + onToggleActions: () -> Unit, + onDelete: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(7.dp)) { + val deleteResourceLabel = "${if (zh) "删除资源" else "Delete Resource"} ${resource.displayTitle}" + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Resource Web Dense Row ${resource.url.ifBlank { resource.displayTitle }}" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + resource.displayTitle, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOf( + resource.resourceType.ifBlank { if (zh) "资源" else "Resource" }, + resource.url.ifBlank { Models.shortId(resource.id) }, + ).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Project Resource Actions ${resource.url.ifBlank { resource.displayTitle }}", + onClick = onToggleActions, + tone = if (expanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics(mergeDescendants = true) { + contentDescription = "Project Resource Actions ${resource.url.ifBlank { resource.displayTitle }}" + }, + ) + } + } + if (expanded) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Resource Web Delete Row ${resource.displayTitle}" }, + horizontalArrangement = Arrangement.End, + ) { + MulticaPillButton( + text = deleteResourceLabel, + onClick = onDelete, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun ProjectResourceDeleteConfirmWebPanel( + resource: Models.ProjectResource, + zh: Boolean, + isDeleting: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp, bottom = 2.dp) + .semantics { + contentDescription = "Project Resource Web Delete Row ${resource.displayTitle} Project Resource Delete Web Alert Panel" + }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = if (zh) "删除资源?" else "Delete resource?", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + text = resource.displayTitle, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = if (zh) { + "此操作会从项目中移除该资源引用,不会删除远端仓库。" + } else { + "This removes the resource reference from the project. It does not delete the remote repository." + }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.08f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Danger, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (isDeleting) "..." else if (zh) "删除" else "Delete", + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Project Resource Delete Confirm Destructive" }, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun ProjectDetailIssueWebDenseRow( + issue: Models.Issue, + zh: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = "Project Detail Issue Web Dense Row ${issue.identifier.ifBlank { Models.shortId(issue.id) }}" + semanticsOnClick(label = issue.title) { + onClick() + true + } + }, + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + issue.identifier.ifBlank { Models.shortId(issue.id) }, + modifier = Modifier.width(72.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + issue.title, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${Models.statusLabel(issue.status, zh)} · ${Models.priorityLabel(issue.priority, zh)}", + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon(Icons.Outlined.KeyboardArrowRight, contentDescription = null, tint = MulticaColors.TextTertiary, modifier = Modifier.size(17.dp)) + } + } +} + +@Composable +private fun PilotProjects( + api: ApiClient, + workspaceId: String, + workspace: Models.Workspace, + projects: List, + zh: Boolean, + onProjectClick: (Models.Project) -> Unit, + onRefresh: () -> Unit, +) { + val scope = rememberCoroutineScope() + var editingProject by remember(workspaceId) { mutableStateOf(null) } + var formOpen by remember(workspaceId) { mutableStateOf(false) } + var title by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf(editingProject?.name.orEmpty()) } + var description by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf(editingProject?.description.orEmpty()) } + var icon by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf(editingProject?.icon.orEmpty()) } + var status by remember(workspaceId, editingProject?.id, formOpen) { + mutableStateOf(editingProject?.status?.takeIf { it.isNotBlank() } ?: "planned") + } + var priority by remember(workspaceId, editingProject?.id, formOpen) { + mutableStateOf(editingProject?.priority?.takeIf { it.isNotBlank() } ?: "none") + } + var leadType by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf(editingProject?.leadType.orEmpty()) } + var leadId by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf(editingProject?.leadId.orEmpty()) } + var selectedResourceUrls by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf>(emptySet()) } + var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") } + var projectMembers by remember(workspaceId) { mutableStateOf>(emptyList()) } + var projectAgents by remember(workspaceId) { mutableStateOf>(emptyList()) } + var loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) } + var saving by remember(workspaceId) { mutableStateOf(false) } + var pendingDelete by remember(workspaceId) { mutableStateOf(null) } + var deleting by remember(workspaceId) { mutableStateOf(false) } + var resultText by remember(workspaceId) { mutableStateOf(null) } + var expandedProjectActionsId by remember(workspaceId) { mutableStateOf(null) } + var projectQuery by remember(workspaceId) { mutableStateOf("") } + val visibleProjects = remember(projects, projectQuery, zh) { + val query = projectQuery.trim() + if (query.isBlank()) { + projects + } else { + projects.filter { project -> + project.name.contains(query, ignoreCase = true) + || project.description.contains(query, ignoreCase = true) + || project.icon.contains(query, ignoreCase = true) + || Models.projectStatusLabel(project.status, zh).contains(query, ignoreCase = true) + || Models.priorityLabel(project.priority, zh).contains(query, ignoreCase = true) + } + } + } + + fun openForm(project: Models.Project?) { + projectQuery = "" + editingProject = project + title = project?.name.orEmpty() + description = project?.description.orEmpty() + icon = project?.icon.orEmpty() + status = project?.status?.takeIf { it.isNotBlank() } ?: "planned" + priority = project?.priority?.takeIf { it.isNotBlank() } ?: "none" + leadType = project?.leadType.orEmpty() + leadId = project?.leadId.orEmpty() + selectedResourceUrls = emptySet() + resourceUrlsText = "" + formOpen = true + pendingDelete = null + expandedProjectActionsId = null + resultText = null + } + + fun saveProject() { + val titleText = title.trim() + if (titleText.isEmpty() || saving) { + resultText = if (zh) "项目标题不能为空" else "Project title is required" + return + } + val project = editingProject + saving = true + resultText = null + val iconText = icon.trim() + val resourceUrls = (selectedResourceUrls + resourceUrlsText + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() }) + .distinct() + .sorted() + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + if (project == null) { + api.createProject(workspaceId, titleText, description, status, priority, iconText, leadType, leadId, resourceUrls) + } else { + api.updateProject(workspaceId, project, titleText, description, status, priority, iconText, leadType, leadId) + } + } + } + saving = false + result.onSuccess { + formOpen = false + editingProject = null + projectQuery = "" + resultText = it.name + onRefresh() + }.onFailure { + resultText = "${if (zh) "项目保存失败" else "Project save failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun deleteProject(project: Models.Project) { + if (deleting) return + deleting = true + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteProject(workspaceId, project.id) } + } + deleting = false + result.onSuccess { + pendingDelete = null + resultText = if (zh) "项目已删除" else "Project deleted" + onRefresh() + }.onFailure { + resultText = "${if (zh) "项目删除失败" else "Project delete failed"}: ${it.message ?: it.toString()}" + } + } + } + + LaunchedEffect(formOpen, workspaceId) { + if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect + loadingProjectOptions = true + withContext(Dispatchers.IO) { + runCatching { api.members(workspaceId) to api.agents(workspaceId) } + }.onSuccess { (members, agents) -> + projectMembers = members + projectAgents = agents.filter { it.archivedAt.isBlank() } + }.onFailure { + resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}" + } + loadingProjectOptions = false + } + + val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) { + when (leadType) { + "member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member -> + member.displayName.ifBlank { member.email } + } ?: Models.shortId(leadId) + "agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId) + else -> if (zh) "无负责人" else "No Lead" + } + } + + if (formOpen) { + PilotListPage( + title = if (editingProject == null) { + if (zh) "新建项目" else "New Project" + } else { + if (zh) "编辑项目" else "Edit Project" + }, + leading = { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = if (zh) "返回项目列表" else "Back to Projects", + onClick = { + formOpen = false + editingProject = null + resultText = null + }, + tone = MulticaButtonTone.Ghost, + ) + }, + trailing = { + MulticaPillButton( + text = if (saving) "..." else if (zh) "保存" else "Save", + onClick = { saveProject() }, + tone = MulticaButtonTone.Primary, + enabled = title.trim().isNotEmpty() && !saving, + contentDescription = "Project Save Button", + ) + }, + bottomContent = { + MulticaHazeBottomBar( + hazeState = rememberHazeState(), + contentDescription = "Project Form Bottom Haze Save Bar", + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + formOpen = false + editingProject = null + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (editingProject == null) { + if (zh) "创建项目" else "Create Project" + } else { + if (zh) "保存项目" else "Save Project" + }, + onClick = { saveProject() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + enabled = title.trim().isNotEmpty() && !saving, + contentDescription = "Project Form Bottom Save", + ) + } + } + }, + ) { + if (!resultText.isNullOrBlank()) { + item { + val isError = resultText!!.contains("failed", true) || resultText!!.contains("失败") + PilotInlineResultState( + message = resultText.orEmpty(), + isError = isError, + contentDescription = "Project Form Result Web Banner", + ) + } + } + item { + SettingsSectionLabel(if (zh) "项目" else "Project") + ProjectFormWebPanel(contentDescription = "Project Form Identity Web Panel") { + MulticaTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Form Web Title Editor" }, + label = if (zh) "项目标题" else "Project title", + contentDescription = "Project Title Field", + singleLine = true, + ) + MulticaTextField( + value = icon, + onValueChange = { icon = it }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Form Web Icon Editor" }, + label = if (zh) "图标" else "Icon", + contentDescription = "Project Icon Field", + singleLine = true, + ) + MulticaTextField( + value = description, + onValueChange = { description = it }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Form Web Description Editor" }, + label = if (zh) "项目描述" else "Project description", + contentDescription = "Project Description Field", + minLines = 4, + ) + } + } + item { + SettingsSectionLabel(if (zh) "详情" else "Details") + ProjectFormWebPanel(contentDescription = "Project Form Details Web Panel") { + IssueFormMenuRow( + eyebrow = if (zh) "状态" else "Status", + title = Models.projectStatusLabel(status, zh), + subtitle = null, + contentDescription = "Project Status Picker", + options = Models.PROJECT_STATUS_VALUES.map { value -> + IssueFormMenuOption( + label = Models.projectStatusLabel(value, zh), + selected = value == status, + onClick = { status = value }, + ) + }, + ) + IssueFormMenuRow( + eyebrow = if (zh) "优先级" else "Priority", + title = Models.priorityLabel(priority, zh), + subtitle = null, + contentDescription = "Project Priority Picker", + options = Models.PRIORITY_VALUES.map { value -> + IssueFormMenuOption( + label = Models.priorityLabel(value, zh), + selected = value == priority, + onClick = { priority = value }, + ) + }, + ) + IssueFormMenuRow( + eyebrow = if (zh) "负责人" else "Lead", + title = selectedLeadLabel, + subtitle = if (loadingProjectOptions) { + if (zh) "正在加载成员和 Agent" else "Loading members and agents" + } else { + null + }, + contentDescription = "Project Lead Picker", + options = listOf( + IssueFormMenuOption( + label = if (zh) "无负责人" else "No Lead", + selected = leadType.isBlank() || leadId.isBlank(), + onClick = { + leadType = "" + leadId = "" + }, + ) + ) + projectMembers.map { member -> + val assigneeId = member.userId.ifBlank { member.id } + IssueFormMenuOption( + label = listOf(member.displayName.ifBlank { member.email }, if (zh) "成员" else "Member") + .filter { it.isNotBlank() } + .joinToString(" · "), + selected = leadType == "member" && (leadId == assigneeId || leadId == member.id), + onClick = { + leadType = "member" + leadId = assigneeId + }, + ) + } + projectAgents.map { agent -> + IssueFormMenuOption( + label = listOf(agent.name, "Agent").filter { it.isNotBlank() }.joinToString(" · "), + selected = leadType == "agent" && leadId == agent.id, + onClick = { + leadType = "agent" + leadId = agent.id + }, + ) + }, + ) + } + } + if (editingProject == null) { + item { + SettingsSectionLabel(if (zh) "资源" else "Resources") + ProjectFormWebPanel(contentDescription = "Project Form Resources Web Panel") { + workspace.repos + .map { it.url } + .filter { it.isNotBlank() } + .distinct() + .forEachIndexed { index, repoUrl -> + ProjectFormRepoOptionRow( + repoUrl = repoUrl, + selected = selectedResourceUrls.contains(repoUrl), + zh = zh, + onClick = { + selectedResourceUrls = if (selectedResourceUrls.contains(repoUrl)) { + selectedResourceUrls - repoUrl + } else { + selectedResourceUrls + repoUrl + } + }, + rowContentDescription = "Project Repository Option ${index + 1}", + ) + } + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaTextField( + value = resourceUrlsText, + onValueChange = { resourceUrlsText = it }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Form Web Resource URLs" }, + label = if (zh) "自定义 GitHub URLs" else "Custom GitHub URLs", + contentDescription = "Project Resource URLs Field", + minLines = 3, + ) + Text( + text = if (zh) "每行添加一个 GitHub repo URL。" else "Add one GitHub repo URL per line.", + style = MaterialTheme.typography.bodySmall, + color = MulticaColors.Muted, + ) + } + } + } + } + } + return + } + + PilotListPage( + title = if (zh) "项目" else "Projects", + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = if (zh) "新建项目" else "New Project", + onClick = { openForm(null) }, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + item { + MulticaTextField( + value = projectQuery, + onValueChange = { projectQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 8.dp), + label = if (zh) "搜索项目" else "Search projects", + contentDescription = "Project List Search", + singleLine = true, + ) + } + if (!resultText.isNullOrBlank()) { + item { + val isError = resultText!!.contains("failed", true) || resultText!!.contains("失败") + PilotInlineResultState( + message = resultText.orEmpty(), + isError = isError, + contentDescription = "Project List Result Web Banner", + ) + } + } + if (visibleProjects.isEmpty()) { + item { + ProjectsWebEmptyState( + zh = zh, + onCreateProject = { openForm(null) }, + ) + } + } + items(visibleProjects) { project -> + Column(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onProjectClick(project) } + .semantics { contentDescription = "Open Project ${project.name}" }, + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Project Web Dense Row ${project.name}" } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = clean(project.icon).ifBlank { project.name.take(1).uppercase() }, + modifier = Modifier.semantics { contentDescription = "Project Web Row Icon" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 14.sp), + color = MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val descriptionText = clean(project.description).ifBlank { + projectLeadLabel(project, zh) ?: if (zh) "暂无描述" else "No description" + } + val total = project.issueCount.coerceAtLeast(0) + val done = project.doneCount.coerceIn(0, total.coerceAtLeast(project.doneCount)) + val resourceHint = if (workspace.repos.isNotEmpty()) { + if (zh) "${workspace.repos.size} 个工作区 repo" else "${workspace.repos.size} workspace repos" + } else { + if (zh) "详情页关联资源" else "Resources in detail" + } + Text( + text = project.name, + modifier = Modifier.semantics { contentDescription = "Project Web Row Title" }, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 19.sp, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = descriptionText, + modifier = Modifier.semantics { contentDescription = "Project Web Row Description" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 14.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { + ProjectMetricCapsule( + text = Models.priorityLabel(project.priority, zh), + icon = Icons.Outlined.Flag, + contentDescription = "Project Priority Capsule ${project.name}", + tone = if (project.priority == "urgent" || project.priority == "high") MulticaColors.Warning else MulticaColors.Muted, + ) + } + item { + ProjectMetricCapsule( + text = Models.projectStatusLabel(project.status, zh), + icon = Icons.Outlined.CheckCircle, + contentDescription = "Project Status Capsule ${project.name}", + tone = MulticaColors.Accent, + ) + } + item { + ProjectMetricCapsule( + text = if (total > 0) "$done/$total" else "--", + icon = Icons.Outlined.CheckCircle, + contentDescription = "Project Issue Metrics Capsule ${project.name}", + tone = MulticaColors.Success, + showProgress = total > 0, + progress = if (total > 0) (done.toFloat() / total.toFloat()).coerceIn(0f, 1f) else 0f, + ) + } + item { + ProjectMetricCapsule( + text = resourceHint, + icon = Icons.Outlined.Link, + contentDescription = "Project Resource Hint Capsule ${project.name}", + tone = MulticaColors.Muted, + ) + } + item { + ProjectMetricCapsule( + text = shortDate(project.createdAt).ifBlank { Models.shortId(project.id) }, + icon = Icons.Outlined.CalendarMonth, + contentDescription = "Project Created Capsule ${project.name}", + tone = MulticaColors.TextTertiary, + ) + } + } + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Project Actions ${project.name}", + onClick = { + expandedProjectActionsId = if (expandedProjectActionsId == project.id) null else project.id + pendingDelete = null + formOpen = false + editingProject = null + resultText = null + }, + tone = if (expandedProjectActionsId == project.id) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics(mergeDescendants = true) { + contentDescription = "Project Actions ${project.name}" + }, + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border.copy(alpha = 0.74f)) + ) + if (expandedProjectActionsId == project.id) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp)) { + MulticaPillButton( + text = "${if (zh) "编辑项目" else "Edit Project"} ${project.name}", + onClick = { openForm(project) }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = "${if (zh) "删除项目" else "Delete Project"} ${project.name}", + onClick = { + pendingDelete = project + formOpen = false + editingProject = null + expandedProjectActionsId = null + resultText = null + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Destructive, + ) + } + } + if (pendingDelete?.id == project.id) { + ProjectDeleteConfirmWebPanel( + project = project, + zh = zh, + isDeleting = deleting, + onCancel = { pendingDelete = null }, + onConfirm = { deleteProject(project) }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } + } + } + } +} + +@Composable +private fun ProjectMetricCapsule( + text: String, + icon: ImageVector, + contentDescription: String, + tone: Color, + modifier: Modifier = Modifier, + showProgress: Boolean = false, + progress: Float = 0f, +) { + Surface( + modifier = modifier + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + }, + shape = RoundedCornerShape(999.dp), + color = MulticaColors.SurfaceElevated.copy(alpha = 0.72f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.62f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 7.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tone, + modifier = Modifier.size(11.dp), + ) + if (showProgress) { + Box( + modifier = Modifier + .width(26.dp) + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Border.copy(alpha = 0.72f)) + .semantics { this.contentDescription = "Project Progress Track" }, + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Success), + ) + } + } + Text( + text = text, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ProjectsWebEmptyState( + zh: Boolean, + onCreateProject: () -> Unit, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_PROJECTS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/projects/components/projects-page.tsx" + val WEB_SSOT_PROJECTS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/projects.json" + Box( + modifier = modifier.semantics { + contentDescription = "Projects Web Empty State $WEB_SSOT_PROJECTS_PAGE $WEB_SSOT_PROJECTS_LOCALE" + }, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MulticaEmptyState( + title = if (zh) "还没有项目" else "No projects yet", + description = null, + ) + MulticaPillButton( + text = if (zh) "创建第一个项目" else "Create your first project", + onClick = onCreateProject, + icon = Icons.Outlined.Add, + tone = MulticaButtonTone.Secondary, + contentDescription = if (zh) "创建第一个项目" else "Create your first project", + ) + } + } +} + +@Composable +private fun ProjectListLoadingSkeleton(zh: Boolean) { + PilotListPage( + title = if (zh) "项目" else "Projects", + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = if (zh) "新建项目" else "New Project", + onClick = {}, + tone = MulticaButtonTone.Ghost, + ) + }, + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 8.dp) + .semantics { contentDescription = "Project List Loading Skeleton Header" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProjectSkeletonBlock(width = 28.dp, height = 28.dp) + ProjectSkeletonBlock(modifier = Modifier.weight(1f), height = 18.dp) + ProjectSkeletonBlock(width = 72.dp, height = 18.dp) + } + } + items(4) { index -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .semantics { contentDescription = "Project List Loading Skeleton Row ${index + 1}" }, + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProjectSkeletonBlock(width = 28.dp, height = 28.dp) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(7.dp)) { + ProjectSkeletonBlock(modifier = Modifier.fillMaxWidth(0.62f), height = 14.dp) + ProjectSkeletonBlock(modifier = Modifier.fillMaxWidth(0.86f), height = 10.dp) + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + ProjectSkeletonBlock(width = 58.dp, height = 20.dp) + ProjectSkeletonBlock(width = 68.dp, height = 20.dp) + ProjectSkeletonBlock(width = 52.dp, height = 20.dp) + } + } + } + } + } +} + +@Composable +private fun ProjectSkeletonBlock( + modifier: Modifier = Modifier, + width: Dp? = null, + height: Dp, +) { + Box( + modifier = modifier + .then(if (width != null) Modifier.width(width) else Modifier) + .height(height) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Border.copy(alpha = 0.42f)), + ) +} + +@Composable +private fun PilotInlineResultBannersPreview() { + var previewSecretCopied by remember { mutableStateOf(false) } + var previewSecretVisible by remember { mutableStateOf(false) } + PilotListPage(title = "Result Banners") { + item { + SettingsSectionLabel("Inbox") + Box( + modifier = Modifier.semantics { + contentDescription = "Inbox Web Empty State Preview" + }, + ) { + InboxWebEmptyState(zh = false) + } + PilotInlineResultState( + message = "Inbox action failed: network unavailable", + isError = true, + contentDescription = "Inbox Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Inbox Detail Highlight And Archive Next Preview" + }, + ) { + CompactInfoPanel { + MulticaPillButton( + text = "Done", + icon = Icons.Outlined.CheckCircle, + contentDescription = "Inbox Issue Detail Done Archive Current", + onClick = {}, + tone = MulticaButtonTone.Primary, + ) + IssueCommentCard( + comment = remember { + Models.Comment( + JSONObject() + .put("id", "comment-preview") + .put("content", "Inbox opened this issue at the exact comment.") + .put("author_type", "member") + .put("author_name", "Multica") + .put("created_at", "2026-05-14T08:00:00Z") + ) + }, + authorName = "Multica", + expandedActions = false, + contentExpanded = true, + highlighted = true, + zh = false, + onToggleActions = {}, + onToggleContent = {}, + onReplyAction = {}, + onEditAction = {}, + onDeleteAction = {}, + ) + Text( + text = "Archive current, then open next inbox issue or return to Inbox.", + modifier = Modifier.semantics { + contentDescription = "Inbox Archive Next Selection Preview" + }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + } + item { + SettingsSectionLabel("Issue Empty State") + Box( + modifier = Modifier.semantics { + contentDescription = "Issue List Web Empty State Preview" + }, + ) { + IssueListWebEmptyState( + personal = false, + unfilteredEmpty = true, + zh = false, + ) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Issue Detail Comments Web Empty State Preview" + }, + ) { + IssueDetailCommentsWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Issue Attachments Empty Web Panel Preview" + }, + ) { + IssueAttachmentsWebPanel( + attachments = emptyList(), + deletingAttachmentId = null, + zh = false, + onOpen = {}, + onDelete = {}, + ) + } + } + item { + SettingsSectionLabel("Project Empty States") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier.semantics { + contentDescription = "Projects Web Empty State Preview" + }, + ) { + ProjectsWebEmptyState( + zh = false, + onCreateProject = {}, + ) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Project Detail Resource Empty State Preview" + }, + ) { + ProjectDetailWebEmptyState( + type = ProjectDetailEmptyStateType.Resources, + zh = false, + ) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Project Detail Issues Empty State Preview" + }, + ) { + ProjectDetailWebEmptyState( + type = ProjectDetailEmptyStateType.Issues, + zh = false, + ) + } + } + } + item { + SettingsSectionLabel("Chat") + Box( + modifier = Modifier.semantics { + contentDescription = "Chat Sessions Web Empty State Preview" + }, + ) { + ChatSessionsWebEmptyState( + hasAgents = false, + showAllSessions = true, + zh = false, + ) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Chat Window Web Empty State Preview" + }, + ) { + ChatWindowWebEmptyState( + zh = false, + onPromptClick = {}, + ) + } + PilotInlineResultState( + message = "Chat session created", + isError = false, + contentDescription = "Chat Sessions Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Chat Actions Web Preview" + }, + ) { + CompactInfoPanel { + ChatSessionListWebPanel { + ChatSessionWebDenseRow( + session = remember { + Models.ChatSession( + JSONObject() + .put("id", "chat-preview-1") + .put("title", "Review Android polish") + .put("agent_id", "agent-preview") + .put("status", "active") + .put("has_unread", true) + .put("updated_at", "2026-05-14T10:30:00Z") + ) + }, + agentName = "Design Agent", + zh = false, + showDivider = false, + deleting = false, + onClick = {}, + onDelete = {}, + ) + } + ChatMessageBubble( + message = remember { + Models.ChatMessage( + JSONObject() + .put("id", "message-preview-1") + .put("role", "assistant") + .put("content", "The mobile chat now keeps actions behind compact more menus and preserves the Haze composer.") + .put("created_at", "2026-05-14T10:31:00Z") + ) + }, + zh = false, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + ChatMessagesInlineLoadingIndicator(zh = false) + } + } + } + } + item { + SettingsSectionLabel("Search") + Box( + modifier = Modifier.semantics { + contentDescription = "Search Web Command Preview" + }, + ) { + CompactInfoPanel { + MulticaTextField( + value = "", + onValueChange = {}, + label = "Type a command or search", + contentDescription = "Search Web Command Input", + singleLine = true, + ) + SearchWebResultsGroup( + title = "Suggestions", + count = 5, + contentDescription = "Search Web Suggestions Group", + ) { + SearchWebResultRow( + icon = Icons.Outlined.Folder, + eyebrow = "Page", + title = "Open Projects", + subtitle = "Review project progress", + trailing = "Page", + contentDescription = "Search Web Suggestion Row Projects", + showDivider = true, + onClick = {}, + ) + SearchWebResultRow( + icon = Icons.Outlined.Bolt, + eyebrow = "Configure", + title = "Open Agents", + subtitle = "Manage automation members", + trailing = "Settings", + contentDescription = "Search Web Suggestion Row Agents", + showDivider = true, + onClick = {}, + ) + SearchWebResultRow( + icon = Icons.Outlined.Tune, + eyebrow = "Configure", + title = "Open Skills", + subtitle = "Manage reusable capabilities", + trailing = "Settings", + contentDescription = "Search Web Suggestion Row Skills", + showDivider = false, + onClick = {}, + ) + } + SearchWebResultsGroup( + title = "Agents", + count = 1, + contentDescription = "Search Web Results Group Agents", + ) { + SearchWebResultRow( + icon = Icons.Outlined.Bolt, + eyebrow = "active", + title = "codex", + subtitle = "Default automation agent", + trailing = "private", + contentDescription = "Search Web Agent Result Row preview-agent", + showDivider = false, + onClick = {}, + ) + } + SearchWebResultsGroup( + title = "Skills", + count = 1, + contentDescription = "Search Web Results Group Skills", + ) { + SearchWebResultRow( + icon = Icons.Outlined.Tune, + eyebrow = "Skill", + title = "agent-sync-doctor", + subtitle = "Troubleshoot synced agent skills", + trailing = "Skill", + contentDescription = "Search Web Skill Result Row preview-skill", + showDivider = false, + onClick = {}, + ) + } + } + } + Box( + modifier = Modifier.semantics { + contentDescription = "Search Web Empty State Preview" + }, + ) { + SearchWebEmptyState(zh = false) + } + PilotInlineResultState( + message = "Enter a search query", + isError = true, + contentDescription = "Search Result Web Banner Preview", + ) + } + item { + SettingsSectionLabel("Settings") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PilotInlineResultState( + message = "Create failed: network unavailable", + isError = true, + contentDescription = "API Token Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "API Token Created Secret Dialog Preview" + }, + ) { + Text( + text = "API token created dialog preview", + style = MaterialTheme.typography.bodySmall, + color = MulticaColors.Muted, + ) + } + Box( + modifier = Modifier.semantics { + contentDescription = "API Tokens Web Empty State Preview" + }, + ) { + ApiTokensWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Settings Text Rows Web Empty State Preview" + }, + ) { + SettingsTextRowsWebEmptyState(zh = false) + } + PilotInlineResultState( + message = "Save failed: network unavailable", + isError = true, + contentDescription = "Autopilot Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Autopilots Web Empty State Preview" + }, + ) { + AutopilotsWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Autopilot Detail Triggers Web Empty State Preview" + }, + ) { + AutopilotDetailTriggersWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Autopilot Detail Runs Web Empty State Preview" + }, + ) { + AutopilotDetailRunsWebEmptyState(zh = false) + } + PilotInlineResultState( + message = "Delete failed: network unavailable", + isError = true, + contentDescription = "Runtime Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Runtimes Web Empty State Preview" + }, + ) { + RuntimesWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Runtimes Web No Matches State Preview" + }, + ) { + RuntimesWebNoMatchesState(zh = false) + } + PilotInlineResultState( + message = "Create failed: label already exists", + isError = true, + contentDescription = "Label Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Labels Web Empty State Preview" + }, + ) { + LabelsWebEmptyState(zh = false) + } + PilotInlineResultState( + message = "Import failed: invalid repository URL", + isError = true, + contentDescription = "Skill Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Skills Web Empty State Preview" + }, + ) { + SkillsWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Skills Web No Matches State Preview" + }, + ) { + SkillsWebNoMatchesState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Skill Detail Supporting Files Web Empty State Preview" + }, + ) { + SkillDetailSupportingFilesWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Skill Detail Empty SKILL.md Web Editor Preview" + }, + ) { + SkillDetailWebEditorSurface(content = "", zh = false) + } + PilotInlineResultState( + message = "Submit failed: network unavailable", + isError = true, + contentDescription = "Feedback Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Archive failed: agent is running", + isError = true, + contentDescription = "Agent Result Web Banner Preview", + ) + Box( + modifier = Modifier.semantics { + contentDescription = "Agents Web Empty State Preview" + }, + ) { + AgentsWebEmptyState(zh = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Agents Web No Matches State Preview" + }, + ) { + AgentsWebNoMatchesState(zh = false, query = "runtime", archived = false) + } + Box( + modifier = Modifier.semantics { + contentDescription = "Agent Transcript Web Empty State Preview" + }, + ) { + AgentTranscriptWebEmptyState(zh = false) + } + AgentTranscriptTimelineRow( + seq = 7, + type = "tool_result", + tool = "Bash", + title = "npm test completed", + subtitle = "Result · redacted output available", + detail = "command output\nTOKEN=sk-test_12345678901234567890\nBearer abc.def-123\nLong log content stays collapsed until opened.", + timestamp = "2026-05-15T08:30:00Z", + contentDescription = "Agent Transcript Web Timeline Row Preview", + ) + PilotInlineResultState( + message = "Select an agent and enter a prompt", + isError = true, + contentDescription = "Quick Create Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Send to Agent failed: network unavailable", + isError = true, + contentDescription = "Issue Form Agent Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Status update failed: network unavailable", + isError = true, + contentDescription = "Issue Detail Mutation Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Issue link copied", + isError = false, + contentDescription = "Issue Detail Copy Link Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Subscriber action failed: permission denied", + isError = true, + contentDescription = "Issue Detail Subscriber Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Comment sent", + isError = false, + contentDescription = "Issue Detail New Comment Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Reply send failed: network unavailable", + isError = true, + contentDescription = "Issue Detail Reply Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Comment updated", + isError = false, + contentDescription = "Issue Detail Comment Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Reaction action failed: network unavailable", + isError = true, + contentDescription = "Issue Detail Reaction Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Attachment uploaded", + isError = false, + contentDescription = "Issue Detail Attachment Result Web Banner Preview", + ) + PilotInlineResultState( + message = "No linkable issues found", + isError = true, + contentDescription = "Issue Detail Hierarchy Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Cancel task failed: task already completed", + isError = true, + contentDescription = "Issue Detail Task Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Labels updated", + isError = false, + contentDescription = "Issue Detail Label Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Load older timeline failed: network unavailable", + isError = true, + contentDescription = "Issue Detail Timeline Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Project pinned", + isError = false, + contentDescription = "Project Pin Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Add failed: invalid repository URL", + isError = true, + contentDescription = "Project Resource Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Profile save failed: network unavailable", + isError = true, + contentDescription = "Account Profile Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Refresh models failed: runtime offline", + isError = true, + contentDescription = "Runtime Capability Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Accept invitation failed: network unavailable", + isError = true, + contentDescription = "My Invitations Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Invite failed: email already belongs to this workspace", + isError = true, + contentDescription = "Members Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Notification update failed: network unavailable", + isError = true, + contentDescription = "Notifications Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Workspace save failed: slug already exists", + isError = true, + contentDescription = "Workspace Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Updated 3 issues", + isError = false, + contentDescription = "Issue Batch Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Cancel failed: task already completed", + isError = true, + contentDescription = "Chat Action Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Send failed: network unavailable", + isError = true, + contentDescription = "Chat Send Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Save failed: title is required", + isError = true, + contentDescription = "Issue Form Save Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Workspace create failed: slug already exists", + isError = true, + contentDescription = "No Workspace Result Web Banner Preview", + ) + PilotInlineResultState( + message = "Cancelled tasks: 2", + isError = false, + contentDescription = "Agent Action Result Web Banner Preview", + ) + } + } + } + MulticaCupertinoSecretDialog( + visible = previewSecretVisible, + title = "API token created", + message = "This secret is shown once. Copy it now and store it somewhere safe.", + secret = "mul_preview_secret_1234567890abcdef", + copied = previewSecretCopied, + copyText = if (previewSecretCopied) "Copy again" else "Copy", + doneText = "Done", + copiedText = "Copied", + contentDescription = "API Token Created Secret Dialog Preview", + onDismissRequest = { + previewSecretVisible = false + previewSecretCopied = false + }, + onCopy = { previewSecretCopied = true }, + ) +} + +@Composable +private fun PilotInlineResultState( + message: String, + isError: Boolean, + contentDescription: String, + modifier: Modifier = Modifier, +) { + val tone = if (isError) MulticaColors.Danger else MulticaColors.Success + val icon = if (isError) Icons.Outlined.Close else Icons.Outlined.CheckCircle + Surface( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription }, + shape = RoundedCornerShape(12.dp), + color = tone.copy(alpha = 0.08f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, tone.copy(alpha = 0.20f), RoundedCornerShape(12.dp)) + .padding(horizontal = 11.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = tone, modifier = Modifier.size(17.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = tone, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun pilotInlineResultIsError(message: String): Boolean = + message.contains("failed", ignoreCase = true) || + message.contains("失败") || + message.contains("请选择") || + message.contains("请输入") || + message.startsWith("Enter ", ignoreCase = true) || + message.contains("没有可关联") + +@Composable +private fun ProjectFormWebPanel( + contentDescription: String, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.68f), shape) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } +} + +@Composable +private fun ProjectFormRepoOptionRow( + repoUrl: String, + selected: Boolean, + zh: Boolean, + onClick: () -> Unit, + rowContentDescription: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = "$rowContentDescription Project Form Web Repo Option" + semanticsOnClick(label = if (selected) { + if (zh) "取消选择仓库" else "Deselect repository" + } else { + if (zh) "选择仓库" else "Select repository" + }) { + onClick() + true + } + } + .padding(horizontal = 4.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Folder, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(16.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Text( + repoUrl, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) "工作区仓库" else "Workspace repository", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box( + modifier = Modifier + .size(22.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (selected) MulticaColors.Accent.copy(alpha = 0.14f) else MulticaColors.Background) + .border( + 1.dp, + if (selected) MulticaColors.Accent.copy(alpha = 0.44f) else MulticaColors.Border.copy(alpha = 0.74f), + RoundedCornerShape(6.dp), + ) + .semantics { contentDescription = if (selected) "Project Form Repo Selected" else "Project Form Repo Unselected" }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon(Icons.Outlined.Check, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(14.dp)) + } + } + } +} + +@Composable +private fun ProjectDeleteConfirmWebPanel( + project: Models.Project, + zh: Boolean, + isDeleting: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp, bottom = 2.dp) + .semantics { contentDescription = "Project Delete Web Alert Panel ${project.name}" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = if (zh) "删除项目?" else "Delete project?", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + text = project.name, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = if (zh) { + "此操作会删除该项目,并从项目列表、关联 Issue 和导航入口中移除。" + } else { + "This deletes the project and removes it from project lists, linked issues, and navigation surfaces." + }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.08f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Danger, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (isDeleting) "..." else if (zh) "删除" else "Delete", + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Project Delete Confirm Destructive" }, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun PilotSettings( + api: ApiClient, + session: PilotSession, + zh: Boolean, + authStore: AuthStore, + profileOpen: Boolean, + onProfileOpenChange: (Boolean) -> Unit, + onOpenSettingsPage: (PilotSettingsPage) -> Unit, + onReloadSession: () -> Unit, + onSessionUpdated: (PilotSession) -> Unit, +) { + var profileName by remember(session.user.id, session.user.name) { mutableStateOf(session.user.name) } + var profileAvatar by remember(session.user.id, session.user.avatarUrl) { mutableStateOf(session.user.avatarUrl) } + var profileSaving by remember(session.user.id) { mutableStateOf(false) } + var profileUploadingAvatar by remember(session.user.id) { mutableStateOf(false) } + var profileMessage by remember(session.user.id) { mutableStateOf(null) } + var settingsHomeStatusSummary by remember(session.user.id, session.workspace.id) { + mutableStateOf(SettingsHomeStatusSummary(null, null, null, null)) + } + var confirmingLogout by remember(session.user.id) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(session.user.id, session.workspace.id, zh) { + settingsHomeStatusSummary = withContext(Dispatchers.IO) { + val pending = runCatching { api.myInvitations().size }.getOrNull() + val tokens = runCatching { api.personalAccessTokens().size }.getOrNull() + val runtimes = runCatching { api.runtimes(session.workspace.id) }.getOrNull() + val runtimeHint = runtimes?.let { list -> + val updateRelated = list.count { runtime -> + runtime.status.contains("update", ignoreCase = true) || + runtime.status.contains("outdated", ignoreCase = true) + } + if (updateRelated > 0) { + if (zh) "$updateRelated 个更新提示" else "$updateRelated update hints" + } else { + if (zh) "详情页检查更新" else "Check updates in detail" + } + } + SettingsHomeStatusSummary( + pendingInvitationCount = pending, + tokenCount = tokens, + runtimeCount = runtimes?.size, + runtimeUpdateHint = runtimeHint, + ) + } + } + val profileAvatarLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri == null || profileUploadingAvatar) return@rememberLauncherForActivityResult + profileUploadingAvatar = true + profileMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + api.uploadFile( + session.workspace.id, + displayNameForUri(context, uri), + context.contentResolver.getType(uri) ?: "application/octet-stream", + bytes, + ) + } + } + profileUploadingAvatar = false + result.onSuccess { uploadedUrl -> + profileAvatar = uploadedUrl + profileMessage = if (zh) "头像已上传,请保存资料" else "Avatar uploaded. Save profile to apply." + }.onFailure { + profileMessage = "${if (zh) "头像上传失败" else "Avatar upload failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun saveProfile() { + val name = profileName.trim() + if (name.isBlank() || profileSaving) return + profileSaving = true + profileMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + api.updateMe( + name, + profileAvatar.trim(), + null, + ) + } + } + profileSaving = false + result.onSuccess { updatedUser -> + onProfileOpenChange(false) + profileMessage = if (zh) "账号资料已保存" else "Profile saved" + onSessionUpdated(session.copy(user = updatedUser)) + }.onFailure { + profileMessage = "${if (zh) "保存账号资料失败" else "Profile save failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun switchLanguage() { + val nextChinese = !zh + val nextLanguage = if (nextChinese) "zh-Hans" else "en" + authStore.setChinese(nextChinese) + scope.launch { + withContext(Dispatchers.IO) { + runCatching { api.updateMe(null, null, nextLanguage) } + } + onReloadSession() + } + } + val WEB_SSOT_SETTINGS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/settings-page.tsx" + val WEB_SSOT_SETTINGS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/settings.json" + + if (profileOpen) { + PilotListPage( + title = if (zh) "账号资料" else "Account Profile", + leading = { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = if (zh) "返回设置" else "Back to Settings", + onClick = { onProfileOpenChange(false) }, + tone = MulticaButtonTone.Ghost, + ) + }, + trailing = { + MulticaPillButton( + text = if (profileSaving) "..." else if (zh) "保存" else "Save", + onClick = { saveProfile() }, + tone = MulticaButtonTone.Primary, + enabled = profileName.trim().isNotBlank() && !profileSaving, + contentDescription = "Account Profile Save", + ) + }, + bottomContent = { + MulticaHazeBottomBar( + hazeState = rememberHazeState(), + contentDescription = "Account Profile Bottom Haze Save Bar", + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { onProfileOpenChange(false) }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (profileSaving) "..." else if (zh) "保存资料" else "Save Profile", + onClick = { saveProfile() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + enabled = profileName.trim().isNotBlank() && !profileSaving, + contentDescription = "Account Profile Bottom Save", + ) + } + } + }, + ) { + if (!profileMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = profileMessage.orEmpty(), + isError = pilotInlineResultIsError(profileMessage.orEmpty()), + contentDescription = "Account Profile Result Web Banner", + ) + } + } + item { + AccountProfileWebEditor( + name = profileName, + avatarUrl = profileAvatar, + uploadingAvatar = profileUploadingAvatar, + zh = zh, + onNameChange = { profileName = it }, + onAvatarUrlChange = { profileAvatar = it }, + onUploadAvatar = { if (!profileUploadingAvatar) profileAvatarLauncher.launch("image/*") }, + ) + } + } + return + } + + PilotListPage(title = if (zh) "设置" else "Settings") { + item { + Box( + modifier = Modifier.semantics(mergeDescendants = false) { + contentDescription = "Settings Home Web SSOT $WEB_SSOT_SETTINGS_PAGE $WEB_SSOT_SETTINGS_LOCALE" + }, + ) { + SettingsWebAccountHeader( + user = session.user, + workspace = session.workspace, + zh = zh, + onEditProfile = { + profileName = session.user.name + profileAvatar = session.user.avatarUrl + onProfileOpenChange(true) + profileMessage = null + }, + ) + } + } + if (!profileMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = profileMessage.orEmpty(), + isError = pilotInlineResultIsError(profileMessage.orEmpty()), + contentDescription = "Account Profile Result Web Banner", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "我的账号" else "My Account", + subtitle = if (zh) "个人资料、偏好和安全入口" else "Profile, preferences, and security", + ) { + SettingsWebTabRow( + title = if (zh) "资料" else "Profile", + subtitle = if (zh) "姓名和头像 URL" else "Name and avatar URL", + icon = Icons.Outlined.Person, + showDivider = true, + onClick = { + profileName = session.user.name + profileAvatar = session.user.avatarUrl + onProfileOpenChange(true) + profileMessage = null + }, + ) + SettingsWebTabRow( + title = if (zh) "偏好" else "Preferences", + subtitle = if (zh) "语言与本机显示偏好" else "Language and local display preference", + value = if (zh) "中文" else "English", + icon = Icons.Outlined.Tune, + showDivider = true, + onClick = { switchLanguage() }, + ) + SettingsWebTabRow( + title = if (zh) "通知" else "Notifications", + subtitle = if (zh) "Inbox 与系统通知分组" else "Inbox and system notification groups", + icon = Icons.Outlined.Bolt, + showDivider = true, + onClick = { onOpenSettingsPage(PilotSettingsPage.Notifications) }, + ) + SettingsWebTabRow( + title = if (zh) "API 令牌" else "API Tokens", + subtitle = if (zh) "个人访问令牌" else "Personal access tokens", + value = settingsHomeStatusSummary.tokenCount?.let { + if (zh) "$it 个令牌" else "$it tokens" + } ?: "...", + icon = Icons.Outlined.Link, + showDivider = true, + contentDescription = "Settings API Tokens Summary Row", + onClick = { onOpenSettingsPage(PilotSettingsPage.ApiTokens) }, + ) + SettingsWebTabRow( + title = if (zh) "我的邀请" else "My Invitations", + subtitle = if (zh) "待处理工作区邀请" else "Pending workspace invitations", + value = settingsHomeStatusSummary.pendingInvitationCount?.let { + if (zh) "$it 个待处理" else "$it pending" + } ?: "!", + icon = Icons.Outlined.Archive, + contentDescription = "Settings My Invitations Pending Count", + onClick = { onOpenSettingsPage(PilotSettingsPage.MyInvitations) }, + ) + } + } + item { + SettingsWebTabGroup( + title = session.workspace.name.ifBlank { if (zh) "工作区" else "Workspace" }, + subtitle = if (zh) "当前工作区的组织、权限和反馈" else "Organization, access, and feedback for this workspace", + ) { + SettingsWebTabRow( + title = if (zh) "工作区详情" else "Workspace Details", + subtitle = if (zh) "名称、描述、上下文和 slug" else "Name, description, context, and slug", + value = session.workspace.slug, + icon = Icons.Outlined.Folder, + showDivider = true, + onClick = { onOpenSettingsPage(PilotSettingsPage.WorkspaceDetails) }, + ) + SettingsWebTabRow( + title = if (zh) "成员" else "Members", + subtitle = if (zh) "邀请成员、调整角色和撤销邀请" else "Invite people, update roles, and revoke invitations", + icon = Icons.Outlined.Person, + showDivider = true, + onClick = { onOpenSettingsPage(PilotSettingsPage.Members) }, + ) + SettingsWebTabRow( + title = if (zh) "标签" else "Labels", + subtitle = if (zh) "维护 Issue 标签和颜色" else "Maintain issue labels and colors", + icon = Icons.Outlined.Flag, + showDivider = true, + onClick = { onOpenSettingsPage(PilotSettingsPage.Labels) }, + ) + SettingsWebTabRow( + title = if (zh) "反馈" else "Feedback", + subtitle = if (zh) "向 Multica 团队提交产品反馈" else "Send product feedback to the Multica team", + icon = Icons.Outlined.Edit, + onClick = { onOpenSettingsPage(PilotSettingsPage.Feedback) }, + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "自动化与运行环境" else "Automation & Agents", + subtitle = if (zh) "Agent、自动化、运行环境和能力包" else "Agents, automations, runtimes, and reusable skills", + ) { + SettingsWebTabRow( + title = "Agents", + subtitle = if (zh) "配置可执行任务的 AI 成员" else "Configure AI workers that can execute tasks", + icon = Icons.Outlined.Bolt, + showDivider = true, + onClick = { onOpenSettingsPage(PilotSettingsPage.Agents) }, + ) + SettingsWebTabRow( + title = "Autopilots", + subtitle = if (zh) "定时或事件触发的自动化" else "Scheduled and event-triggered automations", + icon = Icons.Outlined.Dashboard, + showDivider = true, + onClick = { onOpenSettingsPage(PilotSettingsPage.Autopilots) }, + ) + SettingsWebTabRow( + title = "Runtimes", + subtitle = if (zh) "连接本地和远程执行环境" else "Connect local and remote execution environments", + value = settingsHomeStatusSummary.runtimeCount?.let { count -> + val countLabel = if (zh) "$count 个" else "$count runtimes" + listOf(countLabel, settingsHomeStatusSummary.runtimeUpdateHint.orEmpty()) + .filter { it.isNotBlank() } + .joinToString(" · ") + } ?: if (zh) "检查更新" else "Check updates", + icon = Icons.Outlined.CalendarMonth, + showDivider = true, + contentDescription = "Settings Runtime Update Hint", + onClick = { onOpenSettingsPage(PilotSettingsPage.Runtimes) }, + ) + SettingsWebTabRow( + title = "Skills", + subtitle = if (zh) "管理可复用的 agent 能力包" else "Manage reusable agent capability packages", + icon = Icons.Outlined.Sort, + onClick = { onOpenSettingsPage(PilotSettingsPage.Skills) }, + ) + } + } + if (session.workspaces.size > 1) { + item { + SettingsWebTabGroup( + title = if (zh) "切换工作区" else "Switch Workspace", + subtitle = if (zh) "和 Web sidebar 的 workspace switcher 保持一致" else "Mirrors the web sidebar workspace switcher", + contentDescription = "Settings Web Workspace Switcher", + ) { + session.workspaces.forEachIndexed { index, workspace -> + SettingsWebTabRow( + title = workspace.name, + subtitle = workspace.slug, + value = if (workspace.id == session.workspace.id) { + if (zh) "当前" else "Current" + } else null, + icon = Icons.Outlined.Folder, + showDivider = index < session.workspaces.lastIndex, + onClick = { + authStore.saveWorkspaceId(workspace.id) + onReloadSession() + }, + ) + } + } + } + } + item { + SettingsWebTabGroup( + title = if (zh) "账号操作" else "Account Actions", + subtitle = if (zh) "低频破坏性操作收在底部" else "Low-frequency destructive actions stay at the bottom", + ) { + SettingsWebTabRow( + title = if (zh) "退出登录" else "Log Out", + subtitle = if (zh) "清除本机登录状态并返回登录页" else "Clear local auth and return to login", + icon = Icons.Outlined.Delete, + destructive = true, + onClick = { confirmingLogout = true }, + contentDescription = "Settings Log Out", + ) + } + } + if (confirmingLogout) { + item { + SettingsLogoutConfirmWebPanel( + workspaceName = session.workspace.name, + zh = zh, + onCancel = { confirmingLogout = false }, + onConfirm = { + authStore.clearToken() + confirmingLogout = false + onReloadSession() + }, + ) + } + } + } +} + +@Composable +private fun SettingsLogoutConfirmWebPanel( + workspaceName: String, + zh: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Settings Logout Web Alert Panel" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = if (zh) "退出登录?" else "Log out?", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + text = workspaceName, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = if (zh) { + "这会清除本机登录状态,并返回登录流程。" + } else { + "This clears local authentication on this device and returns to the login flow." + }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.08f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Danger, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (zh) "退出登录" else "Log Out", + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Settings Logout Confirm Destructive" }, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun AccountProfileWebEditor( + name: String, + avatarUrl: String, + uploadingAvatar: Boolean, + zh: Boolean, + onNameChange: (String) -> Unit, + onAvatarUrlChange: (String) -> Unit, + onUploadAvatar: () -> Unit, +) { + val initials = name + .split(" ") + .mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + .take(2) + .ifBlank { "M" } + + SettingsWebTabGroup( + title = if (zh) "资料" else "Profile", + subtitle = if (zh) "头像、姓名和公开显示信息" else "Avatar, name, and public display details", + contentDescription = "Account Profile Web Editor", + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .semantics(mergeDescendants = false) { contentDescription = "Account Profile Web Avatar Row" }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Border.copy(alpha = 0.42f), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = initials, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MulticaColors.Muted, + ) + MulticaRemoteAvatarImage( + avatarUrl = avatarUrl, + size = 56.dp, + contentDescription = "Account Profile Remote Avatar", + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = if (zh) "头像" else "Avatar", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + ) + Text( + text = if (zh) "上传图片,或在下方粘贴头像 URL" else "Upload an image, or paste an avatar URL below", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = "Account Upload Avatar", + enabled = !uploadingAvatar, + onClick = onUploadAvatar, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "Account Upload Avatar" }, + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = name, + onValueChange = onNameChange, + label = if (zh) "姓名" else "Name", + contentDescription = "Account Profile Name Field", + singleLine = true, + ) + MulticaTextField( + value = avatarUrl, + onValueChange = onAvatarUrlChange, + label = if (zh) "头像 URL" else "Avatar URL", + contentDescription = "Account Profile Avatar URL Field", + singleLine = true, + ) + } + } +} + +@Composable +private fun SettingsWebAccountHeader( + user: Models.User, + workspace: Models.Workspace, + zh: Boolean, + onEditProfile: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Settings Web Account Header" }, + shape = RoundedCornerShape(16.dp), + color = MulticaColors.Surface.copy(alpha = 0.82f), + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.72f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 13.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(42.dp), + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Accent.copy(alpha = 0.12f), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = user.name + .split(" ") + .mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + .take(2) + .ifBlank { "M" }, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + color = MulticaColors.Accent, + ) + MulticaRemoteAvatarImage( + avatarUrl = user.avatarUrl, + size = 42.dp, + contentDescription = "Settings Account Remote Avatar", + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = user.name.ifBlank { if (zh) "Multica 用户" else "Multica User" }, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = user.email, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = workspace.name.ifBlank { workspace.slug }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.Edit, + contentDescription = "Settings Web Edit Profile", + onClick = onEditProfile, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(38.dp) + .semantics { contentDescription = "Settings Web Edit Profile" }, + ) + } + } +} + +@Composable +private fun SettingsWebTabGroup( + title: String, + subtitle: String, + contentDescription: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + MulticaCupertinoSection( + title = title, + footer = subtitle, + contentDescription = contentDescription ?: "Settings Web Tab Group $title", + content = content, + ) +} + +@Composable +private fun SettingsWebTabRow( + title: String, + subtitle: String, + icon: ImageVector, + value: String? = null, + showDivider: Boolean = false, + destructive: Boolean = false, + contentDescription: String? = null, + onClick: () -> Unit, +) { + MulticaCupertinoListRow( + title = title, + subtitle = subtitle, + leadingIcon = icon, + value = value, + showDivider = showDivider, + destructive = destructive, + contentDescription = contentDescription ?: "Settings Web Tab Row $title", + onClick = onClick, + ) +} + +@Composable +private fun SettingsSectionLabel(title: String) { + Text( + text = title.uppercase(Locale.getDefault()), + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + letterSpacing = 0.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextTertiary, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 16.dp), + ) +} + +@Composable +private fun SettingsCompactCommandRow( + title: String, + command: String, + showDivider: Boolean = false, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = command, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (showDivider) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun PilotSettingsSubpage( + page: PilotSettingsPage, + api: ApiClient, + workspaceId: String, + currentUserId: String, + zh: Boolean, + onReloadSession: () -> Unit, + onSwitchWorkspace: (String) -> Unit, + onBack: () -> Unit, +) { + when (page) { + PilotSettingsPage.WorkspaceDetails -> PilotWorkspaceSettings(api, workspaceId, currentUserId, zh, onBack, onReloadSession, onSwitchWorkspace) + PilotSettingsPage.MyInvitations -> PilotMyInvitationsSettings(api, zh, onBack, onReloadSession) + PilotSettingsPage.Members -> PilotMembersSettings(api, workspaceId, currentUserId, zh, onBack) + PilotSettingsPage.Notifications -> PilotNotificationSettings(api, workspaceId, zh, onBack) + PilotSettingsPage.ApiTokens -> PilotApiTokensSettings(api, zh, onBack) + PilotSettingsPage.Agents -> PilotAgentsSettings(api, workspaceId, zh, onBack) + PilotSettingsPage.Autopilots -> PilotAutopilotsSettings(api, workspaceId, zh, onBack) + PilotSettingsPage.Runtimes -> PilotRuntimesSettings(api, workspaceId, zh, onBack) + PilotSettingsPage.Labels -> PilotLabelsSettings(api, workspaceId, zh, onBack) + PilotSettingsPage.Skills -> PilotSkillsSettings(api, workspaceId, zh, onBack) + PilotSettingsPage.Feedback -> PilotFeedbackSettings(api, workspaceId, zh, onBack) + } +} + +@Composable +private fun PilotMyInvitationsSettings( + api: ApiClient, + zh: Boolean, + onBack: () -> Unit, + onReloadSession: () -> Unit, +) { + val WEB_SSOT_INVITATIONS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/invitations/invitations-page.tsx" + val WEB_SSOT_INVITE_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/invite/invite-page.tsx" + var refresh by remember { mutableIntStateOf(0) } + var state by remember { mutableStateOf?>(null) } + var mutatingId by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { PilotMyInvitationsSettingsData(api.myInvitations()) } + } + } + + fun mutate(invitation: Models.Invitation, accept: Boolean) { + if (invitation.id.isBlank() || mutatingId != null) return + mutatingId = invitation.id + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + if (accept) api.acceptInvitation(invitation.id) else api.declineInvitation(invitation.id) + } + } + mutatingId = null + result.onSuccess { + message = if (accept) { + if (zh) "邀请已接受" else "Invitation accepted" + } else { + if (zh) "邀请已拒绝" else "Invitation declined" + } + refresh++ + if (accept) onReloadSession() + }.onFailure { + message = "${if (accept) { + if (zh) "接受邀请失败" else "Accept invitation failed" + } else { + if (zh) "拒绝邀请失败" else "Decline invitation failed" + }}: ${it.message ?: it.toString()}" + } + } + } + + PilotListPage( + title = if (zh) "我的邀请" else "My Invitations", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "My Invitations Result Web Banner", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + val loaded = state + when { + loaded == null -> item { MulticaLoadingState(if (zh) "正在加载邀请..." else "Loading invitations...") } + loaded.isFailure -> item { + MulticaErrorState( + message = "${if (zh) "邀请加载失败" else "Invitations failed"}\n${loaded.exceptionOrNull()?.message ?: loaded.exceptionOrNull().toString()}", + onRetry = { refresh++ }, + ) + } + else -> { + val invitations = loaded.getOrThrow().invitations + item { SettingsSectionLabel(if (zh) "待处理邀请" else "Pending Invitations") } + if (invitations.isEmpty()) { + item { + SettingsWebTabGroup( + title = if (zh) "待处理邀请" else "Pending Invitations", + subtitle = if (zh) "新的 workspace 邀请会显示在这里" else "New workspace invitations appear here.", + contentDescription = "My Invitations Web Empty Group $WEB_SSOT_INVITATIONS_PAGE $WEB_SSOT_INVITE_PAGE", + ) { + MulticaCupertinoListRow( + title = if (zh) "暂无待处理邀请" else "No pending invitations", + subtitle = if (zh) "新的 workspace 邀请会显示在这里" else "New workspace invitations appear here.", + leadingIcon = Icons.Outlined.Link, + contentDescription = "My Invitations Compact Empty State", + onClick = {}, + ) + } + } + } else { + item { + SettingsWebTabGroup( + title = if (zh) "待处理邀请" else "Pending Invitations", + subtitle = if (zh) "${invitations.size} 个待处理 workspace 邀请" else "${invitations.size} pending workspace invitations", + contentDescription = "My Invitations Web List Group $WEB_SSOT_INVITATIONS_PAGE $WEB_SSOT_INVITE_PAGE", + ) { + invitations.forEachIndexed { index, invitation -> + MyInvitationWebDenseRow( + invitation = invitation, + busy = mutatingId == invitation.id, + zh = zh, + showDivider = index < invitations.lastIndex, + onAccept = { mutate(invitation, true) }, + onDecline = { mutate(invitation, false) }, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun MyInvitationWebDenseRow( + invitation: Models.Invitation, + busy: Boolean, + zh: Boolean, + showDivider: Boolean, + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + val WEB_SSOT_INVITATIONS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/invitations/invitations-page.tsx" + val WEB_SSOT_INVITE_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/invite/invite-page.tsx" + var actionsOpen by remember { mutableStateOf(false) } + val workspaceTitle = invitation.workspaceName.ifBlank { Models.shortId(invitation.workspaceId) } + val inviter = invitation.inviterName.ifBlank { + invitation.inviterEmail.ifBlank { if (zh) "未知邀请人" else "Unknown inviter" } + } + val role = invitation.role.ifBlank { "member" } + val status = invitation.status.ifBlank { "pending" } + val created = invitation.createdAt.ifBlank { invitation.updatedAt } + val createdText = if (created.isBlank()) { + if (zh) "创建时间未知" else "Created date unavailable" + } else { + "${if (zh) "创建" else "Created"} ${shortDate(created)}" + } + val inviterText = if (zh) "邀请人:$inviter" else "Invited by $inviter" + val roleText = when (role.lowercase(Locale.getDefault())) { + "admin" -> if (zh) "管理员" else "Admin" + "owner" -> if (zh) "所有者" else "Owner" + else -> if (zh) "成员" else "Member" + } + val statusText = when (status.lowercase(Locale.getDefault())) { + "accepted" -> if (zh) "已接受" else "Accepted" + "declined" -> if (zh) "已拒绝" else "Declined" + else -> if (zh) "待处理" else "Pending" + } + + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { + contentDescription = "My Invitation Web Dense Row ${invitation.id} $WEB_SSOT_INVITATIONS_PAGE $WEB_SSOT_INVITE_PAGE" + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + modifier = Modifier + .size(34.dp) + .padding(top = 2.dp), + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Border.copy(alpha = 0.42f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Link, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(15.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + text = workspaceTitle, + modifier = Modifier.semantics { + contentDescription = "My Invitation Workspace Name ${invitation.id}" + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = inviterText, + modifier = Modifier.semantics { + contentDescription = "My Invitation Inviter Metadata ${invitation.id}" + }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + InvitationMetadataCapsule( + text = roleText, + contentDescription = "My Invitation Role Capsule ${invitation.id}", + tone = MulticaColors.Accent, + ) + InvitationMetadataCapsule( + text = statusText, + contentDescription = "My Invitation Status Capsule ${invitation.id}", + tone = MulticaColors.Warning, + ) + InvitationMetadataCapsule( + text = createdText, + contentDescription = "My Invitation Created Metadata ${invitation.id}", + tone = MulticaColors.Muted, + ) + } + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "My Invitation Actions Menu ${invitation.id}", + enabled = !busy, + onClick = { actionsOpen = true }, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "My Invitation Actions Menu ${invitation.id}" }, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + if (actionsOpen) { + Spacer( + modifier = Modifier + .size(1.dp) + .semantics { contentDescription = "My Invitation Actions Sheet ${invitation.id}" }, + ) + } + MulticaCupertinoActionSheet( + visible = actionsOpen, + title = if (zh) "邀请操作" else "Invitation actions", + message = "$workspaceTitle · $roleText · $createdText", + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (busy) "..." else if (zh) "接受邀请" else "Accept invitation", + subtitle = if (zh) "加入 $workspaceTitle 并刷新工作区" else "Join $workspaceTitle and refresh workspace", + contentDescription = "My Invitation Accept Action ${invitation.id}", + enabled = !busy, + selected = false, + onClick = onAccept, + ), + MulticaCupertinoActionSheetItem( + title = if (busy) "..." else if (zh) "拒绝邀请" else "Decline invitation", + subtitle = if (zh) "保留当前 workspace,并移除此邀请" else "Keep your current workspace and remove this invitation", + contentDescription = "My Invitation Decline Action ${invitation.id}", + destructive = true, + enabled = !busy, + onClick = onDecline, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { actionsOpen = false }, + ) + } +} + +@Composable +private fun InvitationMetadataCapsule( + text: String, + contentDescription: String, + tone: Color, +) { + Surface( + modifier = Modifier.semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + }, + shape = RoundedCornerShape(999.dp), + color = MulticaColors.SurfaceElevated.copy(alpha = 0.78f), + border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.68f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.Medium), + color = tone, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun PilotMyInvitationsPreview() { + val invitations = listOf( + Models.Invitation( + JSONObject() + .put("id", "preview-invite-alpha") + .put("workspace_id", "workspace-alpha") + .put("workspace_name", "Multica Design Lab") + .put("role", "admin") + .put("status", "pending") + .put("inviter_name", "Nora Chen") + .put("inviter_email", "nora@multica.ai") + .put("created_at", "2026-05-14T17:30:00Z") + ), + Models.Invitation( + JSONObject() + .put("id", "preview-invite-beta") + .put("workspace_id", "workspace-beta") + .put("workspace_name", "Automation Ops") + .put("role", "member") + .put("status", "pending") + .put("inviter_email", "ops@multica.ai") + .put("created_at", "2026-05-11T09:15:00Z") + ), + ) + PilotListPage( + title = "My Invitations", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + ) { + item { + SettingsWebTabGroup( + title = "Pending Invitations", + subtitle = "${invitations.size} pending workspace invitations", + contentDescription = "My Invitations Web List Preview", + ) { + invitations.forEachIndexed { index, invitation -> + MyInvitationWebDenseRow( + invitation = invitation, + busy = false, + zh = false, + showDivider = index < invitations.lastIndex, + onAccept = {}, + onDecline = {}, + ) + } + } + } + } +} + +private val notificationInboxPreferenceKeys = listOf( + "assignments", + "status_changes", + "comments", + "updates", + "agent_activity", +) + +private const val WEB_SSOT_NOTIFICATIONS_TAB = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/notifications-tab.tsx" +private const val WEB_SSOT_NOTIFICATIONS_LOCALE_EN = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/settings.json" +private const val WEB_SSOT_NOTIFICATIONS_LOCALE_ZH = "/Users/park0er/coding/multica-web-source/packages/views/locales/zh-Hans/settings.json" + +private fun notificationPreferenceLabel(key: String, zh: Boolean): String = when (key) { + "assignments" -> if (zh) "分配" else "Assignments" + "status_changes" -> if (zh) "状态变化" else "Status Changes" + "comments" -> if (zh) "评论" else "Comments" + "updates" -> if (zh) "更新" else "Updates" + "agent_activity" -> if (zh) "Agent 活动" else "Agent Activity" + "system_notifications" -> if (zh) "系统通知" else "System Notifications" + else -> key +} + +private fun notificationPreferenceDescription(key: String, zh: Boolean): String = when (key) { + "assignments" -> if (zh) "你被分配或取消分配某个 issue 时" else "When you are assigned or unassigned from an issue" + "status_changes" -> if (zh) "你订阅的 issue 状态变化时(例如 todo、in progress、done)" else "When an issue you follow changes status (e.g. todo, in progress, done)" + "comments" -> if (zh) "你订阅的 issue 有新评论,或有人 @ 你时" else "New comments on issues you follow, or when someone @mentions you" + "updates" -> if (zh) "你订阅的 issue 优先级或截止日期变更时" else "When priority or due date changes on issues you follow" + "agent_activity" -> if (zh) "智能体 task 完成或失败时" else "When an agent task completes or fails" + "system_notifications" -> if (zh) "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。" else "Show a banner from your operating system for new inbox items when the app isn't focused." + else -> if (zh) "控制此事件是否产生通知" else "Control delivery for this event" +} + +private fun notificationInboxFooter(zh: Boolean): String = if (zh) { + "控制哪些事件会产生收件箱通知。被静默的事件类型仍然存在,可以直接到 issue 页面查看。" +} else { + "Control which events generate inbox notifications. Muted event types are silently filtered; you can still see them by visiting the issue directly." +} + +private fun notificationSystemFooter(zh: Boolean): String = if (zh) { + "控制 Multica 在后台时是否显示操作系统的原生通知横幅。邮件和应用内收件箱仍按上方事件分组处理。" +} else { + "Control native OS notification banners shown when Multica is in the background. Email and in-app inbox delivery still follow the event groups above." +} + +private fun notificationPreferencesWithValues( + workspaceId: String, + values: Map, +): Models.NotificationPreferences { + val preferences = JSONObject() + values.forEach { (key, value) -> + if (value.isNotBlank() && value != "all") { + preferences.put(key, value) + } + } + return Models.NotificationPreferences( + JSONObject() + .put("workspace_id", workspaceId) + .put("preferences", preferences) + ) +} + +private enum class MemberSettingsMode { List, Invite } + +@Composable +private fun PilotMembersSettings( + api: ApiClient, + workspaceId: String, + currentUserId: String, + zh: Boolean, + onBack: () -> Unit, +) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var memberSettingsMode by remember(workspaceId) { mutableStateOf(MemberSettingsMode.List) } + var email by remember(workspaceId) { mutableStateOf("") } + var inviteRole by remember(workspaceId) { mutableStateOf("member") } + var saving by remember(workspaceId) { mutableStateOf(false) } + var revokingId by remember(workspaceId) { mutableStateOf(null) } + var memberActionId by remember(workspaceId) { mutableStateOf(null) } + var pendingRemove by remember(workspaceId) { mutableStateOf(null) } + var pendingRevoke by remember(workspaceId) { mutableStateOf(null) } + var memberMenu by remember(workspaceId) { mutableStateOf(null) } + var rolePickerMember by remember(workspaceId) { mutableStateOf(null) } + var message by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + PilotMembersSettingsData( + members = api.members(workspaceId), + invitations = api.workspaceInvitations(workspaceId), + ) + } + } + } + + BackHandler(enabled = memberSettingsMode != MemberSettingsMode.List) { + memberSettingsMode = MemberSettingsMode.List + } + + fun invite() { + val value = email.trim() + if (value.isBlank() || saving) return + saving = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.createMemberInvitation(workspaceId, value, inviteRole) } + } + saving = false + result.onSuccess { + email = "" + inviteRole = "member" + message = if (zh) "邀请已发送" else "Invitation sent" + memberSettingsMode = MemberSettingsMode.List + refresh++ + }.onFailure { + message = "${if (zh) "邀请失败" else "Invite failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun updateRole(member: Models.Member, role: String) { + if (memberActionId != null || role == member.role) return + memberActionId = member.id + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.updateMemberRole(workspaceId, member.id, role) } + } + memberActionId = null + result.onSuccess { + message = if (zh) "成员角色已更新" else "Member role updated" + refresh++ + }.onFailure { + message = "${if (zh) "角色更新失败" else "Role update failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun removeMember(member: Models.Member) { + if (memberActionId != null) return + memberActionId = member.id + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteMember(workspaceId, member.id) } + } + memberActionId = null + result.onSuccess { + pendingRemove = null + message = if (zh) "成员已移除" else "Member removed" + refresh++ + }.onFailure { + message = "${if (zh) "移除成员失败" else "Remove member failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun revoke(invitation: Models.Invitation) { + if (revokingId != null) return + revokingId = invitation.id + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.revokeInvitation(workspaceId, invitation.id) } + } + revokingId = null + result.onSuccess { + pendingRevoke = null + message = if (zh) "邀请已撤销" else "Invitation revoked" + refresh++ + }.onFailure { + message = "${if (zh) "撤销失败" else "Revoke failed"}: ${it.message ?: it.toString()}" + } + } + } + + if (memberSettingsMode == MemberSettingsMode.Invite) { + PilotListPage( + title = if (zh) "邀请成员" else "Invite Member", + trailing = { + MulticaPillButton( + text = if (zh) "成员" else "Members", + onClick = { memberSettingsMode = MemberSettingsMode.List }, + tone = MulticaButtonTone.Secondary, + contentDescription = "Back to Members", + ) + }, + bottomContent = { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MulticaColors.Surface) + .padding(horizontal = 20.dp, vertical = 14.dp) + .imePadding(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { memberSettingsMode = MemberSettingsMode.List }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (saving) "..." else if (zh) "发送邀请" else "Send Invite", + onClick = { invite() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + }, + ) { + item { + SettingsSectionLabel(if (zh) "成员邀请" else "Member Invitation") + MulticaTextField( + value = email, + onValueChange = { email = it }, + label = if (zh) "邮箱地址" else "Email address", + singleLine = true, + contentDescription = "Member Invite Form Email", + modifier = Modifier.padding(bottom = 12.dp), + ) + MemberInviteRolePicker( + selectedRole = inviteRole, + zh = zh, + onRoleChange = { inviteRole = it }, + ) + } + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "Members Result Web Banner", + modifier = Modifier.padding(top = 12.dp), + ) + } + } + } + return + } + + PilotListPage( + title = if (zh) "成员" else "Members", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载成员..." else "Loading members...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "成员加载失败" else "Members failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { data -> + val currentMember = data.members.firstOrNull { it.userId == currentUserId } + val canManage = currentMember?.role == "owner" || currentMember?.role == "admin" + val canManageOwners = currentMember?.role == "owner" + val ownerCount = data.members.count { it.role == "owner" } + if (canManage) { + item { + SettingsWebTabGroup( + title = if (zh) "管理" else "Manage", + subtitle = if (zh) "邀请成员并指定 workspace role" else "Invite members and assign workspace roles", + contentDescription = "Members Web Invite Entry Member Invite Entry", + ) { + SettingsWebTabRow( + title = if (zh) "邀请成员" else "Invite Member", + subtitle = if (zh) "发送邮件邀请并指定成员角色" else "Send an email invite and choose a workspace role", + icon = Icons.Outlined.Add, + onClick = { memberSettingsMode = MemberSettingsMode.Invite }, + contentDescription = "Members Web Invite Entry Row", + ) + } + } + } + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "Members Result Web Banner", + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "当前成员" else "Current Members", + subtitle = if (zh) "${data.members.size} 个 workspace 成员" else "${data.members.size} workspace members", + ) { + if (data.members.isEmpty()) { + CompactIssueRow( + eyebrow = if (zh) "空" else "Empty", + title = if (zh) "暂无成员" else "No members", + trailing = null, + ) + } else { + data.members.forEachIndexed { index, member -> + val isSelf = member.userId == currentUserId + val isLastOwner = member.role == "owner" && ownerCount <= 1 + val canEditRole = canManage && !isSelf && !isLastOwner && (member.role != "owner" || canManageOwners) + val canRemove = canManage && !isSelf && !isLastOwner && (member.role != "owner" || canManageOwners) + val roleOptions = buildList { + add("member") + add("admin") + if (canManageOwners) add("owner") + } + MemberWebDenseRow( + member = member, + isSelf = isSelf, + busy = memberActionId == member.id, + canEditRole = canEditRole && roleOptions.isNotEmpty(), + canRemove = canRemove, + canManage = canManage, + isLastOwner = isLastOwner, + zh = zh, + showDivider = index < data.members.lastIndex, + onOpenMenu = { memberMenu = member }, + onRemove = { pendingRemove = member }, + ) + } + } + } + } + memberMenu?.let { member -> + item { + MemberMoreActionSheet( + member = member, + zh = zh, + canEditRole = canManage && member.userId != currentUserId && !(member.role == "owner" && ownerCount <= 1) && (member.role != "owner" || canManageOwners), + canRemove = canManage && member.userId != currentUserId && !(member.role == "owner" && ownerCount <= 1) && (member.role != "owner" || canManageOwners), + onDismiss = { memberMenu = null }, + onChangeRole = { + rolePickerMember = member + memberMenu = null + }, + onRemove = { + pendingRemove = member + memberMenu = null + }, + ) + } + } + rolePickerMember?.let { member -> + item { + MemberRolePickerActionSheet( + member = member, + canManageOwners = canManageOwners, + zh = zh, + onDismiss = { rolePickerMember = null }, + onRoleChange = { role -> + rolePickerMember = null + updateRole(member, role) + }, + ) + } + } + pendingRemove?.let { member -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "移除成员?" else "Remove member?", + message = if (zh) { + "${member.displayName} 会从当前 workspace 移除;已创建内容会保留。" + } else { + "${member.displayName} will be removed from this workspace. Existing authored content remains." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (memberActionId == member.id) "..." else if (zh) "移除" else "Remove", + destructive = true, + contentDescription = "Member Remove Cupertino Alert ${member.id}", + onDismissRequest = { pendingRemove = null }, + onConfirm = { removeMember(member) }, + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "待处理邀请" else "Pending Invitations", + subtitle = if (zh) "${data.invitations.size} 个待处理邀请" else "${data.invitations.size} pending invitations", + ) { + if (data.invitations.isEmpty()) { + CompactIssueRow( + eyebrow = if (zh) "空" else "Empty", + title = if (zh) "暂无邀请" else "No pending invitations", + ) + } else { + data.invitations.forEachIndexed { index, invitation -> + MemberInvitationWebDenseRow( + invitation = invitation, + busy = revokingId == invitation.id, + canManage = canManage, + zh = zh, + showDivider = index < data.invitations.lastIndex, + onRevoke = { pendingRevoke = invitation }, + ) + } + } + } + } + pendingRevoke?.let { invitation -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "撤销邀请?" else "Revoke invitation?", + message = if (zh) { + "${invitation.inviteeEmail.ifBlank { Models.shortId(invitation.id) }} 将不再能通过此邀请加入 workspace。" + } else { + "${invitation.inviteeEmail.ifBlank { Models.shortId(invitation.id) }} will no longer be able to join from this invitation." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (revokingId == invitation.id) "..." else if (zh) "撤销" else "Revoke", + destructive = true, + contentDescription = "Members Invitation Revoke Cupertino Alert ${invitation.id}", + onDismissRequest = { pendingRevoke = null }, + onConfirm = { revoke(invitation) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun MemberWebDenseRow( + member: Models.Member, + isSelf: Boolean, + busy: Boolean, + canEditRole: Boolean, + canRemove: Boolean, + canManage: Boolean, + isLastOwner: Boolean, + zh: Boolean, + showDivider: Boolean, + onOpenMenu: () -> Unit, + onRemove: () -> Unit, +) { + val WEB_SSOT_MEMBERS_TAB = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/members-tab.tsx" + val showMenu = canEditRole || canRemove + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { + contentDescription = "Members Web Dense Row ${member.id} $WEB_SSOT_MEMBERS_TAB" + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .size(32.dp) + .semantics { contentDescription = "Member Row Avatar ${member.id}" }, + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Border.copy(alpha = 0.42f), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = member.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "M", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = MulticaColors.Muted, + ) + MulticaRemoteAvatarImage( + avatarUrl = member.avatarUrl, + size = 32.dp, + contentDescription = "Member Row Remote Avatar ${member.id}", + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = member.displayName, + modifier = Modifier.semantics { contentDescription = "Member Row Name ${member.id}" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = member.email.ifBlank { if (zh) "无邮箱" else "No email" }, + modifier = Modifier.semantics { contentDescription = "Member Row Email ${member.id}" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Member Role Badge ${member.id}" + }, + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Border.copy(alpha = 0.34f), + ) { + Text( + text = member.role.ifBlank { "member" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + maxLines = 1, + ) + } + if (showMenu) { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Member More Menu ${member.id} Member Self Guard ${isSelf} Member Last Owner Guard ${isLastOwner}", + enabled = !busy, + onClick = onOpenMenu, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(34.dp) + .semantics { + contentDescription = "Member More Menu ${member.id} Member Self Guard ${isSelf} Member Last Owner Guard ${isLastOwner}" + }, + ) + } else if (!canManage) { + Spacer(modifier = Modifier.semantics { contentDescription = "Member Management Actions Hidden ${member.id}" }) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun MemberMoreActionSheet( + member: Models.Member, + zh: Boolean, + canEditRole: Boolean, + canRemove: Boolean, + onDismiss: () -> Unit, + onChangeRole: () -> Unit, + onRemove: () -> Unit, +) { + MulticaCupertinoActionSheet( + visible = true, + title = if (zh) "成员操作" else "Member actions", + message = listOf(member.displayName, member.email).filter { it.isNotBlank() }.joinToString(" · "), + items = buildList { + if (canEditRole) { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "更改角色" else "Change role", + subtitle = if (zh) "Owner / Admin / Member" else "Owner, admin, or member", + icon = Icons.Outlined.Key, + contentDescription = "Member Role Cupertino Action Sheet ${member.id}", + onClick = onChangeRole, + ) + ) + } + if (canRemove) { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "移除成员" else "Remove member", + subtitle = if (zh) "从当前 workspace 移除" else "Remove from this workspace", + icon = Icons.Outlined.Delete, + contentDescription = "Member Remove Action ${member.id}", + destructive = true, + onClick = onRemove, + ) + ) + } + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = onDismiss, + ) +} + +@Composable +private fun MemberRolePickerActionSheet( + member: Models.Member, + canManageOwners: Boolean, + zh: Boolean, + onDismiss: () -> Unit, + onRoleChange: (String) -> Unit, +) { + val roles = listOf("owner", "admin", "member") + MulticaCupertinoActionSheet( + visible = true, + title = if (zh) "选择角色" else "Choose role", + message = if (zh) "成员角色会影响 workspace 管理权限" else "Member roles control workspace management access", + items = roles.map { role -> + val ownerDisabled = role == "owner" && !canManageOwners + MulticaCupertinoActionSheetItem( + title = memberRoleLabel(role, zh), + subtitle = memberRoleDescription(role, ownerDisabled, zh), + icon = memberRoleIcon(role), + contentDescription = when (role) { + "owner" -> "Member Role Option Owner" + "admin" -> "Member Role Option Admin" + else -> "Member Role Option Member" + }, + selected = member.role == role, + enabled = !ownerDisabled, + onClick = { onRoleChange(role) }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = onDismiss, + ) +} + +private fun memberRoleLabel(role: String, zh: Boolean): String = when (role) { + "owner" -> if (zh) "所有者" else "Owner" + "admin" -> if (zh) "管理员" else "Admin" + else -> if (zh) "成员" else "Member" +} + +private fun memberRoleDescription(role: String, disabledOwner: Boolean, zh: Boolean): String = when { + disabledOwner -> if (zh) "只有 owner 可以授予 owner" else "Only owners can grant owner" + role == "owner" -> if (zh) "完整管理权限,包括 owner 管理" else "Full access, including owner management" + role == "admin" -> if (zh) "管理成员与 workspace 设置" else "Manage members and workspace settings" + else -> if (zh) "参与协作,无管理权限" else "Collaborate without admin access" +} + +private fun memberRoleIcon(role: String): ImageVector = when (role) { + "owner" -> Icons.Outlined.Key + "admin" -> Icons.Outlined.CheckCircle + else -> Icons.Outlined.Person +} + +@Composable +private fun MemberInviteRolePicker( + selectedRole: String, + zh: Boolean, + onRoleChange: (String) -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Member Invite Form Role Member Invite Role Picker" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.74f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (zh) "角色" else "Role", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + ) + MemberInviteRoleOptionRow( + role = "member", + title = if (zh) "成员" else "Member", + subtitle = if (zh) "可参与当前 workspace 协作" else "Can collaborate in the current workspace", + selected = selectedRole == "member", + icon = Icons.Outlined.Person, + contentDescription = "Member Invite Role Option Member", + onClick = { onRoleChange("member") }, + ) + MemberInviteRoleOptionRow( + role = "admin", + title = if (zh) "管理员" else "Admin", + subtitle = if (zh) "可管理成员和 workspace 设置" else "Can manage members and workspace settings", + selected = selectedRole == "admin", + icon = Icons.Outlined.Key, + contentDescription = "Member Invite Role Option Admin", + onClick = { onRoleChange("admin") }, + ) + } + } +} + +@Composable +private fun MemberInviteRoleOptionRow( + role: String, + title: String, + subtitle: String, + selected: Boolean, + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(9.dp)) + .clickable(onClick = onClick) + .border( + 1.dp, + if (selected) MulticaColors.Accent.copy(alpha = 0.40f) else MulticaColors.Border.copy(alpha = 0.48f), + RoundedCornerShape(9.dp), + ) + .background(if (selected) MulticaColors.Accent.copy(alpha = 0.07f) else MulticaColors.Surface) + .padding(horizontal = 10.dp, vertical = 9.dp) + .semantics { this.contentDescription = contentDescription }, + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Border.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(16.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon(Icons.Outlined.Check, contentDescription = role, tint = MulticaColors.Accent, modifier = Modifier.size(16.dp)) + } + } +} + +@Composable +private fun MemberInvitationWebDenseRow( + invitation: Models.Invitation, + busy: Boolean, + canManage: Boolean, + zh: Boolean, + showDivider: Boolean, + onRevoke: () -> Unit, +) { + val WEB_SSOT_MEMBERS_TAB = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/members-tab.tsx" + val roleLabel = memberRoleLabel(invitation.role.ifBlank { "member" }, zh) + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { + contentDescription = "Members Web Pending Invitation Row ${invitation.id} $WEB_SSOT_MEMBERS_TAB" + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .size(32.dp) + .semantics { contentDescription = "Members Web Pending Invitation Mail Icon ${invitation.id}" }, + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Border.copy(alpha = 0.42f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Email, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(15.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = invitation.inviteeEmail.ifBlank { Models.shortId(invitation.id) }, + modifier = Modifier.semantics { contentDescription = "Members Pending Invitation Email ${invitation.id}" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + InvitationMetadataCapsule( + text = if (zh) "待处理" else "Pending", + contentDescription = "Members Pending Invitation Status Badge ${invitation.id}", + tone = MulticaColors.Warning, + ) + InvitationMetadataCapsule( + text = roleLabel, + contentDescription = "Members Pending Invitation Role Badge ${invitation.id}", + tone = MulticaColors.Accent, + ) + } + } + if (canManage) { + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Members Pending Invitation Revoke Menu ${invitation.id}", + enabled = !busy, + onClick = onRevoke, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "Members Pending Invitation Revoke Menu ${invitation.id}" }, + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun PilotMembersPreview() { + var memberMenu by remember { mutableStateOf(null) } + var rolePickerMember by remember { mutableStateOf(null) } + var pendingRemove by remember { mutableStateOf(null) } + var pendingRevoke by remember { mutableStateOf(null) } + val members = listOf( + Models.Member( + JSONObject() + .put("id", "preview-member-owner") + .put("user_id", "preview-user") + .put("name", "ZhaoXishengGmail") + .put("email", "zhao@example.com") + .put("role", "owner") + ), + Models.Member( + JSONObject() + .put("id", "preview-member-admin") + .put("user_id", "preview-admin") + .put("name", "Nora Chen") + .put("email", "nora@multica.ai") + .put("role", "admin") + ), + ) + val invitations = listOf( + Models.Invitation( + JSONObject() + .put("id", "preview-member-invite") + .put("invitee_email", "designer@multica.ai") + .put("role", "member") + .put("status", "pending") + ) + ) + PilotListPage( + title = "Members", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + ) { + item { + SettingsWebTabGroup( + title = "Current Members", + subtitle = "${members.size} workspace members", + ) { + members.forEachIndexed { index, member -> + MemberWebDenseRow( + member = member, + isSelf = member.userId == "preview-user", + busy = false, + canEditRole = member.userId != "preview-user", + canRemove = member.userId != "preview-user", + canManage = true, + isLastOwner = member.role == "owner", + zh = false, + showDivider = index < members.lastIndex, + onOpenMenu = { memberMenu = member }, + onRemove = { pendingRemove = member }, + ) + } + } + } + memberMenu?.let { member -> + item { + MemberMoreActionSheet( + member = member, + zh = false, + canEditRole = member.userId != "preview-user", + canRemove = member.userId != "preview-user", + onDismiss = { memberMenu = null }, + onChangeRole = { + rolePickerMember = member + memberMenu = null + }, + onRemove = { + pendingRemove = member + memberMenu = null + }, + ) + } + } + rolePickerMember?.let { member -> + item { + MemberRolePickerActionSheet( + member = member, + canManageOwners = true, + zh = false, + onDismiss = { rolePickerMember = null }, + onRoleChange = { rolePickerMember = null }, + ) + } + } + pendingRemove?.let { member -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = "Remove member?", + message = "${member.displayName} will be removed from this workspace. Existing authored content remains.", + cancelText = "Cancel", + confirmText = "Remove", + destructive = true, + contentDescription = "Preview Member Remove Cupertino Alert ${member.id}", + onDismissRequest = { pendingRemove = null }, + onConfirm = { pendingRemove = null }, + ) + } + } + item { + SettingsWebTabGroup( + title = "Pending Invitations", + subtitle = "${invitations.size} pending invitations", + ) { + invitations.forEachIndexed { index, invitation -> + MemberInvitationWebDenseRow( + invitation = invitation, + busy = false, + canManage = true, + zh = false, + showDivider = index < invitations.lastIndex, + onRevoke = { pendingRevoke = invitation }, + ) + } + } + } + pendingRevoke?.let { invitation -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = "Revoke invitation?", + message = "${invitation.inviteeEmail.ifBlank { Models.shortId(invitation.id) }} will no longer be able to join from this invitation.", + cancelText = "Cancel", + confirmText = "Revoke", + destructive = true, + contentDescription = "Preview Members Invitation Revoke Cupertino Alert ${invitation.id}", + onDismissRequest = { pendingRevoke = null }, + onConfirm = { pendingRevoke = null }, + ) + } + } + } +} + +@Composable +private fun NotificationWebSwitchRow( + title: String, + subtitle: String, + checked: Boolean, + saving: Boolean, + showDivider: Boolean = false, + contentDescription: String, + onClick: () -> Unit, +) { + MulticaCupertinoSwitchRow( + title = title, + subtitle = subtitle, + checked = checked, + saving = saving, + showDivider = showDivider, + contentDescription = contentDescription, + onClick = onClick, + ) +} + +@Composable +private fun PilotNotificationSettings( + api: ApiClient, + workspaceId: String, + zh: Boolean, + onBack: () -> Unit, +) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var mutatingKey by remember(workspaceId) { mutableStateOf(null) } + var message by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { api.notificationPreferences(workspaceId) } + } + } + + fun toggle(preferences: Models.NotificationPreferences, key: String) { + if (mutatingKey != null) return + val previousState = state + val next = if (preferences.value(key) == "muted") "all" else "muted" + val updated = LinkedHashMap(preferences.values) + if (next == "all") updated.remove(key) else updated[key] = next + val optimisticPreferences = notificationPreferencesWithValues( + preferences.workspaceId.ifBlank { workspaceId }, + updated, + ) + state = Result.success(optimisticPreferences) + mutatingKey = key + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.updateNotificationPreferences(workspaceId, updated) } + } + mutatingKey = null + result.onSuccess { + state = Result.success(it) + message = if (zh) "通知偏好已更新" else "Notification preferences updated" + }.onFailure { + state = previousState + message = "${if (zh) "通知偏好更新失败" else "Notification update failed"}: ${it.message ?: it.toString()}" + } + } + } + + PilotListPage( + title = if (zh) "通知" else "Notifications", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载通知偏好..." else "Loading notification preferences...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "通知加载失败" else "Notifications failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { preferences -> + item { + // NotificationWebSwitchRow owns CupertinoSwitch( so this page stays Cupertino-first. + SettingsWebTabGroup( + title = if (zh) "收件箱通知" else "Inbox Notifications", + subtitle = notificationInboxFooter(zh), + contentDescription = "Notifications Web Inbox Group $WEB_SSOT_NOTIFICATIONS_TAB", + ) { + notificationInboxPreferenceKeys.forEachIndexed { index, key -> + val value = preferences.value(key) + NotificationWebSwitchRow( + title = notificationPreferenceLabel(key, zh), + subtitle = notificationPreferenceDescription(key, zh), + checked = value != "muted", + saving = mutatingKey == key, + showDivider = index < notificationInboxPreferenceKeys.lastIndex, + contentDescription = "Notification ${key} Cupertino Switch", + onClick = { toggle(preferences, key) }, + ) + } + } + } + item { + SettingsWebTabGroup( + title = if (zh) "系统通知" else "System notifications", + subtitle = notificationSystemFooter(zh), + contentDescription = "Notifications Web System Group $WEB_SSOT_NOTIFICATIONS_TAB $WEB_SSOT_NOTIFICATIONS_LOCALE_EN $WEB_SSOT_NOTIFICATIONS_LOCALE_ZH", + ) { + val key = "system_notifications" + val value = preferences.value(key) + NotificationWebSwitchRow( + title = if (zh) "显示系统通知" else "Show system notifications", + subtitle = notificationPreferenceDescription(key, zh), + checked = value != "muted", + saving = mutatingKey == key, + contentDescription = "Notification ${key} Cupertino Switch", + onClick = { toggle(preferences, key) }, + ) + } + } + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "Notifications Result Web Banner", + ) + } + } + } + } + } + } +} + +@Composable +private fun PilotNotificationSettingsPreview() { + var preferences by remember { + mutableStateOf( + notificationPreferencesWithValues( + workspaceId = "preview-workspace", + values = linkedMapOf("comments" to "muted", "system_notifications" to "muted"), + ) + ) + } + var mutatingKey by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + + fun togglePreview(key: String) { + if (mutatingKey != null) return + val next = if (preferences.value(key) == "muted") "all" else "muted" + val updated = LinkedHashMap(preferences.values) + if (next == "all") updated.remove(key) else updated[key] = next + mutatingKey = key + preferences = notificationPreferencesWithValues(preferences.workspaceId, updated) + message = if (next == "all") "Notification enabled" else "Notification muted" + mutatingKey = null + } + + PilotListPage( + title = "Notifications", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + ) { + item { + SettingsWebTabGroup( + title = "Inbox Notifications", + subtitle = notificationInboxFooter(zh = false), + contentDescription = "Notifications Web Inbox Group Preview $WEB_SSOT_NOTIFICATIONS_TAB", + ) { + notificationInboxPreferenceKeys.forEachIndexed { index, key -> + NotificationWebSwitchRow( + title = notificationPreferenceLabel(key, zh = false), + subtitle = notificationPreferenceDescription(key, zh = false), + checked = preferences.value(key) != "muted", + saving = mutatingKey == key, + showDivider = index < notificationInboxPreferenceKeys.lastIndex, + contentDescription = "Notification ${key} Cupertino Switch Preview", + onClick = { togglePreview(key) }, + ) + } + } + } + item { + SettingsWebTabGroup( + title = "System notifications", + subtitle = notificationSystemFooter(zh = false), + contentDescription = "Notifications Web System Group Preview $WEB_SSOT_NOTIFICATIONS_TAB", + ) { + val key = "system_notifications" + NotificationWebSwitchRow( + title = "Show system notifications", + subtitle = notificationPreferenceDescription(key, zh = false), + checked = preferences.value(key) != "muted", + saving = mutatingKey == key, + contentDescription = "Notification ${key} Cupertino Switch Preview", + onClick = { togglePreview(key) }, + ) + } + } + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = false, + contentDescription = "Notifications Result Web Banner Preview", + ) + } + } + } +} + +@Composable +private fun PilotWorkspaceSettings( + api: ApiClient, + workspaceId: String, + currentUserId: String, + zh: Boolean, + onBack: () -> Unit, + onReloadSession: () -> Unit, + onSwitchWorkspace: (String) -> Unit, +) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var formOpen by remember(workspaceId) { mutableStateOf(false) } + var editingWorkspace by remember(workspaceId) { mutableStateOf(null) } + var name by remember(workspaceId) { mutableStateOf("") } + var slug by remember(workspaceId) { mutableStateOf("") } + var description by remember(workspaceId) { mutableStateOf("") } + var context by remember(workspaceId) { mutableStateOf("") } + var saving by remember(workspaceId) { mutableStateOf(false) } + var pendingDelete by remember(workspaceId) { mutableStateOf(null) } + var deleting by remember(workspaceId) { mutableStateOf(false) } + var pendingLeave by remember(workspaceId) { mutableStateOf(null) } + var leaving by remember(workspaceId) { mutableStateOf(false) } + var repoRows by remember(workspaceId) { mutableStateOf>(emptyList()) } + var savingRepos by remember(workspaceId) { mutableStateOf(false) } + var coAuthoredByEnabled by remember(workspaceId) { mutableStateOf(true) } + var savingLabs by remember(workspaceId) { mutableStateOf(false) } + var message by remember(workspaceId) { mutableStateOf(null) } + var expandedWorkspaceActionsId by remember(workspaceId) { mutableStateOf(null) } + var workspaceSwitcherOpen by remember(workspaceId) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(workspaceId, refresh) { + state = null + val result = withContext(Dispatchers.IO) { + runCatching { + PilotWorkspaceSettingsData( + current = api.workspace(workspaceId), + workspaces = api.workspaces(), + members = api.members(workspaceId), + ) + } + } + result.onSuccess { + repoRows = it.current.repos.map { repo -> repo.url } + coAuthoredByEnabled = it.current.coAuthoredByEnabled() + } + state = result + } + + fun openCreate() { + editingWorkspace = null + name = "" + slug = "" + description = "" + context = "" + pendingDelete = null + pendingLeave = null + message = null + formOpen = true + } + + fun openEdit(workspace: Models.Workspace) { + editingWorkspace = workspace + name = workspace.name + slug = workspace.slug + description = workspace.description + context = workspace.context + pendingDelete = null + pendingLeave = null + message = null + formOpen = true + } + + fun saveWorkspace() { + val trimmedName = name.trim() + val trimmedSlug = slug.trim() + if (trimmedName.isBlank() || (editingWorkspace == null && trimmedSlug.isBlank()) || saving) return + saving = true + message = null + val editing = editingWorkspace + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + if (editing == null) { + api.createWorkspace(trimmedName, trimmedSlug, description.trim(), context.trim()) + } else { + api.updateWorkspace(editing.id, trimmedName, description.trim(), context.trim()) + } + } + } + saving = false + result.onSuccess { saved -> + formOpen = false + editingWorkspace = null + message = "${if (zh) "工作区已保存" else "Workspace saved"}: ${saved.name}" + refresh++ + if (saved.id == workspaceId) onReloadSession() + }.onFailure { + message = "${if (zh) "工作区保存失败" else "Workspace save failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun deleteWorkspace(workspace: Models.Workspace) { + if (deleting) return + deleting = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.deleteWorkspace(workspace.id) } } + deleting = false + result.onSuccess { + pendingDelete = null + message = "${if (zh) "工作区已删除" else "Workspace deleted"}: ${workspace.name}" + refresh++ + if (workspace.id == workspaceId) { + onBack() + onReloadSession() + } + }.onFailure { + message = "${if (zh) "工作区删除失败" else "Workspace delete failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun leaveWorkspace(workspace: Models.Workspace) { + if (leaving) return + leaving = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.leaveWorkspace(workspace.id) } } + leaving = false + result.onSuccess { + pendingLeave = null + message = "${if (zh) "已离开工作区" else "Workspace left"}: ${workspace.name}" + refresh++ + if (workspace.id == workspaceId) { + onBack() + onReloadSession() + } + }.onFailure { + message = "${if (zh) "离开工作区失败" else "Workspace leave failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun saveRepositories() { + if (savingRepos) return + savingRepos = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.updateWorkspaceRepos(workspaceId, repoRows) } } + savingRepos = false + result.onSuccess { saved -> + repoRows = saved.repos.map { repo -> repo.url } + message = if (zh) "仓库已保存" else "Repositories saved" + refresh++ + }.onFailure { + message = "${if (zh) "仓库保存失败" else "Repository save failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun toggleCoAuthoredBy(enabled: Boolean) { + if (savingLabs) return + savingLabs = true + message = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.updateWorkspaceCoAuthoredBy(workspaceId, enabled) } } + savingLabs = false + result.onSuccess { saved -> + coAuthoredByEnabled = saved.coAuthoredByEnabled() + message = if (zh) "实验功能已保存" else "Labs saved" + refresh++ + }.onFailure { + message = "${if (zh) "实验功能保存失败" else "Labs save failed"}: ${it.message ?: it.toString()}" + } + } + } + + if (formOpen) { + PilotListPage( + title = if (editingWorkspace == null) { + if (zh) "创建工作区" else "Create Workspace" + } else { + if (zh) "编辑工作区" else "Edit Workspace" + }, + leading = { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = if (zh) "返回工作区设置" else "Back to Workspace Settings", + onClick = { + formOpen = false + editingWorkspace = null + }, + tone = MulticaButtonTone.Ghost, + ) + }, + trailing = { + MulticaPillButton( + text = if (saving) "..." else if (zh) "保存" else "Save", + onClick = { saveWorkspace() }, + tone = MulticaButtonTone.Primary, + enabled = name.trim().isNotBlank() && (editingWorkspace != null || slug.trim().isNotBlank()) && !saving, + contentDescription = "Workspace Save Button", + ) + }, + bottomContent = { + MulticaHazeBottomBar( + hazeState = rememberHazeState(), + contentDescription = "Workspace Form Bottom Haze Save Bar", + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + formOpen = false + editingWorkspace = null + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (editingWorkspace == null) { + if (zh) "创建工作区" else "Create Workspace" + } else { + if (zh) "保存工作区" else "Save Workspace" + }, + onClick = { saveWorkspace() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + enabled = name.trim().isNotBlank() && (editingWorkspace != null || slug.trim().isNotBlank()) && !saving, + contentDescription = "Workspace Form Bottom Save", + ) + } + } + }, + ) { + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "Workspace Result Web Banner", + ) + } + } + item { + WorkspaceFormWebPanel( + title = if (editingWorkspace == null) { + if (zh) "创建工作区" else "Create Workspace" + } else { + if (zh) "编辑工作区" else "Edit Workspace" + }, + subtitle = if (editingWorkspace == null) { + if (zh) "名称、slug、描述和上下文" else "Name, slug, description, and context" + } else { + if (zh) "名称、描述和上下文" else "Name, description, and context" + }, + zh = zh, + ) { + MulticaTextField( + value = name, + onValueChange = { name = it }, + label = if (zh) "工作区名称" else "Workspace name", + contentDescription = "Workspace Form Name Field", + singleLine = true, + ) + MulticaTextField( + value = slug, + onValueChange = { slug = it }, + enabled = editingWorkspace == null, + label = if (zh) "工作区 slug" else "Workspace slug", + contentDescription = "Workspace Form Slug Field", + singleLine = true, + ) + MulticaTextField( + value = description, + onValueChange = { description = it }, + label = if (zh) "工作区描述" else "Workspace description", + contentDescription = "Workspace Form Description Field", + minLines = 3, + ) + MulticaTextField( + value = context, + onValueChange = { context = it }, + label = if (zh) "工作区上下文" else "Workspace context", + contentDescription = "Workspace Form Context Field", + minLines = 4, + ) + } + } + } + return + } + + PilotListPage( + title = if (zh) "工作区详情" else "Workspace Details", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载工作区..." else "Loading workspaces...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "工作区加载失败" else "Workspace load failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { data -> + val currentMember = data.members.firstOrNull { it.userId == currentUserId } + val canManageWorkspace = currentMember?.role == "owner" || currentMember?.role == "admin" + item { + WorkspaceIdentitySummaryCard( + workspace = data.current, + role = currentMember?.role.orEmpty(), + zh = zh, + canManageWorkspace = canManageWorkspace, + onEdit = { if (canManageWorkspace) openEdit(data.current) }, + ) + } + if (!canManageWorkspace) { + item { + PilotInlineResultState( + message = if (zh) { + "你可以查看 workspace 详情;只有 owner 和 admin 可以修改设置。" + } else { + "You can review workspace details. Only owners and admins can change settings." + }, + isError = false, + contentDescription = "Workspace Permission Read Only", + ) + } + } + item { + WorkspaceDetailWebGeneralForm( + workspace = data.current, + zh = zh, + onEdit = { if (canManageWorkspace) openEdit(data.current) }, + ) + } + if (canManageWorkspace) item { + SettingsWebTabGroup( + title = if (zh) "管理工作区" else "Manage Workspaces", + subtitle = if (zh) "创建新 workspace,或在底部切换已有 workspace" else "Create a new workspace or switch below", + ) { + SettingsWebTabRow( + title = if (zh) "创建工作区" else "Create Workspace", + subtitle = if (zh) "创建一个新的独立 workspace" else "Create a new standalone workspace", + icon = Icons.Outlined.Add, + onClick = { openCreate() }, + ) + } + } + if (formOpen) { + item { + WorkspaceFormWebPanel( + title = if (editingWorkspace == null) { + if (zh) "创建工作区" else "Create Workspace" + } else { + if (zh) "编辑工作区" else "Edit Workspace" + }, + subtitle = if (editingWorkspace == null) { + if (zh) "名称、slug、描述和上下文" else "Name, slug, description, and context" + } else { + if (zh) "名称、描述和上下文" else "Name, description, and context" + }, + zh = zh, + ) { + MulticaTextField( + value = name, + onValueChange = { name = it }, + label = if (zh) "工作区名称" else "Workspace name", + contentDescription = "Workspace Form Name Field", + singleLine = true, + ) + MulticaTextField( + value = slug, + onValueChange = { slug = it }, + enabled = editingWorkspace == null, + label = if (zh) "工作区 slug" else "Workspace slug", + contentDescription = "Workspace Form Slug Field", + singleLine = true, + ) + MulticaTextField( + value = description, + onValueChange = { description = it }, + label = if (zh) "工作区描述" else "Workspace description", + contentDescription = "Workspace Form Description Field", + minLines = 2, + ) + MulticaTextField( + value = context, + onValueChange = { context = it }, + label = if (zh) "工作区上下文" else "Workspace context", + contentDescription = "Workspace Form Context Field", + minLines = 3, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + formOpen = false + editingWorkspace = null + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (saving) "..." else if (zh) "保存工作区" else "Save Workspace", + onClick = { saveWorkspace() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + } + } + } + item { + SettingsWebTabGroup( + title = if (zh) "切换工作区" else "Switch Workspace", + subtitle = if (zh) "从列表中切换当前 workspace" else "Switch the current workspace from this list", + contentDescription = "Workspace Switcher Cupertino Picker", + ) { + SettingsWebTabRow( + title = data.current.name, + subtitle = data.current.slug.ifBlank { if (zh) "当前 workspace" else "Current workspace" }, + value = if (zh) "当前" else "Current", + icon = Icons.Outlined.Folder, + onClick = { workspaceSwitcherOpen = true }, + contentDescription = "Workspace Switcher Open Picker", + ) + } + } + if (workspaceSwitcherOpen) { + item { + MulticaCupertinoActionSheet( + visible = true, + title = if (zh) "切换工作区" else "Switch Workspace", + message = if (zh) "当前 workspace 会立即切换" else "The current workspace changes immediately", + items = data.workspaces.map { workspace -> + MulticaCupertinoActionSheetItem( + title = workspace.name, + subtitle = workspace.slug, + icon = Icons.Outlined.Folder, + selected = workspace.id == workspaceId, + contentDescription = "Workspace Switcher Option ${workspace.id}", + onClick = { + workspaceSwitcherOpen = false + if (workspace.id != workspaceId) onSwitchWorkspace(workspace.id) + }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { workspaceSwitcherOpen = false }, + ) + } + } + if (canManageWorkspace) item { + SettingsWebTabGroup( + title = if (zh) "代码仓库" else "Repositories", + subtitle = if (zh) "这些 GitHub 仓库会出现在项目资源选择器里" else "These GitHub repositories appear in project resource pickers", + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (repoRows.isEmpty()) { + Text( + text = if (zh) "暂无仓库" else "No repositories", + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(vertical = 7.dp), + ) + } + repoRows.forEachIndexed { index, value -> + WorkspaceDetailRepoDenseRow( + value = value, + index = index, + zh = zh, + showDivider = index < repoRows.lastIndex, + onValueChange = { next -> + repoRows = repoRows.toMutableList().also { it[index] = next } + }, + onRemove = { + repoRows = repoRows.filterIndexed { rowIndex, _ -> rowIndex != index } + }, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaPillButton( + text = if (zh) "添加仓库" else "Add Repository", + onClick = { repoRows = repoRows + "" }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (savingRepos) "..." else if (zh) "保存仓库" else "Save Repositories", + onClick = { saveRepositories() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + } + } + } + if (canManageWorkspace) item { + SettingsWebTabGroup( + title = if (zh) "实验功能" else "Labs", + subtitle = if (zh) "和 Web Labs tab 保持一致的轻量开关" else "Lightweight toggles mirrored from the web Labs tab", + ) { + WorkspaceDetailLabsRow( + enabled = coAuthoredByEnabled, + saving = savingLabs, + zh = zh, + onToggle = { toggleCoAuthoredBy(it) }, + ) + } + } + item { + SettingsWebTabGroup( + title = if (zh) "危险操作" else "Danger Zone", + subtitle = if (zh) "离开或删除当前工作区" else "Leave or delete the current workspace", + ) { + WorkspaceDetailDangerRow( + title = if (zh) "离开工作区" else "Leave workspace", + subtitle = if (zh) "从当前 workspace 移除自己的成员关系" else "Remove your membership from this workspace", + zh = zh, + destructive = false, + showDivider = true, + onClick = { + pendingLeave = data.current + pendingDelete = null + }, + ) + WorkspaceDetailDangerRow( + title = if (zh) "删除工作区" else "Delete workspace", + subtitle = if (zh) "删除 workspace 及其协作上下文" else "Delete the workspace and its collaboration context", + zh = zh, + destructive = true, + enabled = canManageWorkspace, + onClick = { + if (canManageWorkspace) { + pendingDelete = data.current + pendingLeave = null + } + }, + ) + } + } + pendingLeave?.let { workspace -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "离开工作区?" else "Leave workspace?", + message = if (zh) { + "这会从 ${workspace.name} 移除你的成员关系。你需要重新受邀才能回来。" + } else { + "This removes your membership from ${workspace.name}. You will need a new invitation to return." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (leaving) "..." else if (zh) "离开" else "Leave", + destructive = true, + contentDescription = "Workspace Leave Cupertino Alert ${workspace.id}", + onDismissRequest = { pendingLeave = null }, + onConfirm = { leaveWorkspace(workspace) }, + ) + } + } + pendingDelete?.let { workspace -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "删除工作区?" else "Delete workspace?", + message = if (zh) { + "这会删除 ${workspace.name} 及其协作上下文。请确认你已经不再需要它。" + } else { + "This deletes ${workspace.name} and its collaboration context. Confirm you no longer need it." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (deleting) "..." else if (zh) "删除" else "Delete", + destructive = true, + contentDescription = "Workspace Delete Cupertino Alert ${workspace.id}", + onDismissRequest = { pendingDelete = null }, + onConfirm = { deleteWorkspace(workspace) }, + ) + } + } + if (!message.isNullOrBlank()) { + item { + PilotInlineResultState( + message = message.orEmpty(), + isError = pilotInlineResultIsError(message.orEmpty()), + contentDescription = "Workspace Result Web Banner", + ) + } + } + item { SettingsSectionLabel(if (zh) "所有工作区" else "All Workspaces") } + items(data.workspaces) { workspace -> + WorkspaceWebDenseRow( + workspace = workspace, + current = workspace.id == workspaceId, + zh = zh, + expanded = expandedWorkspaceActionsId == workspace.id, + onExpandedChange = { expandedWorkspaceActionsId = if (it) workspace.id else null }, + onEdit = { + expandedWorkspaceActionsId = null + openEdit(workspace) + }, + onDelete = { + expandedWorkspaceActionsId = null + pendingDelete = workspace + pendingLeave = null + formOpen = false + editingWorkspace = null + message = null + }, + onLeave = { + expandedWorkspaceActionsId = null + pendingLeave = workspace + pendingDelete = null + formOpen = false + editingWorkspace = null + message = null + }, + ) + } + } + } + } + } +} + +@Composable +private fun PilotWorkspaceSettingsPreview() { + val workspaces = remember { + listOf( + Models.Workspace( + JSONObject() + .put("id", "preview-workspace") + .put("name", "Multica Design Lab") + .put("slug", "design-lab") + .put("description", "Mobile design QA and product polish") + .put("context", "iOS-inspired Android workspace for visual system work.") + .put("issue_prefix", "MUL") + .put("created_at", "2026-05-14T09:30:00Z") + ), + Models.Workspace( + JSONObject() + .put("id", "preview-ops") + .put("name", "Automation Ops") + .put("slug", "automation-ops") + .put("description", "Agent automation operations") + .put("issue_prefix", "OPS") + .put("created_at", "2026-04-21T12:00:00Z") + ), + ) + } + val workspace = workspaces.first() + var editOpen by remember { mutableStateOf(false) } + var switcherOpen by remember { mutableStateOf(false) } + var pendingLeave by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } + var name by remember { mutableStateOf(workspace.name) } + var slug by remember { mutableStateOf(workspace.slug) } + var description by remember { mutableStateOf(workspace.description) } + var context by remember { mutableStateOf(workspace.context) } + + if (editOpen) { + PilotListPage( + title = "Edit Workspace", + leading = { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back to Workspace Settings", + onClick = { editOpen = false }, + tone = MulticaButtonTone.Ghost, + ) + }, + bottomContent = { + MulticaHazeBottomBar( + hazeState = rememberHazeState(), + contentDescription = "Workspace Form Bottom Haze Save Bar Preview", + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = "Cancel", + onClick = { editOpen = false }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = "Save Workspace", + onClick = { editOpen = false }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + contentDescription = "Workspace Form Bottom Save Preview", + ) + } + } + }, + ) { + item { + WorkspaceFormWebPanel( + title = "Edit Workspace", + subtitle = "Name, description, and context", + zh = false, + ) { + MulticaTextField(value = name, onValueChange = { name = it }, label = "Workspace name", contentDescription = "Workspace Form Name Field Preview", singleLine = true) + MulticaTextField(value = slug, onValueChange = { slug = it }, enabled = false, label = "Workspace slug", contentDescription = "Workspace Form Slug Field Preview", singleLine = true) + MulticaTextField(value = description, onValueChange = { description = it }, label = "Workspace description", contentDescription = "Workspace Form Description Field Preview", minLines = 3) + MulticaTextField(value = context, onValueChange = { context = it }, label = "Workspace context", contentDescription = "Workspace Form Context Field Preview", minLines = 4) + } + } + } + return + } + + PilotListPage( + title = "Workspace Details", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + ) { + item { + WorkspaceIdentitySummaryCard( + workspace = workspace, + role = "owner", + zh = false, + canManageWorkspace = true, + onEdit = { editOpen = true }, + ) + } + item { + WorkspaceDetailWebGeneralForm( + workspace = workspace, + zh = false, + onEdit = { editOpen = true }, + ) + } + item { + SettingsWebTabGroup( + title = "Switch Workspace", + subtitle = "Switch the current workspace from this list", + contentDescription = "Workspace Switcher Cupertino Picker Preview", + ) { + SettingsWebTabRow( + title = workspace.name, + subtitle = workspace.slug, + value = "Current", + icon = Icons.Outlined.Folder, + onClick = { switcherOpen = true }, + contentDescription = "Workspace Switcher Open Picker Preview", + ) + } + } + if (switcherOpen) { + item { + MulticaCupertinoActionSheet( + visible = true, + title = "Switch Workspace", + message = "The current workspace changes immediately", + items = workspaces.map { item -> + MulticaCupertinoActionSheetItem( + title = item.name, + subtitle = item.slug, + icon = Icons.Outlined.Folder, + selected = item.id == workspace.id, + contentDescription = "Workspace Switcher Option Preview ${item.id}", + onClick = { switcherOpen = false }, + ) + }, + cancelText = "Cancel", + onDismissRequest = { switcherOpen = false }, + ) + } + } + item { + SettingsWebTabGroup( + title = "Danger Zone", + subtitle = "Leave or delete the current workspace", + ) { + WorkspaceDetailDangerRow( + title = "Leave workspace", + subtitle = "Remove your membership from this workspace", + zh = false, + destructive = false, + showDivider = true, + onClick = { + pendingLeave = workspace + pendingDelete = null + }, + ) + WorkspaceDetailDangerRow( + title = "Delete workspace", + subtitle = "Delete the workspace and its collaboration context", + zh = false, + destructive = true, + onClick = { + pendingDelete = workspace + pendingLeave = null + }, + ) + } + } + pendingLeave?.let { + item { + MulticaCupertinoAlertDialog( + visible = true, + title = "Leave workspace?", + message = "This removes your membership from ${it.name}. You will need a new invitation to return.", + cancelText = "Cancel", + confirmText = "Leave", + destructive = true, + contentDescription = "Workspace Leave Cupertino Alert Preview ${it.id}", + onDismissRequest = { pendingLeave = null }, + onConfirm = { pendingLeave = null }, + ) + } + } + pendingDelete?.let { + item { + MulticaCupertinoAlertDialog( + visible = true, + title = "Delete workspace?", + message = "This deletes ${it.name} and its collaboration context. Confirm you no longer need it.", + cancelText = "Cancel", + confirmText = "Delete", + destructive = true, + contentDescription = "Workspace Delete Cupertino Alert Preview ${it.id}", + onDismissRequest = { pendingDelete = null }, + onConfirm = { pendingDelete = null }, + ) + } + } + } +} + +@Composable +private fun WorkspaceIdentitySummaryCard( + workspace: Models.Workspace, + role: String, + zh: Boolean, + canManageWorkspace: Boolean, + onEdit: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Workspace Summary Hero ${workspace.id}" }, + shape = RoundedCornerShape(16.dp), + color = MulticaColors.Surface.copy(alpha = 0.94f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.5.dp, MulticaColors.Border.copy(alpha = 0.70f)), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Accent.copy(alpha = 0.12f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = workspace.name.firstOrNull()?.uppercaseChar()?.toString() ?: "W", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MulticaColors.Accent, + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = workspace.name, + modifier = Modifier.semantics { contentDescription = "Workspace Summary Name ${workspace.id}" }, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = workspace.slug.ifBlank { Models.shortId(workspace.id) }, + modifier = Modifier.semantics { contentDescription = "Workspace Summary Slug ${workspace.id}" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (canManageWorkspace) { + MulticaIconPillButton( + icon = Icons.Outlined.Edit, + contentDescription = "Workspace Summary Edit", + onClick = onEdit, + tone = MulticaButtonTone.Ghost, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + WorkspaceSummaryChip( + title = if (zh) "角色" else "Role", + value = role.ifBlank { if (zh) "成员" else "member" }, + contentDescription = "Workspace Summary Role ${workspace.id}", + modifier = Modifier.weight(1f), + ) + WorkspaceSummaryChip( + title = if (zh) "创建" else "Created", + value = shortDate(workspace.createdAt).ifBlank { if (zh) "未知" else "Unknown" }, + contentDescription = "Workspace Summary Created ${workspace.id}", + modifier = Modifier.weight(1f), + ) + } + if (workspace.description.isNotBlank() || workspace.context.isNotBlank()) { + Text( + text = listOf(clean(workspace.description), clean(workspace.context)).filter { it.isNotBlank() }.joinToString("\n"), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun WorkspaceSummaryChip( + title: String, + value: String, + contentDescription: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.semantics(mergeDescendants = true) { this.contentDescription = contentDescription }, + shape = RoundedCornerShape(11.dp), + color = MulticaColors.Border.copy(alpha = 0.30f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = value, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun WorkspaceFormWebPanel( + title: String, + subtitle: String, + zh: Boolean, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Workspace Form Web Panel" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.74f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Border.copy(alpha = 0.34f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Workspace Form Web Field Group" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } + } +} + +@Composable +private fun WorkspaceDetailWebGeneralForm( + workspace: Models.Workspace, + zh: Boolean, + onEdit: () -> Unit, +) { + SettingsWebTabGroup( + title = if (zh) "当前工作区" else "Current Workspace", + subtitle = if (zh) "与 Web general tab 对齐的只读摘要" else "Read-only summary aligned with the web general tab", + contentDescription = "Workspace Detail Web General Form", + ) { + SettingsWebTabRow( + title = workspace.name, + subtitle = workspace.slug, + value = workspace.issuePrefix, + icon = Icons.Outlined.Folder, + showDivider = true, + onClick = onEdit, + ) + SettingsWebTabRow( + title = if (zh) "描述" else "Description", + subtitle = clean(workspace.description).ifBlank { if (zh) "暂无描述" else "No description" }, + icon = Icons.Outlined.Edit, + showDivider = true, + onClick = onEdit, + ) + SettingsWebTabRow( + title = if (zh) "上下文" else "Context", + subtitle = clean(workspace.context).ifBlank { if (zh) "暂无上下文" else "No context" }, + icon = Icons.Outlined.ViewList, + onClick = onEdit, + ) + } +} + +@Composable +private fun WorkspaceDetailRepoDenseRow( + value: String, + index: Int, + zh: Boolean, + showDivider: Boolean, + onValueChange: (String) -> Unit, + onRemove: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Workspace Detail Web Repo Row ${index + 1}" }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + label = if (zh) "仓库 URL" else "Repository URL", + contentDescription = "Workspace Repository URL Field ${index + 1}", + singleLine = true, + ) + MulticaIconPillButton( + icon = Icons.Outlined.Delete, + contentDescription = "Workspace Repository Remove ${index + 1}", + onClick = onRemove, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(38.dp) + .semantics { contentDescription = "Workspace Repository Remove ${index + 1}" }, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun WorkspaceDetailLabsRow( + enabled: Boolean, + saving: Boolean, + zh: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 7.dp) + .semantics(mergeDescendants = false) { contentDescription = "Workspace Detail Web Labs Row" }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + modifier = Modifier.size(28.dp), + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Border.copy(alpha = 0.38f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Link, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(15.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "Co-authored-by", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) { + "在 Agent 生成的提交中追加 co-author trailer" + } else { + "Append a co-author trailer to agent-authored commits" + }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (saving) { + Text( + text = "...", + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(top = 4.dp), + ) + } else { + CupertinoSwitch( + checked = enabled, + onCheckedChange = onToggle, + modifier = Modifier.semantics { contentDescription = "Workspace Co-authored-by Cupertino Switch" }, + ) + } + } +} + +@Composable +private fun WorkspaceDetailDangerRow( + title: String, + subtitle: String, + zh: Boolean, + destructive: Boolean, + enabled: Boolean = true, + showDivider: Boolean = false, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = "Workspace Detail Web Danger Row $title" + if (enabled) { + semanticsOnClick(label = title) { + onClick() + true + } + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (destructive) Icons.Outlined.Delete else Icons.Outlined.Archive, + contentDescription = null, + tint = if (destructive) MulticaColors.Danger else MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + color = if (destructive) MulticaColors.Danger else MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = if (!enabled) { + if (zh) "只读" else "Read-only" + } else if (zh) "继续" else "Continue", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = if (destructive) MulticaColors.Danger else MulticaColors.Muted, + maxLines = 1, + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +@Composable +private fun WorkspaceWebDenseRow( + workspace: Models.Workspace, + current: Boolean, + zh: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onLeave: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Workspace Web Dense Row ${workspace.name}" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.Dashboard, contentDescription = null, tint = if (current) MulticaColors.Accent else MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + workspace.name, + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (current) { + Text( + if (zh) "当前" else "Current", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + ) + } + } + Text( + listOf(workspace.slug, workspace.issuePrefix, clean(workspace.description)) + .filter { it.isNotBlank() } + .joinToString(" · ") + .ifBlank { if (zh) "暂无描述" else "No description" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Workspace Actions ${workspace.name}", + onClick = { onExpandedChange(true) }, + tone = if (expanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "Workspace Actions ${workspace.name}" }, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "工作区操作" else "Workspace actions", + message = workspace.name.ifBlank { workspace.slug }, + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "编辑" else "Edit", + icon = Icons.Outlined.Edit, + contentDescription = "Workspace Edit ${workspace.name}", + onClick = onEdit, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "离开" else "Leave", + icon = Icons.Outlined.Close, + destructive = true, + contentDescription = "Workspace Leave ${workspace.name}", + onClick = onLeave, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "删除" else "Delete", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Workspace Delete ${workspace.name}", + onClick = onDelete, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { onExpandedChange(false) }, + ) + } + } +} + +@Composable +private fun PilotTextWebRow( + eyebrow: String, + title: String, + subtitle: String?, +) { + val shape = RoundedCornerShape(10.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Pilot Text Web Row ${title.ifBlank { eyebrow }}" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.72f), shape) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + if (eyebrow.isNotBlank()) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun PilotTextRowsSettings( + page: PilotSettingsPage, + api: ApiClient, + workspaceId: String, + zh: Boolean, + onBack: () -> Unit, +) { + var state by remember(page, workspaceId) { mutableStateOf>?>(null) } + var refresh by remember(page, workspaceId) { mutableIntStateOf(0) } + val title = settingsPageTitle(page) + + LaunchedEffect(page, workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + when (page) { + PilotSettingsPage.WorkspaceDetails -> listOf(api.workspaceSummary(workspaceId)) + PilotSettingsPage.Members -> api.memberSummaries(workspaceId) + PilotSettingsPage.Notifications -> api.notificationSummaries(workspaceId) + PilotSettingsPage.ApiTokens -> api.tokenSummaries() + PilotSettingsPage.Autopilots -> api.autopilots(workspaceId).map { + "${it.title}\n${clean(it.description)}\n${it.status}" + } + PilotSettingsPage.Runtimes -> api.runtimes(workspaceId).map { + "${it.name}\n${it.status}${if (it.version.isBlank()) "" else " · ${it.version}"}" + } + else -> emptyList() + } + } + } + } + + PilotListPage( + title = title, + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载..." else "Loading...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { rows -> + if (rows.isEmpty()) item { SettingsTextRowsWebEmptyState(zh = zh) } + items(rows) { row -> + val parts = row.split('\n') + PilotTextWebRow( + eyebrow = parts.getOrNull(1).orEmpty(), + title = parts.firstOrNull().orEmpty().ifBlank { title }, + subtitle = parts.drop(2).joinToString(" · ").ifBlank { null }, + ) + } + } + } + } + } +} + +@Composable +private fun SettingsTextRowsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SETTINGS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/settings-page.tsx" + val WEB_SSOT_SETTINGS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/settings.json" + Box( + modifier = modifier.semantics { + contentDescription = "Settings Text Rows Web Empty State $WEB_SSOT_SETTINGS_PAGE $WEB_SSOT_SETTINGS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "设置" else "Settings", + description = if (zh) "工作区" else "Workspace", + ) + } +} + +private enum class ApiTokenSettingsMode { List, Create } + +@Composable +private fun PilotApiTokensSettings(api: ApiClient, zh: Boolean, onBack: () -> Unit) { + var state by remember { mutableStateOf>?>(null) } + var refresh by remember { mutableIntStateOf(0) } + var apiTokenMode by remember { mutableStateOf(ApiTokenSettingsMode.List) } + var name by remember { mutableStateOf("") } + var expiryIndex by remember { mutableIntStateOf(1) } + var saving by remember { mutableStateOf(false) } + var pendingRevoke by remember { mutableStateOf(null) } + var resultText by remember { mutableStateOf(null) } + var createdSecret by remember { mutableStateOf(null) } + var createdSecretCopied by remember { mutableStateOf(false) } + var createdTokens by remember { mutableStateOf>(emptyList()) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val expiryOptions = listOf(30 to "30 days", 90 to "90 days", 365 to "365 days", null to "Never") + + LaunchedEffect(refresh) { + state = null + state = withContext(Dispatchers.IO) { runCatching { api.personalAccessTokens() } } + } + + fun resetCreateForm() { + apiTokenMode = ApiTokenSettingsMode.List + name = "" + expiryIndex = 1 + createdSecretCopied = false + } + + fun createToken() { + val tokenName = name.trim() + if (tokenName.isEmpty() || saving) return + saving = true + resultText = null + createdSecret = null + createdSecretCopied = false + val expires = expiryOptions[expiryIndex.coerceAtMost(expiryOptions.lastIndex)].first + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.createPersonalAccessToken(tokenName, expires) } + } + saving = false + result.onSuccess { + createdTokens = listOf(it) + createdTokens.filter { token -> token.id != it.id } + createdSecret = it.token + createdSecretCopied = false + resultText = if (zh) "API 令牌已创建,请立即复制保存。" else "API token created. Copy it now." + resetCreateForm() + refresh++ + }.onFailure { + createdSecret = null + createdSecretCopied = false + resultText = "${if (zh) "创建失败" else "Create failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun copyCreatedToken(secret: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Multica API token", secret)) + createdSecretCopied = true + resultText = if (zh) "API 令牌已复制" else "API token copied" + } + + fun revokeToken(token: Models.PersonalAccessToken) { + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.revokePersonalAccessToken(token.id) } + } + result.onSuccess { + createdTokens = createdTokens.filter { it.id != token.id } + pendingRevoke = null + resultText = if (zh) "API 令牌已撤销" else "API token revoked" + refresh++ + }.onFailure { + pendingRevoke = null + resultText = "${if (zh) "撤销失败" else "Revoke failed"}: ${it.message ?: it.toString()}" + } + } + } + + if (apiTokenMode == ApiTokenSettingsMode.Create) { + PilotListPage( + title = if (zh) "新建 API 令牌" else "New API Token", + leading = { + PilotSecondaryBackButton( + zh = zh, + onBack = { + resetCreateForm() + resultText = null + createdSecret = null + createdSecretCopied = false + }, + contentDescription = if (zh) "返回 API Tokens" else "Back to API Tokens", + ) + }, + bottomContent = { + Surface( + modifier = Modifier + .fillMaxWidth() + .imePadding(), + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + resetCreateForm() + resultText = null + createdSecret = null + createdSecretCopied = false + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + contentDescription = "API Token Form Cancel", + ) + MulticaPillButton( + text = if (saving) "..." else if (zh) "创建" else "Create", + onClick = { createToken() }, + modifier = Modifier.weight(1.2f), + tone = MulticaButtonTone.Primary, + contentDescription = "API Token Form Bottom Create", + ) + } + } + }, + ) { + item { + ApiTokenCreateIntroWebPanel(zh = zh) + } + item { SettingsSectionLabel(if (zh) "令牌信息" else "Token") } + item { + MulticaTextField( + value = name, + onValueChange = { name = it }, + label = if (zh) "令牌名称" else "Token name", + singleLine = true, + contentDescription = "API Token Form Name", + ) + } + item { + val option = expiryOptions[expiryIndex.coerceAtMost(expiryOptions.lastIndex)] + ApiTokenFormExpirationWebPicker( + options = expiryOptions, + selectedIndex = expiryIndex, + zh = zh, + onSelect = { expiryIndex = it }, + ) + } + if (!resultText.isNullOrBlank()) { + item { + if (resultText!!.contains("failed", true) || resultText!!.contains("失败")) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "API Token Create Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + resetCreateForm() + resultText = null + createdSecret = null + createdSecretCopied = false + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (zh) "创建" else "Create", + onClick = { createToken() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + contentDescription = "API Token Form Create", + ) + } + } + } + return + } + + PilotListPage( + title = if (zh) "API 令牌" else "API Tokens", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = if (zh) "新建 API Token" else "New API Token", + onClick = { + apiTokenMode = ApiTokenSettingsMode.Create + resultText = null + createdSecret = null + createdSecretCopied = false + }, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "API Token Create Entry" } + .clickable { + apiTokenMode = ApiTokenSettingsMode.Create + resultText = null + createdSecret = null + createdSecretCopied = false + }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.AccentSoft.copy(alpha = 0.48f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Accent.copy(alpha = 0.18f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + if (zh) "新建 API 令牌" else "Create API Token", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (zh) "设置名称与有效期,密钥只显示一次" else "Set name and expiration; secret is shown once", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier + .size(17.dp) + .semantics { contentDescription = "API Token Create Entry Arrow" }, + ) + } + } + } + if (!resultText.isNullOrBlank()) { + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (resultText!!.contains("failed", true) || resultText!!.contains("失败")) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "API Token List Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + } + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载令牌..." else "Loading tokens...") } + else -> { + loaded.onFailure { item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } } + loaded.onSuccess { tokens -> + val mergedTokens = (createdTokens + tokens).distinctBy { it.id } + if (mergedTokens.isEmpty()) item { ApiTokensWebEmptyState(zh = zh) } + if (mergedTokens.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "API Token Web Table Header" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (zh) "令牌" else "Token", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + if (zh) "前缀" else "Prefix", + modifier = Modifier.width(82.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.width(42.dp)) + } + } + } + items(mergedTokens) { token -> + ApiTokenWebDenseRow( + token = token, + zh = zh, + onRevoke = { pendingRevoke = token }, + ) + } + } + } + } + } + pendingRevoke?.let { token -> + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "撤销令牌?" else "Revoke token?", + message = if (zh) { + "令牌 ${token.name} 将立即失效,相关脚本需要重新配置。" + } else { + "${token.name} will stop working immediately. Connected scripts need a new token." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (zh) "确认撤销" else "Revoke", + destructive = true, + contentDescription = "API Token Revoke Cupertino Alert", + onDismissRequest = { pendingRevoke = null }, + onConfirm = { revokeToken(token) }, + ) + } + createdSecret?.let { secret -> + MulticaCupertinoSecretDialog( + visible = true, + title = if (zh) "API 令牌已创建" else "API token created", + message = if (zh) { + "这个密钥只会显示一次。请立即复制并保存到安全位置。" + } else { + "This secret is shown once. Copy it now and store it somewhere safe." + }, + secret = secret, + copied = createdSecretCopied, + copyText = if (createdSecretCopied) { + if (zh) "再次复制" else "Copy again" + } else { + if (zh) "复制" else "Copy" + }, + doneText = if (zh) "完成" else "Done", + copiedText = if (zh) "已复制" else "Copied", + contentDescription = "API Token Created Secret Dialog", + onDismissRequest = { + createdSecret = null + createdSecretCopied = false + }, + onCopy = { copyCreatedToken(secret) }, + ) + } +} + +@Composable +private fun ApiTokensWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SETTINGS_TOKENS_TAB = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/tokens-tab.tsx" + val WEB_SSOT_SETTINGS_TOKENS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/settings.json" + Box( + modifier = modifier.semantics { + contentDescription = "API Tokens Web Empty State $WEB_SSOT_SETTINGS_TOKENS_TAB $WEB_SSOT_SETTINGS_TOKENS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有 API Token" else "No API tokens yet", + description = if (zh) { + "个人访问 token 让 CLI 和外部集成可以代表你的账号进行身份验证。" + } else { + "Personal access tokens allow the CLI and external integrations to authenticate with your account." + }, + ) + } +} + +@Composable +private fun ApiTokenCreateIntroWebPanel(zh: Boolean) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "API Token Create Web Panel" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.74f), RoundedCornerShape(12.dp)) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Border.copy(alpha = 0.34f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Key, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (zh) "新建 API 令牌" else "New API Token", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) "令牌密钥只会显示一次。设置名称与有效期,创建后立即复制保存。" else "The secret is shown once. Set a name and expiration, then copy it immediately after creating.", + modifier = Modifier.semantics { contentDescription = "API Token Create Web Description" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + } +} + +@Composable +private fun ApiTokenFormExpirationWebPicker( + options: List>, + selectedIndex: Int, + zh: Boolean, + onSelect: (Int) -> Unit, +) { + val safeIndex = selectedIndex.coerceIn(0, options.lastIndex) + val selected = options[safeIndex] + AgentFormWebMenuRow( + eyebrow = if (zh) "有效期" else "Expiration", + title = if (zh && selected.first == null) "永不过期" else selected.second, + subtitle = if (zh) "选择令牌自动失效时间" else "Choose when this token expires", + contentDescription = "API Token Form Expiration Web Picker", + options = options.mapIndexed { index, option -> + AgentFormMenuOption( + label = if (zh && option.first == null) "永不过期" else option.second, + detail = when (option.first) { + 30 -> if (zh) "适合短期脚本和临时集成" else "Best for short-lived scripts" + 90 -> if (zh) "默认安全周期" else "Default safety window" + 365 -> if (zh) "适合长期但仍需轮换的集成" else "Long-lived but still rotates" + null -> if (zh) "仅用于明确需要长期访问的场景" else "Only for explicit long-term access" + else -> null + }, + selected = index == safeIndex, + onClick = { onSelect(index) }, + ) + }, + ) +} + +@Composable +private fun ApiTokenWebDenseRow( + token: Models.PersonalAccessToken, + zh: Boolean, + onRevoke: () -> Unit, +) { + val WEB_SSOT_SETTINGS_TOKENS_TAB = "/Users/park0er/coding/multica-web-source/packages/views/settings/components/tokens-tab.tsx" + val prefix = token.tokenPrefix.ifBlank { Models.shortId(token.id) } + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "API Token Web Dense Row ${token.name} $WEB_SSOT_SETTINGS_TOKENS_TAB" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.Link, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + token.name, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOf( + if (token.createdAt.isNotBlank()) "${if (zh) "创建" else "Created"} ${shortDate(token.createdAt)}" else "", + if (token.expiresAt.isNotBlank()) { + "${if (zh) "过期" else "Expires"} ${shortDate(token.expiresAt)}" + } else { + if (zh) "不过期" else "No expiration" + }, + if (token.lastUsedAt.isNotBlank()) { + "${if (zh) "上次使用" else "Last used"} ${shortDate(token.lastUsedAt)}" + } else { + if (zh) "从未使用" else "Never used" + }, + ).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + prefix, + modifier = Modifier + .width(82.dp) + .semantics { contentDescription = "API Token Prefix $prefix" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + MulticaIconPillButton( + icon = Icons.Outlined.Delete, + contentDescription = "API Token Revoke ${token.name}", + onClick = onRevoke, + tone = MulticaButtonTone.Destructive, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "API Token Revoke ${token.name}" }, + ) + } + } +} + +private enum class AutopilotSettingsMode { List, Create, Edit } + +private data class AutopilotFormMenuOption( + val label: String, + val detail: String?, + val selected: Boolean, + val onClick: () -> Unit, +) + +private data class AutopilotTemplateOption( + val id: String, + val title: String, + val summary: String, + val prompt: String, + val icon: ImageVector, +) + +@Composable +private fun AutopilotFormWebMenuRow( + eyebrow: String, + title: String, + subtitle: String?, + contentDescription: String, + options: List, + modifier: Modifier = Modifier, +) { + AgentFormWebMenuRow( + eyebrow = eyebrow, + title = title, + subtitle = subtitle, + contentDescription = contentDescription, + options = options.map { option -> + AgentFormMenuOption( + label = option.label, + detail = option.detail, + selected = option.selected, + onClick = option.onClick, + ) + }, + modifier = modifier, + ) +} + +@Composable +private fun AutopilotTemplateCompactStrip( + zh: Boolean, + onTemplateClick: (AutopilotTemplateOption) -> Unit, + onBlankClick: () -> Unit, + blankContentDescription: String = "Autopilot Create Entry", + blankArrowContentDescription: String = "Autopilot Create Entry Arrow", + blankTrailingIcon: ImageVector = Icons.Outlined.KeyboardArrowRight, +) { + val WEB_SSOT_AUTOPILOTS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilots-page.tsx" + val templates = autopilotTemplateOptions(zh) + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Autopilot Template Compact Strip $WEB_SSOT_AUTOPILOTS_PAGE" + }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (zh) "模板" else "Templates", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + lineHeight = 15.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) "轻量入口,不遮挡列表" else "Compact starters", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(templates) { template -> + AutopilotTemplateChip( + title = template.title, + summary = template.summary, + icon = template.icon, + contentDescription = autopilotTemplateContentDescription(template.id), + onClick = { onTemplateClick(template) }, + ) + } + item { + AutopilotTemplateChip( + title = if (zh) "空白" else "Start blank", + summary = if (zh) "手动填写 Agent、模板与触发器" else "Choose agent, prompt, and schedule yourself", + icon = Icons.Outlined.Add, + contentDescription = blankContentDescription, + trailingContentDescription = blankArrowContentDescription, + trailingIcon = blankTrailingIcon, + onClick = onBlankClick, + ) + } + } + } +} + +@Composable +private fun AutopilotTemplateChip( + title: String, + summary: String, + icon: ImageVector, + contentDescription: String, + trailingContentDescription: String? = null, + trailingIcon: ImageVector = Icons.Outlined.KeyboardArrowRight, + onClick: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = Modifier + .width(224.dp) + .clip(shape) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.68f), shape) + .padding(horizontal = 11.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Accent.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(15.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = summary, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = trailingIcon, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier + .size(16.dp) + .then( + if (trailingContentDescription == null) { + Modifier + } else { + Modifier.semantics { this.contentDescription = trailingContentDescription } + } + ), + ) + } + } +} + +private fun autopilotTemplateContentDescription(id: String): String { + return when (id) { + "daily_news" -> "Autopilot Template daily_news" + "pr_review" -> "Autopilot Template pr_review" + "bug_triage" -> "Autopilot Template bug_triage" + else -> "Autopilot Template $id" + } +} + +private fun autopilotTemplateOptions(zh: Boolean): List { + return listOf( + AutopilotTemplateOption( + id = "daily_news", + title = if (zh) "每日资讯" else "Daily news", + summary = if (zh) "每天整理团队相关资讯" else "Daily digest for team-relevant news", + prompt = "Search today's relevant news, summarize the key takeaways, and post a concise digest.", + icon = Icons.Outlined.Search, + ), + AutopilotTemplateOption( + id = "pr_review", + title = if (zh) "PR 复查" else "PR review", + summary = if (zh) "提醒长时间未 review 的 PR" else "Surface stale pull requests for review", + prompt = "Find pull requests waiting for review, summarize their age and scope, and remind the team.", + icon = Icons.Outlined.Flag, + ), + AutopilotTemplateOption( + id = "bug_triage", + title = if (zh) "Bug 分诊" else "Bug triage", + summary = if (zh) "检查 backlog 并建议优先级" else "Review backlog issues and suggest priority", + prompt = "Review untriaged issues, assess severity, assign priority, and explain the recommendation.", + icon = Icons.Outlined.Bolt, + ), + ) +} + +@Composable +private fun AutopilotFormWebHero( + title: String, + subtitle: String, + editing: Boolean, + zh: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Autopilot Form Web Hero" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.74f), RoundedCornerShape(12.dp)) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Accent.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (editing) if (zh) "编辑" else "Edit" else if (zh) "新建" else "Create", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + ) + } + Text( + text = subtitle, + modifier = Modifier.semantics { contentDescription = "Autopilot Form Web Subtitle" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AutopilotFormTriggerConfigWebSection( + title: String, + onTitleChange: (String) -> Unit, + prompt: String, + onPromptChange: (String) -> Unit, + template: String, + onTemplateChange: (String) -> Unit, + cron: String, + onCronChange: (String) -> Unit, + timezone: String, + onTimezoneChange: (String) -> Unit, + label: String, + onLabelChange: (String) -> Unit, + zh: Boolean, +) { + val WEB_SSOT_AUTOPILOT_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilot-dialog.tsx" + val WEB_SSOT_TRIGGER_CONFIG = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/trigger-config.tsx" + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Autopilot Form Trigger Config Web Section $WEB_SSOT_AUTOPILOT_DIALOG $WEB_SSOT_TRIGGER_CONFIG" + }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.70f), shape) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AutopilotFormWebStepHeader( + title = if (zh) "Template & prompt" else "Template & prompt", + subtitle = if (zh) "对应 Web AutopilotDialog 的左侧 runbook" else "Matches the Web AutopilotDialog runbook", + contentDescription = "Autopilot Form Template Web Step", + ) + MulticaTextField( + value = title, + onValueChange = onTitleChange, + label = if (zh) "标题" else "Title", + singleLine = true, + contentDescription = "Autopilot Form Title", + ) + MulticaTextField( + value = template, + onValueChange = onTemplateChange, + label = if (zh) "Issue 标题模板" else "Issue title template", + minLines = 2, + maxLines = 4, + contentDescription = "Autopilot Form Template", + ) + AutopilotFormWebStepHeader( + title = if (zh) "Prompt" else "Prompt", + subtitle = if (zh) "Agent 每次运行时执行的说明" else "The runbook instructions for each agent run", + contentDescription = "Autopilot Form Prompt Web Step", + ) + MulticaTextField( + value = prompt, + onValueChange = onPromptChange, + label = if (zh) "描述" else "Description", + minLines = 3, + maxLines = 7, + contentDescription = "Autopilot Form Description", + ) + AutopilotFormWebStepHeader( + title = if (zh) "Schedule / event" else "Schedule / event", + subtitle = if (zh) "创建时同步生成 schedule trigger" else "Creates the schedule trigger together with the autopilot", + contentDescription = "Autopilot Form Schedule Web Step", + ) + MulticaTextField( + value = cron, + onValueChange = onCronChange, + label = if (zh) "Cron 表达式" else "Cron expression", + singleLine = true, + contentDescription = "Autopilot Form Trigger Cron", + ) + MulticaTextField( + value = label, + onValueChange = onLabelChange, + label = if (zh) "触发器标签" else "Trigger label", + singleLine = true, + contentDescription = "Autopilot Form Trigger Label", + ) + AutopilotFormWebStepHeader( + title = if (zh) "Timezone" else "Timezone", + subtitle = if (zh) "与 Web TriggerConfigSection 的 timezone picker 对齐" else "Aligned with the Web TriggerConfigSection timezone picker", + contentDescription = "Autopilot Form Timezone Web Step", + ) + MulticaTextField( + value = timezone, + onValueChange = onTimezoneChange, + label = if (zh) "时区" else "Timezone", + singleLine = true, + contentDescription = "Autopilot Form Trigger Timezone", + ) + } + } +} + +@Composable +private fun AutopilotFormWebStepHeader( + title: String, + subtitle: String, + contentDescription: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + lineHeight = 15.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun AutopilotTriggerComposerWebPanel( + cron: String, + onCronChange: (String) -> Unit, + timezone: String, + onTimezoneChange: (String) -> Unit, + label: String, + onLabelChange: (String) -> Unit, + zh: Boolean, + onAdd: () -> Unit, +) { + val shape = RoundedCornerShape(8.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Autopilot Trigger Composer Web Panel" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.66f), shape) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.CalendarMonth, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(17.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = if (zh) "添加定时触发器" else "Add scheduled trigger", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + ) + Text( + text = if (zh) "Cron、时区和可选标签" else "Cron, timezone, and optional label", + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.74f), + ) + } + MulticaPillButton( + text = if (zh) "添加" else "Add", + onClick = onAdd, + tone = MulticaButtonTone.Secondary, + contentDescription = "Autopilot Trigger Composer Add", + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Autopilot Trigger Composer Fields" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = cron, + onValueChange = onCronChange, + label = if (zh) "Cron 表达式" else "Cron expression", + singleLine = true, + contentDescription = "Autopilot Form Trigger Cron", + modifier = Modifier.weight(1f), + ) + MulticaTextField( + value = timezone, + onValueChange = onTimezoneChange, + label = if (zh) "时区" else "Timezone", + singleLine = true, + contentDescription = "Autopilot Form Trigger Timezone", + modifier = Modifier.weight(1f), + ) + } + MulticaTextField( + value = label, + onValueChange = onLabelChange, + label = if (zh) "触发器标签" else "Trigger label", + singleLine = true, + contentDescription = "Autopilot Form Trigger Label", + ) + } + } + } +} + +@Composable +private fun AutopilotTriggerWebRow( + trigger: Models.AutopilotTrigger, + zh: Boolean, + onUpdate: () -> Unit, + onDelete: () -> Unit, +) { + val shape = RoundedCornerShape(8.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = "Autopilot Trigger Web Row ${trigger.id}" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.66f), shape) + .padding(horizontal = 10.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.CalendarMonth, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(17.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = trigger.label.ifBlank { trigger.kind.ifBlank { if (zh) "定时触发器" else "Scheduled trigger" } }, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!trigger.enabled) { + Text( + text = if (zh) "停用" else "Disabled", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + ) + } + } + Text( + text = listOf( + if (trigger.enabled) if (zh) "已启用" else "enabled" else if (zh) "已停用" else "disabled", + clean(trigger.cronExpression), + clean(trigger.timezone), + clean(trigger.nextRunAt).takeIf { it.isNotBlank() }?.let { if (zh) "下次 $it" else "next $it" } ?: "", + ).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.Edit, + contentDescription = "Autopilot Trigger Update ${trigger.id}", + onClick = onUpdate, + tone = MulticaButtonTone.Ghost, + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "Autopilot Trigger Update ${trigger.id}" }, + ) + MulticaIconPillButton( + icon = Icons.Outlined.Delete, + contentDescription = "Autopilot Trigger Delete ${trigger.id}", + onClick = onDelete, + tone = MulticaButtonTone.Destructive, + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "Autopilot Trigger Delete ${trigger.id}" }, + ) + } + } +} + +@Composable +private fun AutopilotRunWebRow(run: Models.AutopilotRun, zh: Boolean) { + val statusColor = when (run.status.lowercase(Locale.US)) { + "completed", "issue_created" -> MulticaColors.Success + "running", "queued" -> MulticaColors.Accent + "failed" -> MulticaColors.Danger + else -> MulticaColors.Muted + } + val shape = RoundedCornerShape(8.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = "Autopilot Run Web Row ${run.id}" }, + shape = shape, + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.54f), shape) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = statusColor, modifier = Modifier.size(16.dp)) + Text( + text = run.status.ifBlank { if (zh) "未知" else "unknown" }, + modifier = Modifier.width(84.dp), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = statusColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = run.source.ifBlank { if (zh) "manual" else "manual" }, + modifier = Modifier.width(70.dp), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = listOf(clean(run.issueId), clean(run.taskId), clean(run.failureReason)).filter { it.isNotBlank() }.joinToString(" · ").ifBlank { + if (zh) "无关联 Issue" else "No linked issue" + }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = if (run.failureReason.isNotBlank()) MulticaColors.Danger else MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = clean(run.triggeredAt).ifBlank { Models.shortId(run.id) }, + modifier = Modifier.width(90.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun PilotAutopilotsSettings(api: ApiClient, workspaceId: String, zh: Boolean, onBack: () -> Unit) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var autopilotMode by remember(workspaceId) { mutableStateOf(AutopilotSettingsMode.List) } + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var template by remember { mutableStateOf("") } + var agentIndex by remember { mutableIntStateOf(0) } + var modeIndex by remember { mutableIntStateOf(0) } + var statusIndex by remember { mutableIntStateOf(0) } + var editing by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } + var deletingAutopilotId by remember { mutableStateOf(null) } + var updatingAutopilotStatusId by remember { mutableStateOf(null) } + var autopilotActionMenuId by remember { mutableStateOf(null) } + var resultText by remember { mutableStateOf(null) } + var saving by remember { mutableStateOf(false) } + var triggerCron by remember { mutableStateOf("0 9 * * *") } + var triggerTimezone by remember { mutableStateOf("Asia/Shanghai") } + var triggerLabel by remember { mutableStateOf("") } + var detailState by remember { mutableStateOf?>(null) } + val scope = rememberCoroutineScope() + val modes = listOf("create_issue") + val statuses = listOf("active", "paused", "archived") + val editingId = editing?.id + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + val autopilots = api.autopilots(workspaceId) + val agents = api.agents(workspaceId) + val triggers = autopilots.flatMap { autopilot -> + runCatching { api.autopilotTriggers(workspaceId, autopilot.id) }.getOrDefault(emptyList()) + } + PilotAutopilotSettingsData(autopilots, agents, triggers) + } + } + } + + LaunchedEffect(workspaceId, editingId, refresh) { + val id = editingId + if (id == null) { + detailState = null + } else { + detailState = null + detailState = withContext(Dispatchers.IO) { + runCatching { PilotAutopilotDetailData(api.autopilotTriggers(workspaceId, id), api.autopilotRuns(workspaceId, id)) } + } + } + } + + fun resetForm() { + autopilotMode = AutopilotSettingsMode.List + editing = null + title = "" + description = "" + template = "" + agentIndex = 0 + modeIndex = 0 + statusIndex = 0 + } + + fun startCreate() { + resetForm() + autopilotMode = AutopilotSettingsMode.Create + resultText = null + } + + fun startCreateFromTemplate(templateOption: AutopilotTemplateOption) { + startCreate() + title = templateOption.title + description = templateOption.prompt + template = templateOption.title + } + + fun startEdit(autopilot: Models.Autopilot, data: PilotAutopilotSettingsData) { + autopilotMode = AutopilotSettingsMode.Edit + editing = autopilot + title = autopilot.title + description = autopilot.description + template = autopilot.issueTitleTemplate + agentIndex = data.agents.indexOfFirst { it.id == autopilot.assigneeId }.takeIf { it >= 0 } ?: 0 + statusIndex = statuses.indexOf(autopilot.status).takeIf { it >= 0 } ?: 0 + modeIndex = modes.indexOf(autopilot.executionMode).takeIf { it >= 0 } ?: 0 + resultText = null + } + + fun saveAutopilot(data: PilotAutopilotSettingsData) { + val autopilotTitle = title.trim() + if (autopilotTitle.isEmpty() || data.agents.isEmpty() || saving) return + saving = true + resultText = null + val agent = data.agents[agentIndex.coerceAtMost(data.agents.lastIndex)] + val mode = modes[modeIndex.coerceAtMost(modes.lastIndex)] + val status = statuses[statusIndex.coerceAtMost(statuses.lastIndex)] + val current = editing + scope.launch { + val result = withContext(Dispatchers.IO) { + if (current == null) { + runCatching { + val created = api.createAutopilot(workspaceId, autopilotTitle, description, agent.id, mode, template) + api.createAutopilotTrigger(workspaceId, created.id, "schedule", triggerCron, triggerTimezone, triggerLabel) + created + } + } else { + runCatching { api.updateAutopilot(workspaceId, current, autopilotTitle, description, agent.id, status, mode, template) } + } + } + saving = false + result.onSuccess { + resultText = if (zh) "已保存" else "Saved" + resetForm() + refresh++ + }.onFailure { + resultText = "${if (zh) "保存失败" else "Save failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun deleteAutopilot(autopilot: Models.Autopilot) { + if (deletingAutopilotId != null) return + deletingAutopilotId = autopilot.id + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteAutopilot(workspaceId, autopilot.id) } + } + deletingAutopilotId = null + result.onSuccess { + if (editing?.id == autopilot.id) resetForm() + pendingDelete = null + resultText = if (zh) "Autopilot 已删除" else "Autopilot deleted" + refresh++ + }.onFailure { + resultText = "${if (zh) "删除失败" else "Delete failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun updateAutopilotStatus(autopilot: Models.Autopilot, nextStatus: String) { + if (updatingAutopilotStatusId != null || autopilot.status == nextStatus) return + updatingAutopilotStatusId = autopilot.id + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + api.updateAutopilot( + workspaceId, + autopilot, + autopilot.title, + autopilot.description, + autopilot.assigneeId, + nextStatus, + autopilot.executionMode, + autopilot.issueTitleTemplate, + ) + } + } + updatingAutopilotStatusId = null + result.onSuccess { + resultText = if (nextStatus == "active") { + if (zh) "Autopilot 已恢复" else "Autopilot resumed" + } else { + if (zh) "Autopilot 已暂停" else "Autopilot paused" + } + refresh++ + }.onFailure { + resultText = "${if (zh) "状态更新失败" else "Status update failed"}: ${it.message ?: it.toString()}" + } + } + } + + if (autopilotMode == AutopilotSettingsMode.Create || autopilotMode == AutopilotSettingsMode.Edit) { + val editingAutopilot = editing + PilotListPage( + title = if (editingAutopilot == null) if (zh) "新建 Autopilot" else "New Autopilot" else if (zh) "编辑 Autopilot" else "Edit Autopilot", + leading = { + PilotSecondaryBackButton( + zh = zh, + onBack = { resetForm() }, + contentDescription = if (zh) "返回 Autopilots" else "Back to Autopilots", + ) + }, + bottomContent = { + val data = state?.getOrNull() + if (data != null && data.agents.isNotEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .imePadding(), + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { resetForm() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + contentDescription = "Autopilot Form Cancel", + ) + MulticaPillButton( + text = if (saving) "..." else if (editingAutopilot == null) if (zh) "创建 Autopilot" else "Create Autopilot" else if (zh) "保存 Autopilot" else "Save Autopilot", + onClick = { saveAutopilot(data) }, + modifier = Modifier.weight(1.2f), + tone = MulticaButtonTone.Primary, + contentDescription = if (editingAutopilot == null) "Autopilot Form Bottom Create" else "Autopilot Form Bottom Save", + ) + } + } + } + }, + ) { + val loaded = state + when { + loaded == null -> item { MulticaLoadingState(if (zh) "正在加载 Autopilots..." else "Loading autopilots...") } + loaded.isFailure -> item { + MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${loaded.exceptionOrNull()?.message}", onRetry = { refresh++ }) + } + else -> { + val data = loaded.getOrThrow() + item { + AutopilotFormWebHero( + title = if (editingAutopilot == null) { + if (zh) "新建 Autopilot" else "New Autopilot" + } else { + editingAutopilot.title + }, + subtitle = if (editingAutopilot == null) { + if (zh) "创建一个定时或手动触发的 Agent 工作流" else "Create an agent workflow for scheduled or manual runs." + } else { + listOf(editingAutopilot.status, editingAutopilot.executionMode, editingAutopilot.lastRunAt) + .filter { it.isNotBlank() } + .joinToString(" · ") + }, + editing = editingAutopilot != null, + zh = zh, + ) + } + if (data.agents.isEmpty()) { + item { + MulticaErrorState( + if (zh) "没有可用 Agent,先创建 Agent 后再创建 Autopilot。" else "No agent is available. Create an agent before saving an autopilot.", + onRetry = { refresh++ }, + ) + } + } + item { + AutopilotFormTriggerConfigWebSection( + title = title, + onTitleChange = { title = it }, + prompt = description, + onPromptChange = { description = it }, + template = template, + onTemplateChange = { template = it }, + cron = triggerCron, + onCronChange = { triggerCron = it }, + timezone = triggerTimezone, + onTimezoneChange = { triggerTimezone = it }, + label = triggerLabel, + onLabelChange = { triggerLabel = it }, + zh = zh, + ) + } + if (data.agents.isNotEmpty()) { + item { + val selectedAgentIndex = agentIndex.coerceAtMost(data.agents.lastIndex) + val selectedAgent = data.agents[selectedAgentIndex] + AutopilotFormWebMenuRow( + eyebrow = if (zh) "Assignee" else "Assignee", + title = selectedAgent.name, + subtitle = listOf( + clean(selectedAgent.visibility), + clean(selectedAgent.status), + clean(selectedAgent.description), + ).filter { it.isNotBlank() }.joinToString(" · "), + contentDescription = "Autopilot Form Assignee Web Picker", + options = data.agents.mapIndexed { index, agent -> + AutopilotFormMenuOption( + label = agent.name, + detail = listOf( + clean(agent.visibility), + clean(agent.status), + clean(agent.description), + ).filter { it.isNotBlank() }.joinToString(" · "), + selected = index == selectedAgentIndex, + onClick = { agentIndex = index }, + ) + }, + ) + } + } + item { + val selectedModeIndex = modeIndex.coerceAtMost(modes.lastIndex) + val selectedMode = modes[selectedModeIndex] + AutopilotFormWebMenuRow( + eyebrow = if (zh) "模式" else "Mode", + title = selectedMode, + subtitle = if (selectedMode == "create_issue") { + if (zh) "每次运行创建 Issue" else "Create an issue from each run" + } else { + if (zh) "仅运行 Agent" else "Run agent without creating an issue" + }, + contentDescription = "Autopilot Form Mode Web Picker", + options = modes.mapIndexed { index, mode -> + AutopilotFormMenuOption( + label = mode, + detail = if (mode == "create_issue") { + if (zh) "每次运行创建 Issue" else "Create an issue from each run" + } else { + if (zh) "仅运行 Agent" else "Run agent without creating an issue" + }, + selected = index == selectedModeIndex, + onClick = { modeIndex = index }, + ) + }, + ) + } + item { + val selectedStatusIndex = statusIndex.coerceAtMost(statuses.lastIndex) + val selectedStatus = statuses[selectedStatusIndex] + AutopilotFormWebMenuRow( + eyebrow = if (zh) "状态" else "Status", + title = selectedStatus, + subtitle = if (editingAutopilot == null) { + if (zh) "新建后默认 active" else "New autopilots start active" + } else { + if (zh) "控制是否自动运行" else "Controls whether this workflow runs automatically" + }, + contentDescription = "Autopilot Form Status Web Picker", + options = (if (editingAutopilot == null) listOf("active") else statuses).mapIndexed { index, status -> + val actualIndex = statuses.indexOf(status).takeIf { it >= 0 } ?: index + AutopilotFormMenuOption( + label = status, + detail = when (status) { + "active" -> if (zh) "可按计划运行" else "Runs on schedule" + "paused" -> if (zh) "暂停自动运行" else "Pauses scheduled runs" + "archived" -> if (zh) "从活跃列表归档" else "Moves out of the active list" + else -> null + }, + selected = actualIndex == selectedStatusIndex, + onClick = { + if (editingAutopilot != null) statusIndex = actualIndex + }, + ) + }, + ) + } + if (!resultText.isNullOrBlank()) { + item { + if (resultText!!.contains("failed", true) || resultText!!.contains("失败")) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "Autopilot Form Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MulticaPillButton(if (zh) "取消" else "Cancel", onClick = { resetForm() }, modifier = Modifier.weight(1f), tone = MulticaButtonTone.Ghost) + MulticaPillButton( + text = if (saving) "..." else if (editingAutopilot == null) if (zh) "创建 Autopilot" else "Create Autopilot" else if (zh) "保存 Autopilot" else "Save Autopilot", + onClick = { saveAutopilot(data) }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + contentDescription = if (editingAutopilot == null) "Autopilot Form Create" else "Autopilot Form Save", + ) + } + } + editingAutopilot?.let { current -> + item { SettingsSectionLabel(if (zh) "触发器和运行历史" else "Triggers & Runs") } + item { + AutopilotTriggerComposerWebPanel( + cron = triggerCron, + onCronChange = { triggerCron = it }, + timezone = triggerTimezone, + onTimezoneChange = { triggerTimezone = it }, + label = triggerLabel, + onLabelChange = { triggerLabel = it }, + zh = zh, + onAdd = { + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.createAutopilotTrigger(workspaceId, current.id, "cron", triggerCron, triggerTimezone, triggerLabel) } + } + result.onSuccess { + triggerLabel = "" + resultText = if (zh) "触发器已创建" else "Trigger created" + refresh++ + }.onFailure { + resultText = "${if (zh) "触发器创建失败" else "Trigger create failed"}: ${it.message ?: it.toString()}" + } + } + }, + ) + } + when (val detail = detailState) { + null -> item { MulticaLoadingState(if (zh) "正在加载详情..." else "Loading details...") } + else -> { + detail.onFailure { + item { MulticaErrorState("${if (zh) "详情加载失败" else "Details failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { detail -> + if (detail.triggers.isEmpty()) { + item { AutopilotDetailTriggersWebEmptyState(zh = zh) } + } + items(detail.triggers) { trigger -> + AutopilotTriggerWebRow( + trigger = trigger, + zh = zh, + onUpdate = { + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + api.updateAutopilotTrigger( + workspaceId, + current.id, + trigger.id, + trigger.enabled, + triggerCron, + triggerTimezone, + triggerLabel, + ) + } + } + result.onSuccess { + resultText = if (zh) "触发器已更新" else "Trigger updated" + refresh++ + }.onFailure { + resultText = "${if (zh) "更新触发器失败" else "Update trigger failed"}: ${it.message ?: it.toString()}" + } + } + }, + onDelete = { + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteAutopilotTrigger(workspaceId, current.id, trigger.id) } + } + result.onSuccess { + resultText = if (zh) "触发器已删除" else "Trigger deleted" + refresh++ + }.onFailure { + resultText = "${if (zh) "删除触发器失败" else "Delete trigger failed"}: ${it.message ?: it.toString()}" + } + } + }, + ) + } + if (detail.runs.isEmpty()) { + item { AutopilotDetailRunsWebEmptyState(zh = zh) } + } + items(detail.runs.take(8)) { run -> + AutopilotRunWebRow(run = run, zh = zh) + } + } + } + } + } + } + } + } + return + } + + PilotListPage( + title = if (zh) "Autopilots" else "Autopilots", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = if (zh) "新建 Autopilot" else "New Autopilot", + onClick = { startCreate() }, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + val loaded = state + if (loaded == null) { + item { MulticaLoadingState(if (zh) "正在加载 Autopilots..." else "Loading autopilots...") } + } else { + loaded.onFailure { item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } } + loaded.onSuccess { data -> + item { + AutopilotTemplateCompactStrip( + zh = zh, + onTemplateClick = { startCreateFromTemplate(it) }, + onBlankClick = { startCreate() }, + blankContentDescription = "Autopilot Create Entry", + blankArrowContentDescription = "Autopilot Create Entry Arrow", + blankTrailingIcon = Icons.Outlined.KeyboardArrowRight, + ) + } + if (!resultText.isNullOrBlank()) { + item { + if (resultText!!.contains("failed", true) || resultText!!.contains("失败")) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "Autopilot List Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + if (data.autopilots.isEmpty()) item { AutopilotsWebEmptyState(zh = zh) } + if (data.autopilots.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Autopilot Web Table Header" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (zh) "工作流" else "Workflow", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + if (zh) "状态" else "State", + modifier = Modifier.width(72.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.width(42.dp)) + } + } + } + items(data.autopilots) { autopilot -> + Column(modifier = Modifier.fillMaxWidth()) { + AutopilotWebDenseRow( + autopilot = autopilot, + agentName = data.agents.firstOrNull { it.id == autopilot.assigneeId }?.name ?: Models.shortId(autopilot.assigneeId), + triggerSummary = autopilotListTriggerSummary( + autopilot = autopilot, + triggers = data.triggers, + zh = zh, + ), + zh = zh, + actionsExpanded = autopilotActionMenuId == autopilot.id, + onActionsClick = { + autopilotActionMenuId = if (autopilotActionMenuId == autopilot.id) null else autopilot.id + pendingDelete = null + resultText = null + }, + onEdit = { + autopilotActionMenuId = null + startEdit(autopilot, data) + }, + onTrigger = { + autopilotActionMenuId = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.triggerAutopilot(workspaceId, autopilot.id) } } + result.onSuccess { resultText = "${if (zh) "已触发" else "Triggered"}: ${it.status.ifBlank { Models.shortId(it.id) }}" } + .onFailure { resultText = "${if (zh) "触发失败" else "Trigger failed"}: ${it.message ?: it.toString()}" } + } + }, + onPauseResume = { + autopilotActionMenuId = null + val nextStatus = if (autopilot.status.equals("active", ignoreCase = true)) "paused" else "active" + updateAutopilotStatus(autopilot, nextStatus = nextStatus) + }, + onDelete = { + autopilotActionMenuId = null + pendingDelete = autopilot + }, + ) + AutopilotRowCupertinoMoreMenu( + visible = autopilotActionMenuId == autopilot.id, + autopilot = autopilot, + isUpdatingStatus = updatingAutopilotStatusId == autopilot.id, + zh = zh, + onDismissRequest = { autopilotActionMenuId = null }, + onEdit = { + autopilotActionMenuId = null + startEdit(autopilot, data) + }, + onTrigger = { + autopilotActionMenuId = null + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.triggerAutopilot(workspaceId, autopilot.id) } } + result.onSuccess { resultText = "${if (zh) "已触发" else "Triggered"}: ${it.status.ifBlank { Models.shortId(it.id) }}" } + .onFailure { resultText = "${if (zh) "触发失败" else "Trigger failed"}: ${it.message ?: it.toString()}" } + } + }, + onPauseResume = { + autopilotActionMenuId = null + val nextStatus = if (autopilot.status.equals("active", ignoreCase = true)) "paused" else "active" + updateAutopilotStatus(autopilot, nextStatus = nextStatus) + }, + onDelete = { + autopilotActionMenuId = null + pendingDelete = autopilot + }, + ) + } + } + } + } + } + pendingDelete?.let { autopilot -> + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "删除 Autopilot?" else "Delete autopilot?", + message = if (zh) { + "这会删除「${autopilot.title}」,并停止它后续的自动触发。" + } else { + "This deletes \"${autopilot.title}\" and stops its future automated triggers." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (deletingAutopilotId == autopilot.id) "..." else if (zh) "删除" else "Delete", + destructive = true, + contentDescription = "Autopilot Delete Cupertino Alert ${autopilot.title}", + onDismissRequest = { + if (deletingAutopilotId == null) pendingDelete = null + }, + onConfirm = { deleteAutopilot(autopilot) }, + ) + } +} + +@Composable +private fun AutopilotsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_AUTOPILOTS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilots-page.tsx" + val WEB_SSOT_AUTOPILOTS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/autopilots.json" + Box( + modifier = modifier.semantics { + contentDescription = "Autopilots Web Empty State $WEB_SSOT_AUTOPILOTS_PAGE $WEB_SSOT_AUTOPILOTS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有自动化" else "No autopilots yet", + description = if (zh) { + "为你的智能体安排周期性 task。从模板选一个,或从空白开始。" + } else { + "Schedule recurring tasks for your AI agents. Pick a template or start from scratch." + }, + ) + } +} + +@Composable +private fun AutopilotDetailTriggersWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_AUTOPILOTS_DETAIL = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilot-detail-page.tsx" + val WEB_SSOT_AUTOPILOTS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/autopilots.json" + Box( + modifier = modifier.semantics { + contentDescription = "Autopilot Detail Triggers Web Empty State $WEB_SSOT_AUTOPILOTS_DETAIL $WEB_SSOT_AUTOPILOTS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "未配置触发器。添加一个时间表让它自动运行。" else "No triggers configured. Add a schedule to run automatically.", + ) + } +} + +@Composable +private fun AutopilotDetailRunsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_AUTOPILOTS_DETAIL = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilot-detail-page.tsx" + val WEB_SSOT_AUTOPILOTS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/autopilots.json" + Box( + modifier = modifier.semantics { + contentDescription = "Autopilot Detail Runs Web Empty State $WEB_SSOT_AUTOPILOTS_DETAIL $WEB_SSOT_AUTOPILOTS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "暂无运行记录。点击\"立即运行\"手动触发。" else "No runs yet. Click \"Run now\" to trigger manually.", + ) + } +} + +private fun autopilotListTriggerSummary( + autopilot: Models.Autopilot, + triggers: List, + zh: Boolean, +): String { + val ownTriggers = triggers.filter { it.autopilotId == autopilot.id } + if (ownTriggers.isEmpty()) return if (zh) "未配置触发器" else "No triggers configured" + val enabledCount = ownTriggers.count { it.enabled } + val primary = ownTriggers.firstOrNull { it.enabled } ?: ownTriggers.first() + val label = clean(primary.label).ifBlank { clean(primary.kind).ifBlank { if (zh) "定时触发器" else "Scheduled trigger" } } + val schedule = listOf( + clean(primary.cronExpression), + clean(primary.timezone), + clean(primary.nextRunAt).takeIf { it.isNotBlank() }?.let { if (zh) "下次 $it" else "next $it" } ?: "", + ).filter { it.isNotBlank() }.joinToString(" · ") + val count = if (ownTriggers.size > 1) { + if (zh) "$enabledCount/${ownTriggers.size} 已启用" else "$enabledCount/${ownTriggers.size} enabled" + } else { + if (primary.enabled) if (zh) "已启用" else "enabled" else if (zh) "已停用" else "disabled" + } + return listOf(label, count, schedule).filter { it.isNotBlank() }.joinToString(" · ") +} + +@Composable +private fun AutopilotWebDenseRow( + autopilot: Models.Autopilot, + agentName: String, + triggerSummary: String, + zh: Boolean, + actionsExpanded: Boolean, + onActionsClick: () -> Unit, + onEdit: () -> Unit, + onTrigger: () -> Unit, + onPauseResume: () -> Unit, + onDelete: () -> Unit, +) { + val statusColor = when (autopilot.status.lowercase(Locale.US)) { + "active" -> MulticaColors.Success + "paused" -> MulticaColors.Warning + "archived" -> MulticaColors.Muted + else -> MulticaColors.Muted + } + val lastRun = clean(autopilot.lastRunAt).ifBlank { if (zh) "未运行" else "never" } + val WEB_SSOT_AUTOPILOT_DETAIL_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilot-detail-page.tsx" + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Autopilot Web Dense Row ${autopilot.title}" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .clickable(onClick = onEdit) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + autopilot.title, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = triggerSummary, + modifier = Modifier.semantics { + contentDescription = "Autopilot Trigger Summary $WEB_SSOT_AUTOPILOT_DETAIL_PAGE" + }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted.copy(alpha = 0.78f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + agentName.ifBlank { if (zh) "未分配 Agent" else "Unassigned agent" }, + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + autopilot.executionMode.ifBlank { "create_issue" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + relativeDate(lastRun, zh), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row( + modifier = Modifier + .width(72.dp) + .semantics { contentDescription = "Autopilot Status ${autopilot.status}" }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(RoundedCornerShape(99.dp)) + .background(statusColor), + ) + Spacer(Modifier.width(5.dp)) + Text( + autopilot.status.ifBlank { "unknown" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = statusColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Autopilot Actions ${autopilot.title}", + onClick = onActionsClick, + tone = if (actionsExpanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "Autopilot Actions ${autopilot.title}" }, + ) + } + } +} + +@Composable +private fun AutopilotRowCupertinoMoreMenu( + visible: Boolean, + autopilot: Models.Autopilot, + isUpdatingStatus: Boolean, + zh: Boolean, + onDismissRequest: () -> Unit, + onEdit: () -> Unit, + onTrigger: () -> Unit, + onPauseResume: () -> Unit, + onDelete: () -> Unit, +) { + val WEB_SSOT_AUTOPILOT_DETAIL_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/autopilots/components/autopilot-detail-page.tsx" + val isActive = autopilot.status.equals("active", ignoreCase = true) + val isArchived = autopilot.status.equals("archived", ignoreCase = true) + Box(modifier = Modifier.semantics { contentDescription = "Autopilot Row Cupertino More Menu ${autopilot.title}" }) { + MulticaCupertinoActionSheet( + visible = visible, + title = autopilot.title, + message = if (zh) "对齐 Web 详情页操作 $WEB_SSOT_AUTOPILOT_DETAIL_PAGE" else "Actions aligned with Web detail $WEB_SSOT_AUTOPILOT_DETAIL_PAGE", + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = onDismissRequest, + items = buildList { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "编辑 Autopilot" else "Edit Autopilot", + subtitle = if (zh) "打开完整配置" else "Open full configuration", + contentDescription = "Autopilot Menu Edit ${autopilot.title}", + onClick = onEdit, + ) + ) + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "立即触发" else "Trigger now", + subtitle = if (zh) "按 Web 详情页 Run now 执行" else "Matches Web detail Run now", + contentDescription = "Autopilot Menu Trigger ${autopilot.title}", + enabled = isActive, + onClick = onTrigger, + ) + ) + add( + MulticaCupertinoActionSheetItem( + title = if (isActive) { + if (isUpdatingStatus) if (zh) "暂停中..." else "Pausing..." else if (zh) "暂停 Autopilot" else "Pause Autopilot" + } else { + if (isUpdatingStatus) if (zh) "恢复中..." else "Resuming..." else if (zh) "恢复 Autopilot" else "Resume Autopilot" + }, + subtitle = if (isArchived) { + if (zh) "已归档,需先编辑状态" else "Archived; edit status first" + } else { + autopilot.status.ifBlank { if (zh) "自动运行状态" else "Automation status" } + }, + contentDescription = if (isActive) "Autopilot Menu Pause ${autopilot.title}" else "Autopilot Menu Resume ${autopilot.title}", + enabled = !isArchived && !isUpdatingStatus, + onClick = onPauseResume, + ) + ) + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "删除 Autopilot" else "Delete Autopilot", + subtitle = if (zh) "停止后续自动触发" else "Stops future automated triggers", + contentDescription = "Autopilot Menu Delete ${autopilot.title}", + destructive = true, + onClick = onDelete, + ) + ) + }, + ) + } +} + +@Composable +private fun PilotRuntimesSettings(api: ApiClient, workspaceId: String, zh: Boolean, onBack: () -> Unit) { + var state by remember(workspaceId) { mutableStateOf>?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var pendingDelete by remember { mutableStateOf(null) } + var resultText by remember { mutableStateOf(null) } + var selectedRuntime by remember(workspaceId) { mutableStateOf(null) } + var runtimeSearch by remember(workspaceId) { mutableStateOf("") } + var runtimeHealthFilter by remember(workspaceId) { mutableStateOf("all") } + var expandedRuntimeActionsId by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + selectedRuntime?.let { runtime -> + PilotRuntimeDetailSettings( + api = api, + workspaceId = workspaceId, + runtime = runtime, + zh = zh, + onBack = { selectedRuntime = null }, + ) + return + } + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { runCatching { api.runtimes(workspaceId) } } + } + + PilotListPage( + title = if (zh) "Runtimes" else "Runtimes", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + if (!resultText.isNullOrBlank()) { + item { + if (resultText!!.contains("failed", true) || resultText!!.contains("失败")) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "Runtime List Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + pendingDelete?.let { runtime -> + item { + RuntimeDeleteConfirmWebPanel( + runtimeName = runtime.name, + zh = zh, + onCancel = { pendingDelete = null }, + onConfirm = { + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.deleteRuntime(workspaceId, runtime.id) } } + result.onSuccess { + pendingDelete = null + resultText = if (zh) "Runtime 已删除" else "Runtime deleted" + refresh++ + }.onFailure { + resultText = "${if (zh) "删除失败" else "Delete failed"}: ${it.message ?: it.toString()}" + } + } + }, + ) + } + } + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载运行时..." else "Loading runtimes...") } + else -> { + loaded.onFailure { item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } } + loaded.onSuccess { runtimes -> + val healthOptions = listOf("all", "online", "recently_lost", "offline", "about_to_gc") + val filtered = runtimes.filter { runtime -> + val q = runtimeSearch.trim().lowercase(Locale.US) + val health = runtimeHealth(runtime) + val matchesHealth = runtimeHealthFilter == "all" || health == runtimeHealthFilter + val matchesQuery = q.isBlank() || listOf(runtime.name, runtime.provider, runtime.deviceInfo, runtime.version, runtime.runtimeMode) + .joinToString(" ") + .lowercase(Locale.US) + .contains(q) + matchesHealth && matchesQuery + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Runtime Web Filter Toolbar" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = runtimeSearch, + onValueChange = { runtimeSearch = it }, + label = if (zh) "搜索 Runtimes" else "Search runtimes", + singleLine = true, + contentDescription = "Runtime List Search", + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(healthOptions) { option -> + val count = if (option == "all") runtimes.size else runtimes.count { runtimeHealth(it) == option } + SettingsWebFilterPill( + text = "${runtimeHealthLabel(option, zh)} $count", + selected = runtimeHealthFilter == option, + onClick = { runtimeHealthFilter = option }, + contentDescription = "Runtime Health Filter ${runtimeHealthLabel(option, zh)}", + ) + } + } + } + } + if (runtimes.isEmpty()) item { RuntimesWebEmptyState(zh = zh) } + else if (filtered.isEmpty()) item { + RuntimesWebNoMatchesState( + zh = zh, + query = runtimeSearch.trim(), + filtered = runtimeHealthFilter != "all", + ) + } + if (filtered.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Runtime Web Table Header" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (zh) "Runtime" else "Runtime", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + if (zh) "健康" else "Health", + modifier = Modifier.width(88.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.width(42.dp)) + } + } + } + items(filtered) { runtime -> + RuntimeWebDenseRow( + runtime = runtime, + zh = zh, + expanded = expandedRuntimeActionsId == runtime.id, + onExpandedChange = { expandedRuntimeActionsId = if (it) runtime.id else null }, + onOpen = { + expandedRuntimeActionsId = null + selectedRuntime = runtime + }, + onDelete = { + expandedRuntimeActionsId = null + pendingDelete = runtime + }, + ) + } + } + } + } + } +} + +@Composable +private fun RuntimeDeleteConfirmWebPanel( + runtimeName: String, + zh: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + val shape = RoundedCornerShape(8.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = "Runtime Delete Web Confirm" }, + shape = shape, + color = MulticaColors.Danger.copy(alpha = 0.06f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.22f), shape) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = if (zh) "删除 Runtime?" else "Delete runtime?", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Text, + ) + Text( + text = if (zh) { + "Runtime $runtimeName 将从工作区移除;依赖它的 Agent 可能需要重新绑定。" + } else { + "$runtimeName will be removed from the workspace. Agents using it may need reassignment." + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (zh) "删除" else "Delete", + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Runtime Delete Confirm Destructive" }, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun RuntimesWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_RUNTIMES_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/runtimes/components/runtimes-page.tsx" + val WEB_SSOT_RUNTIMES_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/runtimes.json" + Box( + modifier = modifier.semantics { + contentDescription = "Runtimes Web Empty State $WEB_SSOT_RUNTIMES_PAGE $WEB_SSOT_RUNTIMES_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有运行时" else "No runtimes yet", + description = if (zh) { + "桌面端会自动扫描本地机器。对于 AWS EC2 或其他远程机器,可使用连接向导。" + } else { + "Desktop auto-scans your local machine. For AWS EC2 or other remote machines, connect them using the setup wizard." + }, + ) + } +} + +@Composable +private fun RuntimesWebNoMatchesState( + zh: Boolean, + query: String = "", + filtered: Boolean = true, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_RUNTIMES_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/runtimes/components/runtimes-page.tsx" + val WEB_SSOT_RUNTIMES_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/runtimes.json" + val filterOnlyDescription = if (zh) "当前筛选下没有匹配的运行时。试试放宽筛选或清除条件。" else "No runtimes match this filter. Try widening the scope or clearing filters." + val description = when { + query.isNotBlank() && zh -> "没有运行时匹配 \"$query\"${if (filtered) "(在当前筛选下)" else ""}。试试放宽筛选或清除条件。" + query.isNotBlank() -> "No runtimes match \"$query\"${if (filtered) " in this filter" else ""}. Try widening the scope or clearing filters." + else -> filterOnlyDescription + } + Box( + modifier = modifier.semantics { + contentDescription = "Runtimes Web No Matches State $WEB_SSOT_RUNTIMES_PAGE $WEB_SSOT_RUNTIMES_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "无匹配" else "No matches", + description = description, + ) + } +} + +@Composable +private fun RuntimeWebDenseRow( + runtime: Models.Runtime, + zh: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onOpen: () -> Unit, + onDelete: () -> Unit, +) { + val health = runtimeHealth(runtime) + val nameParts = splitRuntimeName(runtime.name) + val WEB_SSOT_RUNTIME_COLUMNS = "/Users/park0er/coding/multica-web-source/packages/views/runtimes/components/runtime-columns.tsx" + val WEB_SSOT_PROVIDER_LOGO = "/Users/park0er/coding/multica-web-source/packages/views/runtimes/components/provider-logo.tsx" + val healthColor = when (health) { + "online" -> MulticaColors.Success + "recently_lost" -> MulticaColors.Warning + "about_to_gc" -> MulticaColors.Danger + else -> MulticaColors.Muted + } + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Runtime Web Dense Row ${runtime.name} WEB_SSOT_RUNTIME_COLUMNS $WEB_SSOT_RUNTIME_COLUMNS WEB_SSOT_PROVIDER_LOGO $WEB_SSOT_PROVIDER_LOGO" + }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .clickable(onClick = onOpen) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RuntimeProviderMark(provider = runtime.provider, runtimeMode = runtime.runtimeMode) + RuntimeRowNameBlock( + baseName = nameParts.first, + hostname = nameParts.second, + provider = runtime.provider, + runtimeMode = runtime.runtimeMode, + deviceInfo = runtime.deviceInfo, + lastSeenAt = runtime.lastSeenAt, + zh = zh, + modifier = Modifier.weight(1f), + ) + Row( + modifier = Modifier + .width(88.dp) + .semantics { contentDescription = "Runtime Health ${runtimeHealthLabel(health, zh)}" }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(RoundedCornerShape(99.dp)) + .background(healthColor), + ) + Spacer(Modifier.width(5.dp)) + Text( + runtimeHealthLabel(health, zh), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = healthColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Column( + modifier = Modifier.widthIn(min = 72.dp, max = 96.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.End, + ) { + RuntimeVersionBadge(runtime = runtime, zh = zh) + RuntimeUpdateStatusBadge(runtime = runtime, zh = zh) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Runtime Actions ${runtime.name}", + onClick = { onExpandedChange(true) }, + tone = if (expanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "Runtime Actions ${runtime.name}" }, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "Runtime 操作" else "Runtime actions", + message = runtime.name, + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "详情" else "Details", + icon = Icons.Outlined.KeyboardArrowRight, + contentDescription = "Runtime Detail Entry ${runtime.name}", + onClick = onOpen, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "删除" else "Delete", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Runtime Delete ${runtime.name}", + onClick = onDelete, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { onExpandedChange(false) }, + ) + } + } +} + +@Composable +private fun RuntimeProviderMark( + provider: String, + runtimeMode: String, + modifier: Modifier = Modifier, +) { + val normalizedProvider = clean(provider).lowercase(Locale.US) + val normalizedMode = clean(runtimeMode).lowercase(Locale.US) + val label = clean(provider).ifBlank { clean(runtimeMode).ifBlank { "runtime" } } + val initials = when (normalizedProvider) { + "claude" -> "C" + "codex" -> "O" + "opencode" -> "OC" + "openclaw" -> "CL" + "hermes" -> "H" + "pi" -> "PI" + "copilot" -> "GH" + "cursor" -> "CU" + "kimi" -> "K" + "kiro" -> "KI" + else -> label.take(2).uppercase(Locale.US) + } + val tint = when { + normalizedProvider == "claude" -> androidx.compose.ui.graphics.Color(0xFFD97757) + normalizedProvider == "codex" -> MulticaColors.Text + normalizedProvider == "cursor" -> androidx.compose.ui.graphics.Color(0xFF72716D) + normalizedProvider == "kimi" -> androidx.compose.ui.graphics.Color(0xFF6D4BFF) + normalizedProvider == "kiro" -> androidx.compose.ui.graphics.Color(0xFF9046FF) + normalizedMode == "cloud" -> MulticaColors.Accent + else -> MulticaColors.Muted + } + Box( + modifier = modifier + .size(34.dp) + .clip(RoundedCornerShape(10.dp)) + .background(tint.copy(alpha = 0.11f)) + .semantics { contentDescription = "Runtime Provider Mark $label" }, + contentAlignment = Alignment.Center, + ) { + Text( + text = initials.ifBlank { "R" }, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = if (initials.length > 1) 10.sp else 13.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.Bold, + ), + color = tint, + maxLines = 1, + ) + } +} + +@Composable +private fun RuntimeRowNameBlock( + baseName: String, + hostname: String?, + provider: String, + runtimeMode: String, + deviceInfo: String, + lastSeenAt: String, + zh: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.semantics { contentDescription = "Runtime Row Name Block ${baseName.ifBlank { hostname ?: "" }}" }, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + baseName.ifBlank { if (zh) "未命名 Runtime" else "Untitled runtime" }, + modifier = Modifier.semantics { contentDescription = "Runtime Row Base Name ${baseName.ifBlank { "untitled" }}" }, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOf( + clean(provider).ifBlank { if (zh) "未知 provider" else "Unknown provider" }, + clean(runtimeMode).ifBlank { if (zh) "未知模式" else "Unknown mode" }, + hostname?.takeIf { it.isNotBlank() }, + ).joinToString(" · "), + modifier = Modifier.semantics { contentDescription = "Runtime Row Hostname ${hostname ?: "none"}" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOf( + clean(deviceInfo).ifBlank { if (zh) "设备信息未知" else "Unknown device" }, + shortDate(lastSeenAt).ifBlank { if (zh) "未见心跳" else "No heartbeat" }, + ).joinToString(" · "), + modifier = Modifier.semantics { contentDescription = "Runtime Device Info ${clean(deviceInfo).ifBlank { "unknown" }}" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, lineHeight = 14.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun RuntimeVersionBadge(runtime: Models.Runtime, zh: Boolean) { + val version = runtimeDisplayVersion(runtime) + Text( + text = version, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MulticaColors.SurfaceElevated) + .padding(horizontal = 6.dp, vertical = 3.dp) + .semantics { contentDescription = "Runtime Version Badge $version" }, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.Monospace, + ), + color = if (version == Models.shortId(runtime.id)) MulticaColors.TextTertiary else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun RuntimeUpdateStatusBadge(runtime: Models.Runtime, zh: Boolean) { + val label = runtimeUpdateLabel(runtime, zh) + val needsUpdate = runtimeNeedsUpdate(runtime) + Text( + text = label, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(if (needsUpdate) MulticaColors.Warning.copy(alpha = 0.10f) else MulticaColors.SurfaceElevated) + .padding(horizontal = 6.dp, vertical = 3.dp) + .semantics { contentDescription = "Runtime Update Status $label" }, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (needsUpdate) MulticaColors.Warning else MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +private fun splitRuntimeName(name: String): Pair { + val trimmed = clean(name) + val match = Regex("^(.+?)\\s+\\(([^)]+)\\)$").find(trimmed) + return if (match != null) { + match.groupValues[1] to match.groupValues[2] + } else { + trimmed to null + } +} + +private fun runtimeNeedsUpdate(runtime: Models.Runtime): Boolean { + val status = clean(runtime.status).lowercase(Locale.US) + return "update" in status || "upgrade" in status || "outdated" in status +} + +private fun runtimeDisplayVersion(runtime: Models.Runtime): String { + return clean(runtime.version) + .ifBlank { extractRuntimeVersion(runtime.deviceInfo) } + .ifBlank { Models.shortId(runtime.id) } +} + +private fun extractRuntimeVersion(deviceInfo: String): String { + val normalized = clean(deviceInfo) + return Regex("""\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.]+)?""") + .find(normalized) + ?.value + .orEmpty() +} + +private fun runtimeUpdateLabel(runtime: Models.Runtime, zh: Boolean): String { + val mode = clean(runtime.runtimeMode).lowercase(Locale.US) + return when { + runtimeNeedsUpdate(runtime) -> if (zh) "需更新" else "Update" + mode == "cloud" -> if (zh) "托管" else "Managed" + runtimeDisplayVersion(runtime) == Models.shortId(runtime.id) -> if (zh) "未知" else "Unknown" + else -> if (zh) "检查" else "Check" + } +} + +private fun runtimeHealth(runtime: Models.Runtime): String { + val status = clean(runtime.status).lowercase(Locale.US) + return when { + "about_to_gc" in status || "gc" in status -> "about_to_gc" + "recent" in status || "lost" in status -> "recently_lost" + "online" in status || "ready" in status || "connected" in status || "active" in status -> "online" + else -> "offline" + } +} + +private fun runtimeHealthLabel(health: String, zh: Boolean): String = when (health) { + "all" -> if (zh) "全部" else "All" + "online" -> if (zh) "在线" else "Online" + "recently_lost" -> if (zh) "刚离线" else "Lost" + "about_to_gc" -> if (zh) "待回收" else "GC" + else -> if (zh) "离线" else "Offline" +} + +@Composable +private fun PilotRuntimeDetailSettings( + api: ApiClient, + workspaceId: String, + runtime: Models.Runtime, + zh: Boolean, + onBack: () -> Unit, +) { + var state by remember(workspaceId, runtime.id) { mutableStateOf?>(null) } + var refresh by remember(workspaceId, runtime.id) { mutableIntStateOf(0) } + var targetVersion by remember { mutableStateOf("") } + var updateRequest by remember { mutableStateOf(null) } + var modelList by remember { mutableStateOf(null) } + var resultText by remember { mutableStateOf(null) } + var capabilityResultText by remember { mutableStateOf(null) } + var capabilityLoadingText by remember { mutableStateOf(null) } + var loadingAction by remember { mutableStateOf(false) } + var connectRemoteOpen by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + if (connectRemoteOpen) { + PilotRuntimeConnectRemotePage( + zh = zh, + onBack = { connectRemoteOpen = false }, + ) + return + } + + LaunchedEffect(workspaceId, runtime.id, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + val loadErrors = mutableListOf() + fun loadRuntimeSection(label: String, fallback: T, block: () -> T): T { + return RuntimeSectionLoader.load(label, fallback, loadErrors) { block() } + } + PilotRuntimeDetailData( + usage = loadRuntimeSection("usage", emptyList()) { api.runtimeUsage(workspaceId, runtime.id, 30) }, + activity = loadRuntimeSection("activity", emptyList()) { api.runtimeActivity(workspaceId, runtime.id) }, + usageByAgent = loadRuntimeSection("usage by agent", emptyList()) { api.runtimeUsageByAgent(workspaceId, runtime.id, 30) }, + usageByHour = loadRuntimeSection("usage by hour", emptyList()) { api.runtimeUsageByHour(workspaceId, runtime.id, 30) }, + members = loadRuntimeSection("members", emptyList()) { api.members(workspaceId) }, + agents = loadRuntimeSection("agents", emptyList()) { api.agents(workspaceId, true) }, + loadErrors = loadErrors, + ) + } + } + } + + fun setFailure(prefix: String, error: Throwable): String { + val message = "$prefix: ${error.message ?: error.toString()}" + resultText = message + return message + } + + fun scopedFailure(prefix: String, error: Throwable): String { + return "$prefix: ${error.message ?: error.toString()}" + } + + PilotListPage( + title = if (zh) "Runtime 详情" else "Runtime Detail", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Hero" }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Accent.copy(alpha = 0.10f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Dashboard, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + runtime.name, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOf(runtime.status, runtime.provider, runtime.runtimeMode).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText( + label = if (zh) "设备" else "Device", + value = runtime.deviceInfo.ifBlank { if (zh) "未知" else "Unknown" }, + modifier = Modifier.weight(1f), + ) + CompactMetaText( + label = if (zh) "最后在线" else "Last Seen", + value = runtime.lastSeenAt.takeIf { it.isNotBlank() }?.let { shortDate(it) } ?: "-", + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText( + label = "Daemon", + value = runtime.daemonId.ifBlank { "-" }.let { if (it == "-") it else Models.shortId(it) }, + modifier = Modifier.weight(1f), + ) + CompactMetaText( + label = if (zh) "版本" else "Version", + value = runtime.version.ifBlank { "-" }, + modifier = Modifier.weight(1f), + ) + } + } + } + if (!resultText.isNullOrBlank()) { + item { + if (resultText!!.contains("failed", true) || resultText!!.contains("失败")) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "Runtime Detail Result Web Banner", + ) + } else { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载 Runtime 详情..." else "Loading runtime detail...") } + else -> { + loaded.onFailure { item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } } + loaded.onSuccess { data -> + val owner = data.members.firstOrNull { it.id == runtime.ownerId }?.displayName + val servingAgents = data.agents.filter { it.runtimeId == runtime.id } + val usageTotal = data.usage.sumOf { it.totalTokens() } + val inputTotal = data.usage.sumOf { it.inputTokens } + val outputTotal = data.usage.sumOf { it.outputTokens } + + item { SettingsSectionLabel(if (zh) "用量 · 30 天" else "Usage · 30D") } + if (data.loadErrors.isNotEmpty()) { + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Partial Error Row" }, + ) { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "部分数据不可用" else "Partial data unavailable", + title = if (zh) "Runtime 详情仍可继续查看" else "Runtime detail is still available", + subtitle = data.loadErrors.joinToString("\n"), + icon = Icons.Outlined.Bolt, + contentDescription = "Runtime Detail Web Partial Error Row", + ) + } + } + } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Usage Metrics Runtime Usage Compact Metric Cards" }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText(if (zh) "总 Tokens" else "Tokens", usageTotal.toString(), Modifier.weight(1f)) + CompactMetaText("Input", inputTotal.toString(), Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + CompactMetaText("Output", outputTotal.toString(), Modifier.weight(1f)) + CompactMetaText("Owner", owner ?: "-", Modifier.weight(1f)) + } + } + } + + if (data.activity.isNotEmpty()) { + item { SettingsSectionLabel(if (zh) "活动" else "Activity") } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Activity" }, + ) { + val rows = data.activity.take(5) + rows.forEachIndexed { index, row -> + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "小时" else "Hour", + title = row.hour, + subtitle = "${row.totalTasks} tasks", + icon = Icons.Outlined.CalendarMonth, + contentDescription = "Runtime Detail Web Dense Row Activity ${row.hour}", + showDivider = index < rows.lastIndex, + ) + } + } + } + } + + if (data.usageByHour.isNotEmpty()) { + item { SettingsSectionLabel(if (zh) "按小时用量" else "Usage by Hour") } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Usage By Hour" }, + ) { + val rows = data.usageByHour.take(5) + RuntimeUsageHourMiniBars(rows = rows, zh = zh) + rows.forEachIndexed { index, row -> + RuntimeDetailWebDenseRow( + eyebrow = row.model, + title = row.hour, + subtitle = "${row.totalTokens()} tokens", + icon = Icons.Outlined.Dashboard, + contentDescription = "Runtime Detail Web Dense Row Usage Hour ${row.hour}", + showDivider = index < rows.lastIndex, + ) + } + } + } + } + + if (servingAgents.isNotEmpty()) { + item { SettingsSectionLabel(if (zh) "关联 Agents" else "Serving Agents") } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Serving Agents" }, + ) { + val visibleAgents = servingAgents.take(4) + visibleAgents.forEachIndexed { index, agent -> + RuntimeDetailWebDenseRow( + eyebrow = agent.status.ifBlank { agent.visibility }, + title = agent.name, + subtitle = agent.description.ifBlank { agent.visibility }, + icon = Icons.Outlined.Person, + contentDescription = "Runtime Detail Web Dense Row Agent ${agent.id}", + showDivider = index < visibleAgents.lastIndex || servingAgents.size > visibleAgents.size, + ) + } + if (servingAgents.size > visibleAgents.size) { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "更多" else "More", + title = if (zh) "还有 ${servingAgents.size - visibleAgents.size} 个 Agent" else "${servingAgents.size - visibleAgents.size} more agents", + subtitle = if (zh) "完整列表保留在 Web/runtime 详情中" else "Full list remains available in runtime detail", + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Runtime Detail Web Dense Row More Agents", + ) + } + } + } + } + + if (data.usageByAgent.isNotEmpty()) { + item { SettingsSectionLabel(if (zh) "按 Agent 用量" else "Usage by Agent") } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Usage By Agent" }, + ) { + val rows = data.usageByAgent.sortedByDescending { it.totalTokens() }.take(5) + rows.forEachIndexed { index, row -> + RuntimeDetailWebDenseRow( + eyebrow = row.model.ifBlank { "${row.taskCount} tasks" }, + title = row.agentName.ifBlank { Models.shortId(row.agentId) }, + subtitle = "${row.totalTokens()} tokens", + icon = Icons.Outlined.Bolt, + contentDescription = "Runtime Detail Web Dense Row Usage Agent ${row.agentId}", + showDivider = index < rows.lastIndex, + ) + } + } + } + } + } + } + } + + item { SettingsSectionLabel(if (zh) "Runtime Update" else "Runtime Update") } + item { + MulticaTextField( + value = targetVersion, + onValueChange = { targetVersion = it }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Runtime Detail Target Version" }, + label = if (zh) "目标版本" else "Target version", + singleLine = true, + ) + } + item { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MulticaPillButton( + if (loadingAction) "..." else if (zh) "发起更新" else "Update Runtime", + onClick = { + val version = targetVersion.trim() + if (version.isEmpty() || loadingAction) { + resultText = if (zh) "请先填写目标版本" else "Enter a target version first" + return@MulticaPillButton + } + scope.launch { + loadingAction = true + val result = withContext(Dispatchers.IO) { runCatching { api.initiateRuntimeUpdate(workspaceId, runtime.id, version) } } + loadingAction = false + result.onSuccess { + updateRequest = it + resultText = "${if (zh) "已发起更新" else "Update started"}: ${it.status}" + }.onFailure { setFailure(if (zh) "更新失败" else "Update failed", it) } + } + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + if (zh) "刷新状态" else "Refresh Status", + onClick = { + val requestId = updateRequest?.id.orEmpty() + if (requestId.isBlank() || loadingAction) return@MulticaPillButton + scope.launch { + loadingAction = true + val result = withContext(Dispatchers.IO) { runCatching { api.runtimeUpdateResult(workspaceId, runtime.id, requestId) } } + loadingAction = false + result.onSuccess { + updateRequest = it + resultText = "${if (zh) "更新状态" else "Update status"}: ${it.status}" + }.onFailure { setFailure(if (zh) "刷新失败" else "Refresh failed", it) } + } + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + } + } + updateRequest?.let { update -> + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Update Result Row" }, + ) { + RuntimeDetailWebDenseRow( + eyebrow = update.targetVersion, + title = update.status, + subtitle = update.error.ifBlank { update.output.ifBlank { update.id } }, + icon = Icons.Outlined.ArrowUpward, + contentDescription = "Runtime Detail Web Update Result Row", + ) + } + } + } + + item { SettingsSectionLabel(if (zh) "连接远程 Runtime" else "Connect Remote") } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Connect Remote Entry Panel" }, + ) { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "独立页面" else "Independent page", + title = if (zh) "连接远程 Runtime" else "Connect remote runtime", + subtitle = if (zh) "安装、配置、登录和启动命令" else "Install, configure, login, and daemon commands", + icon = Icons.Outlined.Link, + contentDescription = "Runtime Connect Remote Entry", + onClick = { connectRemoteOpen = true }, + ) + } + } + + item { SettingsSectionLabel(if (zh) "Runtime Capabilities" else "Runtime Capabilities") } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + if (loadingAction && capabilityLoadingText != null) { + capabilityLoadingText.orEmpty() + } else if (zh) "刷新模型" else "Refresh Models", + onClick = { + if (loadingAction) return@MulticaPillButton + val loadingMessage = if (zh) "正在刷新模型..." else "Refreshing models..." + capabilityResultText = loadingMessage + capabilityLoadingText = loadingMessage + scope.launch { + loadingAction = true + val result = runCatching { + withTimeout(15000) { + withContext(Dispatchers.IO) { api.initiateListRuntimeModels(workspaceId, runtime.id) } + } + } + loadingAction = false + capabilityLoadingText = null + result.onSuccess { + modelList = it + val message = "${if (zh) "模型请求" else "Model request"}: ${it.status}" + capabilityResultText = message + }.onFailure { capabilityResultText = scopedFailure(if (zh) "模型刷新失败" else "Refresh models failed", it) } + } + }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Secondary, + contentDescription = "Runtime Refresh Models", + ) + if (!capabilityResultText.isNullOrBlank()) { + PilotInlineResultState( + message = capabilityResultText.orEmpty(), + isError = pilotInlineResultIsError(capabilityResultText.orEmpty()), + contentDescription = "Runtime Capability Result Web Banner", + ) + } + } + } + modelList?.let { list -> + item { + MulticaPillButton( + if (loadingAction) "..." else if (zh) "检查模型结果" else "Check Models Result", + onClick = { + if (loadingAction || list.id.isBlank()) return@MulticaPillButton + capabilityResultText = if (zh) "正在检查模型结果..." else "Checking models result..." + scope.launch { + loadingAction = true + val result = runCatching { + withTimeout(15000) { + withContext(Dispatchers.IO) { + api.runtimeModelListResult(workspaceId, runtime.id, list.id) + } + } + } + loadingAction = false + result.onSuccess { + modelList = it + capabilityResultText = "${if (zh) "模型结果" else "Models result"}: ${it.status}" + }.onFailure { + capabilityResultText = scopedFailure(if (zh) "模型结果失败" else "Models result failed", it) + } + } + }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Secondary, + contentDescription = "Runtime Check Models Result", + ) + } + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Detail Web Model Results" }, + ) { + val visibleModels = list.models.take(8) + RuntimeDetailWebDenseRow( + eyebrow = if (list.supported) "supported" else list.id.ifBlank { "models" }, + title = "Models · ${list.status}", + subtitle = list.error.ifBlank { "${list.models.size} models" }, + icon = Icons.Outlined.Dashboard, + contentDescription = "Runtime Detail Web Model Result Row", + showDivider = visibleModels.isNotEmpty(), + ) + visibleModels.forEachIndexed { index, model -> + RuntimeDetailWebDenseRow( + eyebrow = model.provider.ifBlank { "model" }, + title = model.name, + subtitle = model.id.ifBlank { null }, + icon = Icons.Outlined.Bolt, + contentDescription = "Runtime Detail Web Model Row ${model.id.ifBlank { model.name }}", + showDivider = index < visibleModels.lastIndex, + ) + } + } + } + } + item { Spacer(modifier = Modifier.height(120.dp)) } + } +} + +@Composable +private fun PilotRuntimeConnectRemotePage( + zh: Boolean, + onBack: () -> Unit, +) { + PilotListPage( + title = if (zh) "连接远程 Runtime" else "Connect Remote", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + ) { + item { + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Connect Remote Page Runtime Detail Web Connect Commands" }, + ) { + SettingsCompactCommandRow( + title = if (zh) "安装 CLI" else "Install CLI", + command = "curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash", + showDivider = true, + ) + SettingsCompactCommandRow( + title = if (zh) "配置服务" else "Configure service", + command = "multica config set server_url https://api.multica.ai", + showDivider = true, + ) + SettingsCompactCommandRow( + title = if (zh) "登录" else "Login", + command = "multica login --token ", + showDivider = true, + ) + SettingsCompactCommandRow( + title = if (zh) "启动 daemon" else "Start daemon", + command = "multica daemon start --device-name \"my-ec2-instance\"", + showDivider = true, + ) + SettingsCompactCommandRow( + title = if (zh) "检查状态" else "Check status", + command = "multica daemon status", + ) + } + } + } +} + +@Composable +private fun RuntimeUsageHourMiniBars( + rows: List, + zh: Boolean, + modifier: Modifier = Modifier, +) { + val maxTokens = rows.maxOfOrNull { it.totalTokens() } ?: 0L + Column( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Runtime Usage Hour Mini Bars" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + rows.forEach { row -> + val total = row.totalTokens() + val fraction = if (maxTokens <= 0L) 0.04f else (total.toFloat() / maxTokens.toFloat()).coerceIn(0.04f, 1f) + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Runtime Usage Hour Mini Bar ${row.hour}" }, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (zh) "${row.hour} 点" else "Hour ${row.hour}", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${total} tokens", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(99.dp)) + .background(MulticaColors.Border.copy(alpha = 0.55f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .height(6.dp) + .clip(RoundedCornerShape(99.dp)) + .background(MulticaColors.Accent.copy(alpha = 0.72f)), + ) + } + } + } + } +} + +@Composable +private fun RuntimeDetailWebDenseRow( + eyebrow: String, + title: String, + subtitle: String?, + icon: ImageVector, + contentDescription: String, + showDivider: Boolean = false, + onClick: (() -> Unit)? = null, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .padding(vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(26.dp), + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Accent.copy(alpha = 0.10f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(14.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = eyebrow.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 8.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border) + ) + } + } +} + +private enum class LabelSettingsMode { List, Create, Edit } + +private data class PilotLabelsSettingsData( + val labels: List, + val usageCounts: Map, + val sampledIssueCount: Int, + val usageHasMore: Boolean, +) + +private val LABEL_COLOR_PALETTE = listOf( + "#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", + "#3b82f6", "#6366f1", "#a855f7", "#ec4899", "#64748b", +) + +private const val WEB_SSOT_LABEL_CHIP = "/Users/park0er/coding/multica-web-source/packages/views/labels/label-chip.tsx" +private const val WEB_SSOT_LABELS_PANEL_PATH = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/labels-panel.tsx" +private const val WEB_SSOT_LABEL_PICKER = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/pickers/label-picker.tsx" + +@Composable +private fun PilotLabelsSettings(api: ApiClient, workspaceId: String, zh: Boolean, onBack: () -> Unit) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var mode by remember(workspaceId) { mutableStateOf(LabelSettingsMode.List) } + var formLabel by remember(workspaceId) { mutableStateOf(null) } + var formName by remember(workspaceId) { mutableStateOf("") } + var formColor by remember(workspaceId) { mutableStateOf("#3b82f6") } + var saving by remember(workspaceId) { mutableStateOf(false) } + var error by remember(workspaceId) { mutableStateOf(null) } + var createdLabels by remember(workspaceId) { mutableStateOf>(emptyList()) } + var deletingLabel by remember(workspaceId) { mutableStateOf(null) } + var mutatingLabelId by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + val labels = api.labels(workspaceId) + val issuesPage = runCatching { api.issues(workspaceId, 250, 0) }.getOrNull() + PilotLabelsSettingsData( + labels = labels, + usageCounts = labelUsageCounts(issuesPage?.items.orEmpty()), + sampledIssueCount = issuesPage?.items?.size ?: 0, + usageHasMore = issuesPage?.hasMore ?: false, + ) + } + } + } + + fun openCreate() { + mode = LabelSettingsMode.Create + formLabel = null + formName = "" + formColor = "#3b82f6" + deletingLabel = null + error = null + } + + fun openEdit(label: Models.IssueLabel) { + mode = LabelSettingsMode.Edit + formLabel = label + formName = label.name + formColor = normalizeLabelColor(label.color) + deletingLabel = null + error = null + } + + fun closeForm() { + mode = LabelSettingsMode.List + formLabel = null + formName = "" + formColor = "#3b82f6" + } + + fun saveForm() { + val nextName = formName.trim() + val nextColor = normalizeLabelColor(formColor) + if (nextName.isBlank() || saving) return + val editing = formLabel + saving = true + error = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + if (editing == null) { + api.createLabel(workspaceId, nextName, nextColor) + } else { + api.updateLabel(workspaceId, editing.id, nextName, nextColor) + } + } + } + saving = false + result.onSuccess { saved -> + createdLabels = listOf(saved) + createdLabels.filter { it.id != saved.id } + closeForm() + refresh++ + }.onFailure { + error = "${if (zh) "保存失败" else "Save failed"}: ${it.message ?: it.toString()}" + } + } + } + + if (mode != LabelSettingsMode.List) { + LabelFormPage( + mode = mode, + label = formLabel, + name = formName, + color = formColor, + saving = saving, + error = error, + zh = zh, + onNameChange = { formName = it }, + onColorChange = { formColor = normalizeLabelColor(it) }, + onBack = { closeForm() }, + onSave = { saveForm() }, + ) + return + } + + PilotListPage( + title = if (zh) "Labels" else "Labels", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = "Label Header Create Button", + onClick = { openCreate() }, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + item { + SettingsWebTabGroup( + title = if (zh) "标签" else "Labels", + subtitle = if (zh) { + "颜色标签用于跨列表、看板和详情页分类 issue" + } else { + "Color labels classify issues across list, board, and detail surfaces" + }, + contentDescription = "Label Management Web Intro /Users/park0er/coding/multica-web-source/packages/views/issues/components/labels-panel.tsx /Users/park0er/coding/multica-web-source/packages/views/issues/components/pickers/label-picker.tsx", + ) { + SettingsWebTabRow( + title = if (zh) "创建 Label" else "Create Label", + subtitle = if (zh) "使用 swatch palette 选择颜色" else "Choose from the swatch palette", + icon = Icons.Outlined.Add, + onClick = { openCreate() }, + contentDescription = "Label Create Entry", + ) + } + } + if (!error.isNullOrBlank()) item { + PilotInlineResultState( + message = error.orEmpty(), + isError = true, + contentDescription = "Label Result Web Banner", + ) + } + deletingLabel?.let { label -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = if (zh) "删除 Label?" else "Delete label?", + message = if (zh) { + "这会从整个工作区移除 ${label.name}。" + } else { + "This removes ${label.name} from the workspace." + }, + cancelText = if (zh) "取消" else "Cancel", + confirmText = if (mutatingLabelId == label.id) "..." else if (zh) "删除" else "Delete", + destructive = true, + contentDescription = "Label Delete Cupertino Alert ${label.id}", + onDismissRequest = { deletingLabel = null }, + onConfirm = { + if (mutatingLabelId != null) return@MulticaCupertinoAlertDialog + mutatingLabelId = label.id + error = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteLabel(workspaceId, label.id) } + } + mutatingLabelId = null + result.onSuccess { + deletingLabel = null + createdLabels = createdLabels.filter { it.id != label.id } + refresh++ + }.onFailure { + error = "${if (zh) "删除失败" else "Delete failed"}: ${it.message ?: it.toString()}" + } + } + }, + ) + } + } + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载 Labels..." else "Loading labels...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { data -> + val visibleLabels = createdLabels + data.labels.filter { label -> + createdLabels.none { it.id == label.id } + } + if (visibleLabels.isEmpty()) item { LabelsWebEmptyState(zh = zh) } + if (visibleLabels.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Label Web List Header" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (zh) "标签" else "Label", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + if (zh) "使用情况" else "Usage", + modifier = Modifier.width(108.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.width(42.dp)) + } + } + } + items(visibleLabels) { label -> + LabelWebDenseRow( + label = label, + usageCount = data.usageCounts[label.id] ?: 0, + usageApproximate = data.usageHasMore, + zh = zh, + onEdit = { openEdit(label) }, + onDelete = { deletingLabel = label }, + ) + } + } + } + } + } +} + +@Composable +private fun LabelsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_LABELS_PANEL = "/Users/park0er/coding/multica-web-source/packages/views/issues/components/labels-panel.tsx" + val WEB_SSOT_LABELS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/issues.json" + Box( + modifier = modifier.semantics { + contentDescription = "Labels Web Empty State $WEB_SSOT_LABELS_PANEL $WEB_SSOT_LABELS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有标签。" else "No labels yet.", + description = if (zh) { + "创建和管理标签,用于在工作区内分类 issue。" + } else { + "Create and manage labels to categorize issues across your workspace." + }, + ) + } +} + +@Composable +private fun LabelFormPage( + mode: LabelSettingsMode, + label: Models.IssueLabel?, + name: String, + color: String, + saving: Boolean, + error: String?, + zh: Boolean, + onNameChange: (String) -> Unit, + onColorChange: (String) -> Unit, + onBack: () -> Unit, + onSave: () -> Unit, +) { + val isCreate = mode == LabelSettingsMode.Create + PilotListPage( + title = if (isCreate) { + if (zh) "创建 Label" else "Create Label" + } else { + if (zh) "编辑 Label" else "Edit Label" + }, + leading = { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back to Labels", + onClick = onBack, + tone = MulticaButtonTone.Ghost, + ) + }, + bottomContent = { + MulticaHazeBottomBar( + hazeState = rememberHazeState(), + contentDescription = "Label Form Bottom Haze Save Bar", + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onBack, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (isCreate) { + if (zh) "创建 Label" else "Create Label" + } else { + if (zh) "保存 Label" else "Save Label" + }, + onClick = onSave, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + enabled = name.trim().isNotBlank() && !saving, + contentDescription = "Label Form Bottom Save", + ) + } + } + }, + ) { + if (!error.isNullOrBlank()) { + item { + PilotInlineResultState( + message = error, + isError = true, + contentDescription = "Label Form Result Web Banner", + ) + } + } + item { + LabelFormWebPanel( + mode = mode, + label = label, + name = name, + color = color, + zh = zh, + onNameChange = onNameChange, + onColorChange = onColorChange, + ) + } + } +} + +@Composable +private fun LabelFormWebPanel( + mode: LabelSettingsMode, + label: Models.IssueLabel?, + name: String, + color: String, + zh: Boolean, + onNameChange: (String) -> Unit, + onColorChange: (String) -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Label Form Web Panel /Users/park0er/coding/multica-web-source/packages/views/labels/label-chip.tsx /Users/park0er/coding/multica-web-source/packages/views/issues/components/labels-panel.tsx /Users/park0er/coding/multica-web-source/packages/views/issues/components/pickers/label-picker.tsx" + }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface.copy(alpha = 0.95f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.5.dp, MulticaColors.Border.copy(alpha = 0.72f)), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val preview = Models.IssueLabel(JSONObject() + .put("id", label?.id ?: "preview-label") + .put("name", name.ifBlank { if (zh) "新 Label" else "New Label" }) + .put("color", normalizeLabelColor(color))) + LabelContrastChip( + label = preview, + contentDescription = "Label Form Contrast Preview", + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + if (mode == LabelSettingsMode.Create) { + if (zh) "新建标签" else "New label" + } else { + if (zh) "编辑标签" else "Edit label" + }, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + if (zh) "颜色直接决定列表、看板和详情页里的扫读效率" else "Color drives scan speed in list, board, and detail views", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + MulticaTextField( + value = name, + onValueChange = onNameChange, + label = if (zh) "标签名" else "Label name", + singleLine = true, + contentDescription = "Label Form Name", + ) + LabelColorPickerSwatches( + selectedColor = color, + colors = LABEL_COLOR_PALETTE, + zh = zh, + onColorChange = onColorChange, + ) + } + } +} + +@Composable +private fun LabelWebDenseRow( + label: Models.IssueLabel, + usageCount: Int, + usageApproximate: Boolean, + zh: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + var actionsOpen by remember(label.id) { mutableStateOf(false) } + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Label Web Dense Row ${label.name}" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + LabelColorSwatch(label.color, "Label Color Swatch ${label.name}") + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + LabelContrastChip( + label = label, + contentDescription = "Label Contrast Chip ${label.name}", + ) + Text( + Models.shortId(label.id), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + labelUsageText(usageCount, usageApproximate, zh), + modifier = Modifier + .width(108.dp) + .semantics { contentDescription = "Label Usage Count ${label.name} $usageCount" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Label More Menu ${label.name}", + onClick = { actionsOpen = true }, + tone = if (actionsOpen) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "Label More Menu ${label.name}" }, + ) + } + } + MulticaCupertinoActionSheet( + visible = actionsOpen, + title = if (zh) "Label 操作" else "Label actions", + message = label.name, + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "编辑" else "Edit", + subtitle = if (zh) "名称和颜色" else "Name and color", + icon = Icons.Outlined.Edit, + contentDescription = "Label Edit Action ${label.name}", + onClick = onEdit, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "删除" else "Delete", + subtitle = if (zh) "从工作区移除" else "Remove from workspace", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Label Delete Action ${label.name}", + onClick = onDelete, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { actionsOpen = false }, + ) +} + +@Composable +private fun LabelColorSwatch(value: String, contentDescription: String) { + val color = remember(value) { + runCatching { androidx.compose.ui.graphics.Color(AndroidColor.parseColor(value.ifBlank { "#2563eb" })) } + .getOrDefault(MulticaColors.Accent) + } + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color) + .border(1.dp, MulticaColors.Border.copy(alpha = 0.65f), RoundedCornerShape(8.dp)) + .semantics { this.contentDescription = contentDescription }, + ) +} + +@Composable +private fun LabelContrastChip(label: Models.IssueLabel, contentDescription: String) { + val bg = remember(label.color) { + runCatching { Color(AndroidColor.parseColor(normalizeLabelColor(label.color))) } + .getOrDefault(MulticaColors.Accent) + } + val fg = remember(label.color) { labelContrastTextColor(label.color) } + Surface( + modifier = Modifier.semantics { this.contentDescription = contentDescription }, + shape = RoundedCornerShape(999.dp), + color = bg, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = label.name, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp, lineHeight = 14.sp, fontWeight = FontWeight.SemiBold), + color = fg, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun LabelColorPickerSwatches( + selectedColor: String, + colors: List, + zh: Boolean, + onColorChange: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + if (zh) "颜色" else "Color", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(9.dp), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Label Form Color Swatch Picker" }, + ) { + items(colors) { option -> + val selected = normalizeLabelColor(option).equals(normalizeLabelColor(selectedColor), ignoreCase = true) + Box( + modifier = Modifier + .size(38.dp) + .clip(RoundedCornerShape(12.dp)) + .background(runCatching { Color(AndroidColor.parseColor(option)) }.getOrDefault(MulticaColors.Accent)) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) MulticaColors.Text else MulticaColors.Border.copy(alpha = 0.72f), + shape = RoundedCornerShape(12.dp), + ) + .clickable { onColorChange(option) } + .semantics { contentDescription = "Label Form Color Swatch Option $option" }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + tint = labelContrastTextColor(option), + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } +} + +@Composable +private fun PilotLabelsSettingsPreview() { + var labels by remember { + mutableStateOf( + listOf( + Models.IssueLabel(JSONObject().put("id", "preview-bug").put("name", "bug").put("color", "#ef4444")), + Models.IssueLabel(JSONObject().put("id", "preview-ios").put("name", "ios-polish").put("color", "#3b82f6")), + Models.IssueLabel(JSONObject().put("id", "preview-dark").put("name", "dark-chip").put("color", "#0f172a")), + ) + ) + } + var mode by remember { mutableStateOf(LabelSettingsMode.List) } + var editing by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var color by remember { mutableStateOf("#3b82f6") } + val usageCounts = remember { mapOf("preview-bug" to 7, "preview-ios" to 3, "preview-dark" to 0) } + + fun openCreate() { + editing = null + name = "" + color = "#3b82f6" + mode = LabelSettingsMode.Create + } + + fun openEdit(label: Models.IssueLabel) { + editing = label + name = label.name + color = normalizeLabelColor(label.color) + mode = LabelSettingsMode.Edit + } + + if (mode != LabelSettingsMode.List) { + LabelFormPage( + mode = mode, + label = editing, + name = name, + color = color, + saving = false, + error = null, + zh = false, + onNameChange = { name = it }, + onColorChange = { color = it }, + onBack = { mode = LabelSettingsMode.List }, + onSave = { + val id = editing?.id ?: "preview-${System.currentTimeMillis()}" + val saved = Models.IssueLabel(JSONObject().put("id", id).put("name", name.ifBlank { "new-label" }).put("color", color)) + labels = listOf(saved) + labels.filter { it.id != id } + mode = LabelSettingsMode.List + }, + ) + return + } + + PilotListPage( + title = "Labels", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = "Label Header Create Button Preview", + onClick = { openCreate() }, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + item { + SettingsWebTabGroup( + title = "Labels", + subtitle = "Color labels classify issues across list, board, and detail surfaces", + contentDescription = "Label Management Web Intro Preview /Users/park0er/coding/multica-web-source/packages/views/issues/components/labels-panel.tsx /Users/park0er/coding/multica-web-source/packages/views/issues/components/pickers/label-picker.tsx", + ) { + SettingsWebTabRow( + title = "Create Label", + subtitle = "Choose from the swatch palette", + icon = Icons.Outlined.Add, + onClick = { openCreate() }, + contentDescription = "Label Create Entry Preview", + ) + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Label Web List Header Preview" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Label", modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.TextTertiary) + Text("Usage", modifier = Modifier.width(108.dp), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.TextTertiary, textAlign = TextAlign.Center) + Spacer(Modifier.width(42.dp)) + } + } + items(labels) { label -> + LabelWebDenseRow( + label = label, + usageCount = usageCounts[label.id] ?: 0, + usageApproximate = false, + zh = false, + onEdit = { openEdit(label) }, + onDelete = { deleting = label }, + ) + } + deleting?.let { label -> + item { + MulticaCupertinoAlertDialog( + visible = true, + title = "Delete label?", + message = "This removes ${label.name} from the workspace.", + cancelText = "Cancel", + confirmText = "Delete", + destructive = true, + contentDescription = "Label Delete Cupertino Alert Preview ${label.id}", + onDismissRequest = { deleting = null }, + onConfirm = { + labels = labels.filter { it.id != label.id } + deleting = null + }, + ) + } + } + } +} + +private fun labelUsageCounts(issues: List): Map { + val counts = LinkedHashMap() + issues.forEach { issue -> + issue.labels.forEach { label -> + counts[label.id] = (counts[label.id] ?: 0) + 1 + } + } + return counts +} + +private fun labelUsageText(count: Int, approximate: Boolean, zh: Boolean): String { + val suffix = if (approximate && count > 0) "+" else "" + return if (count == 0) { + if (zh) "未使用" else "No usage" + } else if (zh) { + "$count$suffix 个 issue" + } else { + "$count$suffix ${if (count == 1) "issue" else "issues"}" + } +} + +private fun normalizeLabelColor(value: String): String { + val trimmed = value.trim().ifBlank { "#3b82f6" } + val withHash = if (trimmed.startsWith("#")) trimmed else "#$trimmed" + return if (withHash.matches(Regex("^#[0-9a-fA-F]{6}$"))) { + withHash.lowercase(Locale.US) + } else { + "#3b82f6" + } +} + +private fun labelContrastTextColor(hex: String): Color { + val normalized = normalizeLabelColor(hex).removePrefix("#") + val r = normalized.substring(0, 2).toInt(16) / 255f + val g = normalized.substring(2, 4).toInt(16) / 255f + val b = normalized.substring(4, 6).toInt(16) / 255f + val luminance = 0.299f * r + 0.587f * g + 0.114f * b + return if (luminance > 0.55f) Color(0xFF111827) else Color(0xFFF9FAFB) +} + +private enum class SkillSettingsMode { List, Create, Edit, Import } + +@Composable +private fun PilotSkillsSettings(api: ApiClient, workspaceId: String, zh: Boolean, onBack: () -> Unit) { + var state by remember(workspaceId) { mutableStateOf>?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + var selectedSkill by remember(workspaceId) { mutableStateOf(null) } + var editing by remember(workspaceId) { mutableStateOf(null) } + var skillsMode by remember(workspaceId) { mutableStateOf(SkillSettingsMode.List) } + var pendingDelete by remember(workspaceId) { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var content by remember { mutableStateOf("") } + var importUrl by remember { mutableStateOf("") } + var importRuntimeId by remember { mutableStateOf("") } + var importRuntimes by remember(workspaceId) { mutableStateOf>(emptyList()) } + var importRuntimeSkillsRequest by remember(workspaceId) { mutableStateOf(null) } + var importRuntimeSkillsLoading by remember(workspaceId) { mutableStateOf(false) } + var importSelectedSkillKey by remember(workspaceId) { mutableStateOf("") } + var importRuntimeRequest by remember(workspaceId) { mutableStateOf(null) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var resultText by remember { mutableStateOf(null) } + var createdSkills by remember(workspaceId) { mutableStateOf>(emptyList()) } + var deletedSkillIds by remember(workspaceId) { mutableStateOf>(emptySet()) } + var skillQuery by remember(workspaceId) { mutableStateOf("") } + var skillFilter by remember(workspaceId) { mutableStateOf("all") } + var expandedSkillActionsId by remember(workspaceId) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun resetForm() { + editing = null + skillsMode = SkillSettingsMode.List + name = "" + description = "" + content = "" + } + + fun startEdit(skill: Models.Skill) { + selectedSkill = null + editing = skill + skillsMode = SkillSettingsMode.Edit + name = skill.name + description = clean(skill.description) + content = clean(skill.content) + error = null + resultText = null + } + + fun upsertVisible(skill: Models.Skill) { + deletedSkillIds = deletedSkillIds - skill.id + createdSkills = listOf(skill) + createdSkills.filter { it.id != skill.id } + } + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { runCatching { api.skills(workspaceId) } } + } + + selectedSkill?.let { skill -> + PilotSkillDetail( + api = api, + workspaceId = workspaceId, + skill = skill, + zh = zh, + onBack = { selectedSkill = null }, + onEdit = { startEdit(it) }, + ) + return + } + + if (skillsMode == SkillSettingsMode.Create || skillsMode == SkillSettingsMode.Edit) { + val editingSkill = editing + PilotListPage( + title = if (editingSkill == null) { + if (zh) "新建 Skill" else "New Skill" + } else { + if (zh) "编辑 Skill" else "Edit Skill" + }, + leading = { + PilotSecondaryBackButton( + zh = zh, + onBack = { resetForm() }, + contentDescription = if (zh) "返回 Skills" else "Back to Skills", + ) + }, + ) { + item { SettingsSectionLabel(if (zh) "基础信息" else "Identity") } + item { + MulticaTextField( + value = name, + onValueChange = { name = it }, + label = if (zh) "名称" else "Name", + singleLine = true, + contentDescription = "Create Skill Form Name", + ) + } + item { + MulticaTextField( + value = description, + onValueChange = { description = it }, + label = if (zh) "描述" else "Description", + minLines = 2, + maxLines = 4, + contentDescription = "Create Skill Form Description", + ) + } + item { SettingsSectionLabel("SKILL.md") } + item { + MulticaTextField( + value = content, + onValueChange = { content = it }, + label = if (zh) "内容" else "Content", + minLines = 8, + maxLines = 14, + contentDescription = "Create Skill Form Content", + ) + } + if (!error.isNullOrBlank()) item { + PilotInlineResultState( + message = error.orEmpty(), + isError = true, + contentDescription = "Skill Form Result Web Banner", + ) + } + if (!resultText.isNullOrBlank()) item { MulticaSuccessState(resultText.orEmpty()) } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { resetForm() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (editingSkill == null) if (zh) "创建" else "Create" else if (zh) "保存" else "Save", + onClick = { + val skillName = name.trim() + if (skillName.isEmpty() || saving) return@MulticaPillButton + saving = true + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + if (editingSkill == null) { + runCatching { api.createSkill(workspaceId, skillName, description, content) } + } else { + runCatching { api.updateSkill(workspaceId, editingSkill, skillName, description, content) } + } + } + saving = false + result.onSuccess { created -> + upsertVisible(created) + resetForm() + resultText = if (zh) "Skill 已保存" else "Skill saved" + refresh++ + }.onFailure { + error = "${if (editingSkill == null) if (zh) "创建失败" else "Create failed" else if (zh) "保存失败" else "Save failed"}: ${it.message ?: it.toString()}" + } + } + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + } + } + return + } + + if (skillsMode == SkillSettingsMode.Import) { + val localImportRuntimes = importRuntimes.filter { runtime -> + runtime.runtimeMode.equals("local", ignoreCase = true) + } + val selectedRuntime = localImportRuntimes.firstOrNull { it.id == importRuntimeId } + val runtimeSkillRows = importRuntimeSkillsRequest?.skills.orEmpty() + val selectedRuntimeSkill = runtimeSkillRows.firstOrNull { it.key == importSelectedSkillKey } + + LaunchedEffect(workspaceId, skillsMode) { + if (skillsMode != SkillSettingsMode.Import) return@LaunchedEffect + error = null + val result = withContext(Dispatchers.IO) { runCatching { api.runtimes(workspaceId) } } + result.onSuccess { runtimes -> + importRuntimes = runtimes + val local = runtimes.filter { it.runtimeMode.equals("local", ignoreCase = true) } + if (importRuntimeId.isBlank() || local.none { it.id == importRuntimeId }) { + importRuntimeId = local.firstOrNull { it.status.equals("online", ignoreCase = true) }?.id + ?: local.firstOrNull()?.id.orEmpty() + } + }.onFailure { + error = "${if (zh) "加载 Runtime 失败" else "Load runtimes failed"}: ${it.message ?: it.toString()}" + } + } + + LaunchedEffect(workspaceId, skillsMode, importRuntimeId, importRuntimes) { + if (skillsMode != SkillSettingsMode.Import) return@LaunchedEffect + importRuntimeSkillsRequest = null + importSelectedSkillKey = "" + val runtime = localImportRuntimes.firstOrNull { it.id == importRuntimeId } + if (runtime == null || !runtime.status.equals("online", ignoreCase = true)) return@LaunchedEffect + importRuntimeSkillsLoading = true + error = null + val result = withContext(Dispatchers.IO) { + runCatching { resolveRuntimeLocalSkills(api, workspaceId, runtime.id) } + } + importRuntimeSkillsLoading = false + result.onSuccess { request -> + importRuntimeSkillsRequest = request + importSelectedSkillKey = request.skills.firstOrNull()?.key.orEmpty() + }.onFailure { + error = "${if (zh) "加载本地 Skills 失败" else "Load local skills failed"}: ${it.message ?: it.toString()}" + } + } + + PilotListPage( + title = if (zh) "导入 Skill" else "Import Skill", + leading = { + PilotSecondaryBackButton( + zh = zh, + onBack = { + skillsMode = SkillSettingsMode.List + importUrl = "" + importRuntimeId = "" + importRuntimeSkillsRequest = null + importSelectedSkillKey = "" + importRuntimeRequest = null + error = null + resultText = null + }, + contentDescription = if (zh) "返回 Skills" else "Back to Skills", + ) + }, + ) { + item { SettingsSectionLabel(if (zh) "本地 Runtime" else "Runtime Local") } + item { + RuntimeLocalSkillImportPanel( + runtimes = localImportRuntimes, + selectedRuntimeId = importRuntimeId, + localSkillRequest = importRuntimeSkillsRequest, + selectedSkillKey = importSelectedSkillKey, + loadingSkills = importRuntimeSkillsLoading, + zh = zh, + onRuntimeSelected = { runtimeId -> + importRuntimeId = runtimeId + importRuntimeSkillsRequest = null + importSelectedSkillKey = "" + importRuntimeRequest = null + }, + onSkillSelected = { skill -> + importSelectedSkillKey = skill.key + importRuntimeRequest = null + }, + onImport = { + val runtimeId = importRuntimeId.trim() + val skill = selectedRuntimeSkill ?: return@RuntimeLocalSkillImportPanel + if (runtimeId.isBlank() || saving) return@RuntimeLocalSkillImportPanel + saving = true + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { resolveRuntimeLocalSkillImport(api, workspaceId, runtimeId, skill) } + } + saving = false + result.onSuccess { request -> + importRuntimeRequest = request + request.skill?.let { imported -> + upsertVisible(imported) + skillsMode = SkillSettingsMode.List + resultText = if (zh) "Runtime skill 已导入" else "Runtime skill imported" + refresh++ + } + }.onFailure { + error = "${if (zh) "Runtime 导入失败" else "Runtime import failed"}: ${it.message ?: it.toString()}" + } + } + }, + importing = saving, + ) + } + item { SettingsSectionLabel(if (zh) "远程 URL" else "Remote URL") } + item { + MulticaTextField( + value = importUrl, + onValueChange = { importUrl = it }, + label = if (zh) "导入 URL" else "Import URL", + singleLine = true, + contentDescription = "Import Skill Form URL", + ) + } + if (!error.isNullOrBlank()) item { + PilotInlineResultState( + message = error.orEmpty(), + isError = true, + contentDescription = "Skill Import Result Web Banner", + ) + } + if (!resultText.isNullOrBlank()) item { MulticaSuccessState(resultText.orEmpty()) } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { + skillsMode = SkillSettingsMode.List + importUrl = "" + importRuntimeId = "" + importRuntimeSkillsRequest = null + importSelectedSkillKey = "" + importRuntimeRequest = null + error = null + resultText = null + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (zh) "导入" else "Import", + onClick = { + val url = importUrl.trim() + if (url.isEmpty() || saving) return@MulticaPillButton + saving = true + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.importSkill(workspaceId, url) } + } + saving = false + result.onSuccess { imported -> + importUrl = "" + upsertVisible(imported) + skillsMode = SkillSettingsMode.List + resultText = if (zh) "Skill 已导入" else "Skill imported" + refresh++ + }.onFailure { + error = "${if (zh) "导入失败" else "Import failed"}: ${it.message ?: it.toString()}" + } + } + }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + ) + } + } + } + return + } + + PilotListPage( + title = if (zh) "Skills" else "Skills", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = if (zh) "新建 Skill" else "New Skill", + onClick = { + resetForm() + skillsMode = SkillSettingsMode.Create + error = null + resultText = null + }, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Skill Web Command Row /Users/park0er/coding/multica-web-source/packages/views/skills/components/skills-page.tsx /Users/park0er/coding/multica-web-source/packages/views/skills/components/skill-columns.tsx" + }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + SkillCommandTile( + title = if (zh) "新建 Skill" else "Create Skill", + subtitle = if (zh) "编写名称、描述和 SKILL.md" else "Name, description, and SKILL.md", + icon = Icons.Outlined.Add, + modifier = Modifier.weight(1f), + contentDescription = "Skill Create Entry", + onClick = { + resetForm() + skillsMode = SkillSettingsMode.Create + }, + ) + SkillCommandTile( + title = if (zh) "导入" else "Import", + subtitle = if (zh) "本地 Runtime 或远程 URL" else "Local runtime or remote URL", + icon = Icons.Outlined.Link, + modifier = Modifier.weight(1f), + contentDescription = "Skill Import Entry", + onClick = { + skillsMode = SkillSettingsMode.Import + importUrl = "" + importRuntimeRequest = null + importRuntimeSkillsRequest = null + importSelectedSkillKey = "" + error = null + resultText = null + }, + ) + } + MulticaTextField( + value = skillQuery, + onValueChange = { skillQuery = it }, + label = if (zh) "搜索 Skills" else "Search skills", + singleLine = true, + contentDescription = "Skill List Search", + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val filters = listOf("all", "manual", "imported", "with_files") + items(filters) { filter -> + SettingsWebFilterPill( + text = skillFilterLabel(filter, zh), + selected = skillFilter == filter, + onClick = { skillFilter = filter }, + contentDescription = "Skill Scope Filter ${skillFilterLabel(filter, zh)}", + ) + } + } + } + } + if (!error.isNullOrBlank()) item { + PilotInlineResultState( + message = error.orEmpty(), + isError = true, + contentDescription = "Skill List Result Web Banner", + ) + } + if (!resultText.isNullOrBlank()) item { MulticaSuccessState(resultText.orEmpty()) } + pendingDelete?.let { skill -> + item { + SkillDeleteWebAlertPanel( + skill = skill, + zh = zh, + isDeleting = saving, + onCancel = { pendingDelete = null }, + onConfirm = { + if (saving) return@SkillDeleteWebAlertPanel + saving = true + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.deleteSkill(workspaceId, skill.id) } + } + saving = false + result.onSuccess { + if (editing?.id == skill.id) resetForm() + pendingDelete = null + deletedSkillIds = deletedSkillIds + skill.id + createdSkills = createdSkills.filter { it.id != skill.id } + resultText = if (zh) "Skill 已删除" else "Skill deleted" + refresh++ + }.onFailure { + error = "${if (zh) "删除失败" else "Delete failed"}: ${it.message ?: it.toString()}" + } + } + }, + ) + } + } + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载 Skills..." else "Loading skills...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { skills -> + val visibleSkills = (createdSkills + skills.filter { skill -> + createdSkills.none { it.id == skill.id } + }).filter { it.id !in deletedSkillIds } + val filteredSkills = visibleSkills.filter { skill -> + val q = skillQuery.trim().lowercase(Locale.US) + val origin = skillOriginLabel(skill, zh).lowercase(Locale.US) + val matchesQuery = q.isBlank() || listOf(skill.name, skill.description, skill.content, origin) + .joinToString(" ") + .lowercase(Locale.US) + .contains(q) + val matchesFilter = when (skillFilter) { + "manual" -> skillOriginKey(skill) == "manual" + "imported" -> skillOriginKey(skill) != "manual" + "with_files" -> skillFileCount(skill) > 1 + else -> true + } + matchesQuery && matchesFilter + } + if (visibleSkills.isEmpty()) item { SkillsWebEmptyState(zh = zh) } + else if (filteredSkills.isEmpty()) item { + SkillsWebNoMatchesState( + zh = zh, + query = skillQuery.trim(), + filtered = skillFilter != "all", + ) + } + if (filteredSkills.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Web Table Header" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (zh) "Skill" else "Skill", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + if (zh) "来源" else "Source", + modifier = Modifier.width(104.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Text( + if (zh) "更新" else "Updated", + modifier = Modifier.width(86.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.width(42.dp)) + } + } + } + items(filteredSkills) { skill -> + SkillWebDenseRow( + skill = skill, + zh = zh, + expanded = expandedSkillActionsId == skill.id, + onExpandedChange = { expandedSkillActionsId = if (it) skill.id else null }, + onOpen = { + expandedSkillActionsId = null + selectedSkill = skill + }, + onEdit = { + expandedSkillActionsId = null + startEdit(skill) + }, + onDelete = { + expandedSkillActionsId = null + pendingDelete = skill + }, + ) + } + } + } + } + } +} + +private suspend fun resolveRuntimeLocalSkills( + api: ApiClient, + workspaceId: String, + runtimeId: String, +): Models.RuntimeLocalSkillListRequest { + var current = api.initiateListRuntimeLocalSkills(workspaceId, runtimeId) + val startedAt = System.currentTimeMillis() + while (current.status == "pending" || current.status == "running") { + if (System.currentTimeMillis() - startedAt > 30_000) { + throw IllegalStateException("runtime local skill discovery timed out") + } + delay(500) + current = api.runtimeLocalSkillListResult(workspaceId, runtimeId, current.id) + } + if (current.status == "failed" || current.status == "timeout") { + throw IllegalStateException(current.error.ifBlank { "runtime local skill discovery failed" }) + } + return current +} + +private suspend fun resolveRuntimeLocalSkillImport( + api: ApiClient, + workspaceId: String, + runtimeId: String, + skill: Models.RuntimeLocalSkillInfo, +): Models.RuntimeLocalSkillImportRequest { + var current = api.initiateImportRuntimeLocalSkill( + workspaceId, + runtimeId, + skill.key, + skill.name, + skill.description, + ) + val startedAt = System.currentTimeMillis() + while (current.status == "pending" || current.status == "running") { + if (System.currentTimeMillis() - startedAt > 30_000) { + throw IllegalStateException("runtime local skill import timed out") + } + delay(500) + current = api.runtimeLocalSkillImportResult(workspaceId, runtimeId, current.id) + } + if (current.status == "failed" || current.status == "timeout") { + throw IllegalStateException(current.error.ifBlank { "runtime local skill import failed" }) + } + if (current.skill == null) { + throw IllegalStateException("runtime local skill import did not return a skill") + } + return current +} + +@Composable +private fun RuntimeLocalSkillImportPanel( + runtimes: List, + selectedRuntimeId: String, + localSkillRequest: Models.RuntimeLocalSkillListRequest?, + selectedSkillKey: String, + loadingSkills: Boolean, + zh: Boolean, + onRuntimeSelected: (String) -> Unit, + onSkillSelected: (Models.RuntimeLocalSkillInfo) -> Unit, + onImport: () -> Unit, + importing: Boolean, +) { + val selectedRuntime = runtimes.firstOrNull { it.id == selectedRuntimeId } + val localSkills = localSkillRequest?.skills.orEmpty() + val selectedSkill = localSkills.firstOrNull { it.key == selectedSkillKey } + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Runtime Local Skill Import Panel /Users/park0er/coding/multica-web-source/packages/views/skills/components/runtime-local-skill-import-panel.tsx" + }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Surface.copy(alpha = 0.95f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.5.dp, MulticaColors.Border.copy(alpha = 0.70f)), + ) { + Column( + modifier = Modifier.padding(13.dp), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = 0.58f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "从本地 Runtime 导入" else "Import from local runtime", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + if (zh) "选择一个在线 Runtime,系统会自动读取可导入的 Skills。" else "Choose an online runtime; available skills load automatically.", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Skill Import Runtime Picker" }, + ) { + if (runtimes.isEmpty()) { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "Runtime" else "Runtime", + title = if (zh) "没有本地 Runtime" else "No local runtimes", + subtitle = if (zh) "请先启动本地 Runtime 后再导入" else "Start a local runtime before importing", + icon = Icons.Outlined.Folder, + contentDescription = "Skill Import Runtime Empty", + ) + } else { + runtimes.forEachIndexed { index, runtime -> + RuntimeDetailWebDenseRow( + eyebrow = runtime.status.ifBlank { if (zh) "状态未知" else "Unknown" }, + title = runtime.name.ifBlank { Models.shortId(runtime.id) }, + subtitle = listOf(runtime.provider, runtime.deviceInfo, Models.shortId(runtime.id)) + .filter { it.isNotBlank() } + .joinToString(" · "), + icon = if (runtime.id == selectedRuntimeId) Icons.Outlined.CheckCircle else Icons.Outlined.Folder, + contentDescription = "Skill Import Runtime Option ${runtime.id}", + showDivider = index < runtimes.lastIndex, + onClick = { onRuntimeSelected(runtime.id) }, + ) + } + } + } + selectedRuntime?.let { runtime -> + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "已选择" else "Selected", + title = runtime.name.ifBlank { Models.shortId(runtime.id) }, + subtitle = if (runtime.status.equals("online", ignoreCase = true)) { + if (zh) "在线,可以读取本地 Skills" else "Online; ready to load local skills" + } else { + if (zh) "该 Runtime 不在线,无法读取本地 Skills" else "This runtime is not online" + }, + icon = Icons.Outlined.CheckCircle, + contentDescription = "Skill Import Runtime Selected Summary ${runtime.id}", + ) + } + CompactInfoPanel( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { contentDescription = "Skill Import Runtime Local Skills" }, + ) { + when { + selectedRuntime == null -> { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "Skills" else "Skills", + title = if (zh) "请选择 Runtime" else "Choose a runtime", + subtitle = null, + icon = Icons.Outlined.Folder, + contentDescription = "Skill Import Runtime Local Skill Empty", + ) + } + !selectedRuntime.status.equals("online", ignoreCase = true) -> { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "离线" else "Offline", + title = if (zh) "Runtime 需要在线" else "Runtime must be online", + subtitle = selectedRuntime.status, + icon = Icons.Outlined.Close, + contentDescription = "Skill Import Runtime Offline", + ) + } + loadingSkills -> { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "读取中" else "Loading", + title = if (zh) "正在加载可导入 Skills..." else "Loading importable skills...", + subtitle = null, + icon = Icons.Outlined.Bolt, + contentDescription = "Skill Import Runtime Local Skill Loading", + ) + } + localSkillRequest != null && !localSkillRequest.supported -> { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "不支持" else "Unsupported", + title = if (zh) "这个 Runtime 暂不支持本地 Skill 导入" else "This runtime does not support local skill import", + subtitle = localSkillRequest.error.ifBlank { null }, + icon = Icons.Outlined.Close, + contentDescription = "Skill Import Runtime Unsupported", + ) + } + localSkills.isEmpty() -> { + RuntimeDetailWebDenseRow( + eyebrow = if (zh) "Skills" else "Skills", + title = if (zh) "没有可导入的 Skill" else "No importable skills", + subtitle = if (zh) "Runtime 返回了空列表" else "The runtime returned an empty list", + icon = Icons.Outlined.Folder, + contentDescription = "Skill Import Runtime Local Skill Empty", + ) + } + else -> { + localSkills.forEachIndexed { index, skill -> + RuntimeDetailWebDenseRow( + eyebrow = if (skill.key == selectedSkillKey) { + if (zh) "已选择" else "Selected" + } else { + skill.provider.ifBlank { if (zh) "本地" else "Local" } + }, + title = skill.name.ifBlank { skill.key }, + subtitle = skill.description.ifBlank { skill.path.ifBlank { skill.key } }, + icon = if (skill.key == selectedSkillKey) Icons.Outlined.CheckCircle else Icons.Outlined.Link, + contentDescription = "Skill Import Runtime Local Skill Row ${skill.key}", + showDivider = index < localSkills.lastIndex, + onClick = { onSkillSelected(skill) }, + ) + } + } + } + } + MulticaPillButton( + text = if (importing) "..." else if (zh) "导入选中的 Skill" else "Import selected skill", + onClick = onImport, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Primary, + enabled = selectedRuntime?.status.equals("online", ignoreCase = true) && selectedSkill != null && !importing, + contentDescription = "Skill Import Runtime Action", + ) + } + } +} + +@Composable +private fun SkillCommandTile( + title: String, + subtitle: String, + icon: ImageVector, + modifier: Modifier = Modifier, + contentDescription: String, + onClick: () -> Unit, +) { + Surface( + modifier = modifier + .semantics { this.contentDescription = contentDescription } + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + color = MulticaColors.AccentSoft.copy(alpha = 0.46f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Accent.copy(alpha = 0.18f), RoundedCornerShape(8.dp)) + .padding(horizontal = 11.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SkillDeleteWebAlertPanel( + skill: Models.Skill, + zh: Boolean, + isDeleting: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Delete Web Alert Panel ${skill.name}" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + if (zh) "删除 Skill?" else "Delete skill?", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + skill.name, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + if (zh) "删除后,使用该 Skill 的 Agent 可能需要重新配置。" else "Agents using this skill may need to be reconfigured after deletion.", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.08f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Danger, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Secondary, + ) + MulticaPillButton( + text = if (isDeleting) "..." else if (zh) "删除" else "Delete", + onClick = onConfirm, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun SkillsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SKILLS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/skills/components/skills-page.tsx" + val WEB_SSOT_SKILLS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/skills.json" + Box( + modifier = modifier.semantics { + contentDescription = "Skills Web Empty State $WEB_SSOT_SKILLS_PAGE $WEB_SSOT_SKILLS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有 skill" else "No skills yet", + description = if (zh) { + "创建第一个 skill、从 URL 导入、或从已连接的运行时复制——之后工作区里每个智能体都能用它。" + } else { + "Create your first skill, import one from a URL, or copy one from a connected runtime — and every agent in the workspace can use it." + }, + ) + } +} + +@Composable +private fun SkillsWebNoMatchesState( + zh: Boolean, + query: String = "", + filtered: Boolean = true, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SKILLS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/skills/components/skills-page.tsx" + val WEB_SSOT_SKILLS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/skills.json" + val filterOnlyDescription = if (zh) "当前筛选下没有匹配的 skill。 换个关键词试试。" else "No skills match this filter. Try a different query." + val description = when { + query.isNotBlank() && zh -> "没有 skill 匹配 \"$query\"${if (filtered) "(在当前筛选下)" else ""}。 换个关键词试试。" + query.isNotBlank() -> "No skills match \"$query\"${if (filtered) " in this filter" else ""}. Try a different query." + else -> filterOnlyDescription + } + Box( + modifier = modifier.semantics { + contentDescription = "Skills Web No Matches State $WEB_SSOT_SKILLS_PAGE $WEB_SSOT_SKILLS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "无匹配" else "No matches", + description = description, + ) + } +} + +@Composable +private fun SkillWebDenseRow( + skill: Models.Skill, + zh: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onOpen: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Web Dense Row ${skill.name}" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .clickable(onClick = onOpen) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(17.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + skill.name, + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${skillFileCount(skill)}", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + ) + } + Text( + clean(skill.description).ifBlank { if (zh) "暂无描述" else "No description" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + skillOriginLabel(skill, zh), + modifier = Modifier + .width(104.dp) + .semantics { contentDescription = "Skill Source ${skillOriginLabel(skill, zh)}" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = if (skillOriginKey(skill) == "manual") MulticaColors.TextTertiary else MulticaColors.Accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + Column( + modifier = Modifier.width(86.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = if (skillCanEdit(skill)) if (zh) "可编辑" else "Editable" else if (zh) "只读" else "Read-only", + modifier = Modifier.semantics { contentDescription = "Skill Editable State ${skill.name}" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = if (skillCanEdit(skill)) MulticaColors.Accent else MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + Text( + text = skillUpdatedLabel(skill, zh), + modifier = Modifier.semantics { contentDescription = "Skill Updated ${skill.name}" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Skill Actions ${skill.name}", + onClick = { onExpandedChange(true) }, + tone = if (expanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(36.dp) + .semantics { contentDescription = "Skill Actions ${skill.name}" }, + ) + MulticaCupertinoActionSheet( + visible = expanded, + title = if (zh) "Skill 操作" else "Skill actions", + message = skill.name, + items = listOf( + MulticaCupertinoActionSheetItem( + title = if (zh) "打开" else "Open", + icon = Icons.Outlined.KeyboardArrowRight, + contentDescription = "Skill Open ${skill.name}", + onClick = onOpen, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "编辑" else "Edit", + icon = Icons.Outlined.Edit, + contentDescription = "Skill Edit ${skill.name}", + onClick = onEdit, + ), + MulticaCupertinoActionSheetItem( + title = if (zh) "删除" else "Delete", + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "Skill Delete ${skill.name}", + onClick = onDelete, + ), + ), + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { onExpandedChange(false) }, + ) + } + } +} + +private fun skillFilterLabel(filter: String, zh: Boolean): String = when (filter) { + "manual" -> if (zh) "手动" else "Manual" + "imported" -> if (zh) "导入" else "Imported" + "with_files" -> if (zh) "多文件" else "Files" + else -> if (zh) "全部" else "All" +} + +private fun skillOriginKey(skill: Models.Skill): String { + val origin = clean(skill.originType).lowercase(Locale.US) + val url = clean(skill.originSourceUrl) + return when { + origin.isNotBlank() && origin != "manual" -> origin + url.isNotBlank() -> "imported" + else -> "manual" + } +} + +private fun skillOriginLabel(skill: Models.Skill, zh: Boolean): String = when (skillOriginKey(skill)) { + "runtime_local" -> if (zh) "Runtime" else "Runtime" + "clawhub" -> "Clawhub" + "skills_sh" -> "skills.sh" + "github" -> "GitHub" + "imported" -> if (zh) "导入" else "Imported" + else -> if (zh) "手动" else "Manual" +} + +private fun skillCanEdit(skill: Models.Skill): Boolean { + return skillOriginKey(skill) == "manual" || skill.originSourceUrl.isBlank() +} + +private fun skillUpdatedLabel(skill: Models.Skill, zh: Boolean): String { + val value = shortDate(skill.updatedAt.ifBlank { skill.createdAt }) + return value.ifBlank { if (zh) "未知" else "Unknown" } +} + +private fun skillFileCount(skill: Models.Skill): Int { + val base = if (clean(skill.content).isBlank()) 0 else 1 + return (base + skill.files.size).coerceAtLeast(1) +} + +private fun validateSkillFilePath(path: String, existingPaths: List): String? { + val p = path.trim() + return when { + p.isBlank() -> "Path is required" + p.startsWith("/") -> "Use a relative path" + p.split("/").any { it == ".." } -> "Path cannot contain .." + p == "SKILL.md" -> "SKILL.md is edited above" + existingPaths.any { it == p } -> "File already exists" + else -> null + } +} + +@Composable +private fun PilotSkillDetail( + api: ApiClient, + workspaceId: String, + skill: Models.Skill, + zh: Boolean, + onBack: () -> Unit, + onEdit: (Models.Skill) -> Unit, +) { + var state by remember(workspaceId, skill.id) { mutableStateOf?>(null) } + var addFileOpen by remember(workspaceId, skill.id) { mutableStateOf(false) } + var filePath by remember(workspaceId, skill.id) { mutableStateOf("") } + var fileContent by remember(workspaceId, skill.id) { mutableStateOf("") } + var fileSaving by remember(workspaceId, skill.id) { mutableStateOf(false) } + var fileError by remember(workspaceId, skill.id) { mutableStateOf(null) } + var selectedFilePath by remember(workspaceId, skill.id) { mutableStateOf("SKILL.md") } + var viewerWrap by remember(workspaceId, skill.id) { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + LaunchedEffect(workspaceId, skill.id) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + val detail = api.skill(workspaceId, skill.id) + val files = runCatching { api.skillFiles(workspaceId, skill.id) } + .getOrElse { detail.files } + .ifEmpty { detail.files } + PilotSkillDetailData(detail, files) + } + } + } + + PilotListPage( + title = if (zh) "Skill 详情" else "Skill Detail", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MulticaPillButton( + text = if (zh) "编辑" else "Edit", + onClick = { onEdit(state?.getOrNull()?.skill ?: skill) }, + tone = MulticaButtonTone.Secondary, + ) + } + }, + ) { + when (val loaded = state) { + null -> item { MulticaLoadingState(if (zh) "正在加载 Skill..." else "Loading skill...") } + else -> { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { state = null }) } + }.onSuccess { data -> + val displayFiles = skillDisplayFilesSorted(data.skill, data.files) + val selectedFile = displayFiles.firstOrNull { it.path == selectedFilePath } + ?: displayFiles.firstOrNull() + item { + SkillDetailWebHero( + skill = data.skill, + fileCount = data.files.size, + originLabel = skillOriginLabel(data.skill, zh), + zh = zh, + ) + } + item { + SkillDetailMobileFileBrowser( + files = displayFiles, + selectedPath = selectedFile?.path.orEmpty(), + wrap = viewerWrap, + zh = zh, + onSelect = { selectedFilePath = it }, + onWrapChange = { viewerWrap = it }, + ) + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail Web Add File Row" }, + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "文件" else "Files", + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + if (zh) "使用相对路径补充脚本、提示词或配置文件" else "Add scripts, prompts, or config with relative paths", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaPillButton( + text = if (addFileOpen) if (zh) "取消" else "Cancel" else if (zh) "添加文件" else "Add file", + onClick = { + addFileOpen = !addFileOpen + fileError = null + }, + tone = if (addFileOpen) MulticaButtonTone.Ghost else MulticaButtonTone.Secondary, + icon = Icons.Outlined.Add, + contentDescription = "Skill Detail Add File Toggle", + ) + } + if (addFileOpen) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = filePath, + onValueChange = { + filePath = it + fileError = null + }, + label = if (zh) "路径" else "Path", + singleLine = true, + contentDescription = "Skill Detail Add File Path", + ) + MulticaTextField( + value = fileContent, + onValueChange = { fileContent = it }, + label = if (zh) "内容" else "Content", + minLines = 4, + maxLines = 8, + contentDescription = "Skill Detail Add File Content", + ) + if (!fileError.isNullOrBlank()) { + Text( + fileError.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MulticaColors.Danger, + ) + } + MulticaPillButton( + text = if (fileSaving) "..." else if (zh) "保存文件" else "Save file", + onClick = { + val nextPath = filePath.trim() + val nextContent = fileContent + val validation = validateSkillFilePath(nextPath, data.files.map { it.path }) + if (validation != null) { + fileError = validation + return@MulticaPillButton + } + if (fileSaving) return@MulticaPillButton + fileSaving = true + fileError = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.upsertSkillFile(workspaceId, data.skill.id, nextPath, nextContent) } + } + fileSaving = false + result.onSuccess { saved -> + val nextFiles = (data.files.filter { it.path != saved.path } + saved) + .sortedBy { it.path.lowercase(Locale.US) } + state = Result.success(data.copy(files = nextFiles)) + filePath = "" + fileContent = "" + addFileOpen = false + }.onFailure { + fileError = "${if (zh) "保存失败" else "Save failed"}: ${it.message ?: it.toString()}" + } + } + }, + modifier = Modifier.fillMaxWidth(), + tone = MulticaButtonTone.Primary, + contentDescription = "Skill Detail Add File Button", + ) + } + } + } + } + } + } + } + } + } +} + +private data class SkillDisplayFile( + val path: String, + val content: String, + val isDirectory: Boolean, +) + +@Composable +private fun SkillDetailMobileFileBrowser( + files: List, + selectedPath: String, + wrap: Boolean, + zh: Boolean, + onSelect: (String) -> Unit, + onWrapChange: (Boolean) -> Unit, +) { + val selectedFile = files.firstOrNull { it.path == selectedPath } ?: files.firstOrNull { !it.isDirectory } + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Skill Detail Mobile File Browser /Users/park0er/coding/multica-web-source/packages/views/skills/components/file-tree.tsx /Users/park0er/coding/multica-web-source/packages/views/skills/components/file-viewer.tsx" + }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Surface.copy(alpha = 0.95f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.5.dp, MulticaColors.Border.copy(alpha = 0.70f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "文件树" else "File tree", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + if (zh) "SKILL.md 优先,目录排在文件前" else "SKILL.md first; directories before files", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted, + ) + } + MulticaPillButton( + text = if (wrap) if (zh) "换行" else "Wrap" else if (zh) "横滚" else "Scroll", + onClick = { onWrapChange(!wrap) }, + tone = MulticaButtonTone.Secondary, + contentDescription = "Skill Detail Viewer Wrap Toggle", + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail File Tree Panel" }, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + files.forEach { file -> + SkillDetailFileTreeRow( + file = file, + selected = file.path == selectedFile?.path, + zh = zh, + onClick = { if (!file.isDirectory) onSelect(file.path) }, + ) + } + } + SkillDetailFileViewerPanel( + file = selectedFile, + wrap = wrap, + zh = zh, + ) + } + } +} + +@Composable +private fun SkillDetailFileTreeRow( + file: SkillDisplayFile, + selected: Boolean, + zh: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) MulticaColors.AccentSoft.copy(alpha = 0.60f) else Color.Transparent) + .clickable(enabled = !file.isDirectory, onClick = onClick) + .padding(horizontal = 9.dp, vertical = 8.dp) + .semantics { contentDescription = "Skill Detail File Tree Row ${file.path}" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (file.isDirectory) Icons.Outlined.Folder else Icons.Outlined.Link, + contentDescription = null, + tint = if (file.isDirectory) MulticaColors.TextTertiary else MulticaColors.Accent, + modifier = Modifier.size(16.dp), + ) + Text( + file.path, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp, lineHeight = 15.sp, fontFamily = FontFamily.Monospace, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal), + color = if (file.isDirectory) MulticaColors.Muted else MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (file.isDirectory) if (zh) "目录" else "dir" else if (SkillFileTree.isMarkdownPath(file.path)) "md" else "file", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + } +} + +@Composable +private fun SkillDetailFileViewerPanel( + file: SkillDisplayFile?, + wrap: Boolean, + zh: Boolean, +) { + val content = clean(file?.content).ifBlank { if (zh) "空文件" else "Empty file" } + val isMarkdown = file?.path?.let { SkillFileTree.isMarkdownPath(it) } == true + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail File Viewer Panel ${file?.path.orEmpty()}" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = androidx.compose.foundation.BorderStroke(0.5.dp, MulticaColors.Border.copy(alpha = 0.62f)), + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + file?.path ?: "SKILL.md", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 14.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isMarkdown) { + Box( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail Viewer Markdown ${file?.path.orEmpty()}" }, + ) { + MarkdownBlock(content) + } + } else { + Text( + content, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail Viewer Monospace ${file?.path.orEmpty()}" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.Text, + softWrap = wrap, + overflow = if (wrap) TextOverflow.Ellipsis else TextOverflow.Clip, + ) + } + } + } +} + +private fun skillDisplayFilesSorted(skill: Models.Skill, files: List): List { + val byPath = LinkedHashMap() + byPath["SKILL.md"] = SkillDisplayFile("SKILL.md", skill.content, isDirectory = false) + val tree = SkillFileTree.build(files) + SkillFileTree.flattenDisplayPaths(tree).forEach { path -> + val file = files.firstOrNull { it.path == path } + byPath[path] = SkillDisplayFile(path, file?.content.orEmpty(), isDirectory = path.endsWith("/")) + } + return byPath.values.sortedWith( + compareBy { + skillDisplayFileSortBucket(path = it.path, isDirectory = it.isDirectory) + }.thenBy { it.path.lowercase(Locale.US) } + ) +} + +private fun skillDisplayFileSortBucket(path: String, isDirectory: Boolean): Int = when { + path == "SKILL.md" -> 0 + isDirectory -> 1 + else -> 2 +} + +@Composable +private fun PilotSkillsSettingsPreview() { + val skills = remember { + listOf( + Models.Skill(JSONObject() + .put("id", "preview-skill-ios") + .put("name", "ios-compose-polish") + .put("description", "Cupertino density, Haze glass bars, and file viewer guidance.") + .put("content", "# iOS Compose Polish\n\nUse compact rows, spring feedback, and readable code surfaces.") + .put("updated_at", "2026-05-14T13:20:00Z") + .put("files", org.json.JSONArray() + .put(JSONObject().put("id", "f1").put("path", "scripts/audit.sh").put("content", "rg \"OutlinedTextField\" app/src/main/java")) + .put(JSONObject().put("id", "f2").put("path", "docs/checklist.md").put("content", "## Checklist\n- Web SSOT\n- Emulator evidence")))), + Models.Skill(JSONObject() + .put("id", "preview-skill-runtime") + .put("name", "runtime-imported-skill") + .put("description", "Imported from a local runtime skill store.") + .put("content", "# Runtime Skill\n\nKeep source metadata visible.") + .put("updated_at", "2026-05-13T09:00:00Z") + .put("config", JSONObject().put("origin", JSONObject().put("type", "runtime_local").put("source_url", "/Users/park0er/.multica/skills/runtime-imported-skill")))), + ) + } + var selectedSkill by remember { mutableStateOf(null) } + var expandedSkillActionsId by remember { mutableStateOf(null) } + + selectedSkill?.let { skill -> + PilotSkillDetailPreview( + skill = skill, + onBack = { selectedSkill = null }, + ) + return + } + + PilotListPage( + title = "Skills", + leading = { PilotSecondaryBackButton(zh = false, onBack = {}) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = "New Skill Preview", + onClick = {}, + tone = MulticaButtonTone.Primary, + ) + }, + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Web Command Row Preview" }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + RuntimeLocalSkillImportPanel( + runtimes = listOf( + Models.Runtime( + JSONObject() + .put("id", "runtime-local-preview") + .put("name", "MacBook Local Runtime") + .put("status", "online") + .put("runtime_mode", "local") + .put("provider", "local") + ) + ), + selectedRuntimeId = "runtime-local-preview", + localSkillRequest = Models.RuntimeLocalSkillListRequest( + JSONObject() + .put("id", "preview-list") + .put("runtime_id", "runtime-local-preview") + .put("status", "completed") + .put("supported", true) + .put( + "skills", + org.json.JSONArray() + .put( + JSONObject() + .put("key", "ios-compose-polish") + .put("name", "ios-compose-polish") + .put("description", "Preview runtime import row") + .put("path", "/Users/park0er/.multica/skills/ios-compose-polish") + ) + ) + ), + selectedSkillKey = "ios-compose-polish", + loadingSkills = false, + zh = false, + onRuntimeSelected = {}, + onSkillSelected = {}, + onImport = {}, + importing = false, + ) + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Web Table Header Preview" } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Skill", modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.TextTertiary) + Text("Source", modifier = Modifier.width(104.dp), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.TextTertiary, textAlign = TextAlign.Center) + Text("Updated", modifier = Modifier.width(86.dp), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.TextTertiary, textAlign = TextAlign.Center) + Spacer(Modifier.width(42.dp)) + } + } + items(skills) { skill -> + SkillWebDenseRow( + skill = skill, + zh = false, + expanded = expandedSkillActionsId == skill.id, + onExpandedChange = { expandedSkillActionsId = if (it) skill.id else null }, + onOpen = { + expandedSkillActionsId = null + selectedSkill = skill + }, + onEdit = { expandedSkillActionsId = null }, + onDelete = { expandedSkillActionsId = null }, + ) + } + } +} + +@Composable +private fun PilotSkillDetailPreview(skill: Models.Skill, onBack: () -> Unit) { + var selectedPath by remember(skill.id) { mutableStateOf("SKILL.md") } + var wrap by remember(skill.id) { mutableStateOf(true) } + val files = remember(skill.id) { skillDisplayFilesSorted(skill, skill.files) } + + PilotListPage( + title = "Skill Detail", + leading = { PilotSecondaryBackButton(zh = false, onBack = onBack) }, + ) { + item { + SkillDetailWebHero( + skill = skill, + fileCount = skill.files.size, + originLabel = skillOriginLabel(skill, false), + zh = false, + ) + } + item { + SkillDetailMobileFileBrowser( + files = files, + selectedPath = selectedPath, + wrap = wrap, + zh = false, + onSelect = { selectedPath = it }, + onWrapChange = { wrap = it }, + ) + } + } +} + +@Composable +private fun SkillDetailWebEditorSurface( + content: String, + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SKILL_FILE_VIEWER = "/Users/park0er/coding/multica-web-source/packages/views/skills/components/file-viewer.tsx" + val WEB_SSOT_SKILLS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/skills.json" + Surface( + modifier = modifier + .fillMaxWidth() + .semantics { + contentDescription = "Skill Detail Web Editor Surface $WEB_SSOT_SKILL_FILE_VIEWER $WEB_SSOT_SKILLS_LOCALE" + }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + MarkdownBlock(clean(content).ifBlank { if (zh) "*暂无内容*" else "*No content yet*" }) + } + } +} + +@Composable +private fun SkillDetailSupportingFilesWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_SKILL_FILE_TREE = "/Users/park0er/coding/multica-web-source/packages/views/skills/components/file-tree.tsx" + val WEB_SSOT_SKILLS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/skills.json" + Box( + modifier = modifier.semantics { + contentDescription = "Skill Detail Supporting Files Web Empty State $WEB_SSOT_SKILL_FILE_TREE $WEB_SSOT_SKILLS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "无文件" else "No files", + description = if (zh) "文件" else "Files", + ) + } +} + +@Composable +private fun SkillDetailWebHero( + skill: Models.Skill, + fileCount: Int, + originLabel: String, + zh: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail Web Hero ${skill.name}" }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 11.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = 0.58f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + skill.name, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + clean(skill.description).ifBlank { if (zh) "暂无描述" else "No description" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail Web Origin Row ${skill.name}" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SkillDetailWebFact( + label = if (zh) "来源" else "Origin", + value = originLabel, + modifier = Modifier.weight(1f), + ) + SkillDetailWebFact( + label = if (zh) "文件" else "Files", + value = (fileCount + if (clean(skill.content).isBlank()) 0 else 1).coerceAtLeast(1).toString(), + modifier = Modifier.weight(1f), + ) + SkillDetailWebFact( + label = "ID", + value = Models.shortId(skill.id), + modifier = Modifier.weight(1f), + ) + } + if (skill.originSourceUrl.isNotBlank()) { + Text( + text = skill.originSourceUrl, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Background) + .padding(horizontal = 10.dp, vertical = 7.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 14.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SkillDetailWebFact( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Background) + .padding(horizontal = 9.dp, vertical = 7.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + value, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SkillFileWebDenseRow( + path: String, + file: Models.SkillFile?, + zh: Boolean, +) { + val isDirectory = path.endsWith("/") + val content = clean(file?.content).ifBlank { if (zh) "空文件" else "Empty file" } + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Skill Detail Web File Row $path" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isDirectory) Icons.Outlined.Folder else Icons.Outlined.Link, + contentDescription = null, + tint = if (isDirectory) MulticaColors.Muted else MulticaColors.Accent, + modifier = Modifier.size(17.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + path, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace), + color = if (isDirectory) MulticaColors.Muted else MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!isDirectory) { + Text( + content.lineSequence().firstOrNull().orEmpty().ifBlank { content }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp, fontFamily = FontFamily.Monospace), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + if (isDirectory) if (zh) "目录" else "dir" else if (SkillFileTree.isMarkdownPath(path)) "md" else "file", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + maxLines = 1, + ) + } + } +} + +@Composable +private fun SkillFilePreview( + path: String, + file: Models.SkillFile?, + zh: Boolean, +) { + val isDirectory = path.endsWith("/") + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (isDirectory) path else path, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = if (isDirectory) MulticaColors.Muted else MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!isDirectory && file != null) { + val content = clean(file.content).ifBlank { if (zh) "空文件" else "Empty file" } + if (SkillFileTree.isMarkdownPath(path)) { + MarkdownBlock(content) + } else { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MulticaColors.Muted, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun PilotFeedbackSettings(api: ApiClient, workspaceId: String, zh: Boolean, onBack: () -> Unit) { + PilotFeedbackSettingsContent( + zh = zh, + onBack = onBack, + initialTitle = "", + initialType = "bug", + initialMessage = "", + initialContext = "", + initialSubmitAttempted = false, + onSubmitFeedback = { payload, context -> + withContext(Dispatchers.IO) { + api.createFeedback(workspaceId, payload, context) + } + }, + ) +} + +@Composable +private fun PilotFeedbackSettingsPreview(showInlineError: Boolean = false) { + PilotFeedbackSettingsContent( + zh = false, + onBack = {}, + initialTitle = if (showInlineError) "" else "Polish the Settings feedback flow", + initialType = "design", + initialMessage = "The form should feel like a compact iOS modal: clear hierarchy, a calm text area, and visible submit feedback.", + initialContext = "preview://settings/feedback", + initialSubmitAttempted = showInlineError, + onSubmitFeedback = { _, _ -> delay(120) }, + ) +} + +@Composable +private fun PilotFeedbackSettingsContent( + zh: Boolean, + onBack: () -> Unit, + initialTitle: String, + initialType: String, + initialMessage: String, + initialContext: String, + initialSubmitAttempted: Boolean, + onSubmitFeedback: suspend (payload: String, context: String) -> Unit, +) { + val WEB_SSOT_FEEDBACK_MODAL = "/Users/park0er/coding/multica-web-source/packages/views/modals/feedback.tsx" + var feedbackTitle by remember { mutableStateOf(initialTitle) } + var feedbackType by remember { mutableStateOf(initialType) } + var message by remember { mutableStateOf(initialMessage) } + var context by remember { mutableStateOf(initialContext) } + var typePickerVisible by remember { mutableStateOf(false) } + var submitAttempted by remember { mutableStateOf(initialSubmitAttempted) } + var saving by remember { mutableStateOf(false) } + var resultText by remember { mutableStateOf(null) } + var resultIsError by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + val maxFeedbackLength = 10000 + val validationError = feedbackValidationError(feedbackTitle, message, maxFeedbackLength, zh) + val canSubmit = validationError == null && !saving + val selectedType = feedbackTypeOptions(zh).firstOrNull { it.key == feedbackType } + ?: feedbackTypeOptions(zh).first() + + PilotListPage( + title = if (zh) "Feedback" else "Feedback", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + bottomContent = { + Surface( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .semantics { contentDescription = "Feedback Web Bottom Bar" }, + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.45f), RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${message.length}/$maxFeedbackLength", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = if (message.length > maxFeedbackLength) MulticaColors.Danger else MulticaColors.TextTertiary, + ) + MulticaPillButton( + text = if (saving) "..." else if (zh) "发送" else "Send", + onClick = { + submitAttempted = true + val currentError = feedbackValidationError(feedbackTitle, message, maxFeedbackLength, zh) + if (currentError != null || saving) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + resultText = null + return@MulticaPillButton + } + saving = true + resultText = null + resultIsError = false + val payload = feedbackPayloadMarkdown( + type = selectedType.label, + title = feedbackTitle, + message = message, + context = context, + ) + val contextValue = context.trim() + scope.launch { + val result = runCatching { onSubmitFeedback(payload, contextValue) } + saving = false + result.onSuccess { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + feedbackTitle = "" + message = "" + context = "" + submitAttempted = false + resultIsError = false + resultText = if (zh) "反馈已提交" else "Feedback submitted" + }.onFailure { + resultIsError = true + resultText = "${if (zh) "提交失败" else "Submit failed"}: ${it.message ?: it.toString()}" + } + } + }, + tone = if (canSubmit) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + contentDescription = "Feedback Submit Bottom", + enabled = !saving, + ) + } + } + }, + ) { + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Feedback Web Intro" }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (zh) "告诉我们哪里卡住了,或者把你想要的体验直接写下来。" else "Tell us what feels rough, blocked, or missing.", + style = MaterialTheme.typography.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + ) + Text( + if (zh) "Web 端使用富文本编辑器和附件;Android 当前先保持 Markdown 文本和当前 URL 上下文。" else "Web uses a rich editor and attachments; Android keeps Markdown text plus URL context for now.", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + ) + } + } + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Feedback Web Short Form $WEB_SSOT_FEEDBACK_MODAL" + }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaTextField( + value = feedbackTitle, + onValueChange = { + feedbackTitle = it + resultText = null + }, + modifier = Modifier.fillMaxWidth(), + label = if (zh) "标题" else "Title", + singleLine = true, + contentDescription = "Feedback Title Field", + ) + FeedbackTypePickerRow( + selectedType = selectedType, + zh = zh, + onOpen = { typePickerVisible = true }, + ) + MulticaTextField( + value = message, + onValueChange = { + message = it + resultText = null + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Feedback Web Editor Feedback Message Cupertino Text Area" }, + label = if (zh) "反馈内容" else "Message", + minLines = 7, + maxLines = 12, + contentDescription = "Feedback Message", + ) + MulticaTextField( + value = context, + onValueChange = { + context = it + resultText = null + }, + modifier = Modifier.fillMaxWidth(), + label = if (zh) "上下文 / URL" else "Context / URL", + minLines = 2, + maxLines = 3, + contentDescription = "Feedback Context Field", + ) + } + } + if (submitAttempted && validationError != null) { + item { + PilotInlineResultState( + message = validationError, + isError = true, + contentDescription = "Feedback Inline Error", + ) + } + } + if (!resultText.isNullOrBlank()) { + item { + if (resultIsError) { + PilotInlineResultState( + message = resultText.orEmpty(), + isError = true, + contentDescription = "Feedback Result Web Banner Feedback Inline Error", + ) + } else { + Box(modifier = Modifier.semantics { contentDescription = "Feedback Success Motion" }) { + MulticaSuccessState(resultText.orEmpty()) + } + } + } + } + } + MulticaCupertinoActionSheet( + visible = typePickerVisible, + title = if (zh) "反馈类型" else "Feedback type", + message = if (zh) "选择最接近这条反馈的类别。" else "Choose the category closest to this note.", + items = feedbackTypeOptions(zh).map { option -> + MulticaCupertinoActionSheetItem( + title = option.label, + subtitle = option.detail, + icon = option.icon, + selected = option.key == feedbackType, + contentDescription = "Feedback Type Action Sheet ${option.label}", + onClick = { + feedbackType = option.key + resultText = null + }, + ) + }, + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = { typePickerVisible = false }, + ) +} + +@Composable +private fun FeedbackTypePickerRow( + selectedType: FeedbackTypeOption, + zh: Boolean, + onOpen: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onOpen() + } + .semantics(mergeDescendants = true) { contentDescription = "Feedback Type Cupertino Picker" }, + shape = RoundedCornerShape(10.dp), + color = MulticaColors.SurfaceElevated, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.72f), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(selectedType.icon, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + if (zh) "类型" else "Type", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + Text( + selectedType.label, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon(Icons.Outlined.KeyboardArrowDown, contentDescription = null, tint = MulticaColors.TextTertiary, modifier = Modifier.size(18.dp)) + } + } +} + +private data class FeedbackTypeOption( + val key: String, + val label: String, + val detail: String, + val icon: ImageVector, +) + +private fun feedbackTypeOptions(zh: Boolean): List = listOf( + FeedbackTypeOption( + key = "bug", + label = if (zh) "问题" else "Bug report", + detail = if (zh) "有东西不工作或结果不对。" else "Something is broken or incorrect.", + icon = Icons.Outlined.Flag, + ), + FeedbackTypeOption( + key = "feature", + label = if (zh) "功能建议" else "Feature request", + detail = if (zh) "一个希望 Multica 支持的新能力。" else "A capability you want Multica to support.", + icon = Icons.Outlined.Bolt, + ), + FeedbackTypeOption( + key = "design", + label = if (zh) "体验审美" else "Design feedback", + detail = if (zh) "信息层级、动效、视觉或手感问题。" else "Hierarchy, motion, visuals, or interaction feel.", + icon = Icons.Outlined.Tune, + ), + FeedbackTypeOption( + key = "other", + label = if (zh) "其他" else "Other", + detail = if (zh) "不属于上面几类的反馈。" else "Anything that does not fit the other categories.", + icon = Icons.Outlined.MoreHoriz, + ), +) + +private fun feedbackValidationError(title: String, message: String, maxFeedbackLength: Int, zh: Boolean): String? = when { + title.trim().isEmpty() -> if (zh) "请填写标题。" else "Title is required." + message.trim().isEmpty() -> if (zh) "请填写反馈内容。" else "Message is required." + message.length > maxFeedbackLength -> if (zh) "反馈内容不能超过 $maxFeedbackLength 个字符。" else "Message must be $maxFeedbackLength characters or fewer." + else -> null +} + +private fun feedbackPayloadMarkdown( + type: String, + title: String, + message: String, + context: String, +): String { + val cleanTitle = title.trim() + val cleanMessage = message.trim() + val cleanContext = context.trim() + return buildString { + appendLine("# $cleanTitle") + appendLine() + appendLine("- Type: $type") + if (cleanContext.isNotEmpty()) { + appendLine("- Context: $cleanContext") + } + appendLine() + append(cleanMessage) + } +} + +@Composable +private fun AgentsOverviewPanel(data: PilotAgentSettingsData, zh: Boolean) { + val total = data.agents.size + val archived = data.agents.count { !it.archivedAt.isNullOrBlank() } + val active = total - archived + val activeTasks = data.taskSnapshot.count { it.status in setOf("queued", "dispatched", "running") } + val runs30d = data.runCounts.sumOf { it.runCount } + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(top = 2.dp, bottom = 8.dp) + .semantics { contentDescription = "Agents Overview Compact Strip" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.66f), RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { AgentOverviewMetric(if (zh) "可用" else "Active", active.toString()) } + item { AgentOverviewMetric(if (zh) "已归档" else "Archived", archived.toString()) } + item { AgentOverviewMetric(if (zh) "活跃任务" else "Active tasks", activeTasks.toString()) } + item { AgentOverviewMetric(if (zh) "30 天运行" else "Runs 30d", runs30d.toString()) } + } + } +} + +@Composable +private fun AgentOverviewMetric(label: String, value: String) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Background, + modifier = Modifier + .border(1.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(999.dp)) + .semantics { contentDescription = "Agent Overview Compact Metric $label" }, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = value, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 14.sp), + color = MulticaColors.Text, + maxLines = 1, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + ) + } + } +} + +private enum class AgentSettingsMode { List, Create, Edit } + +private data class AgentFormMenuOption( + val label: String, + val detail: String?, + val selected: Boolean, + val onClick: () -> Unit, +) + +private data class AgentFormModelOption( + val value: String, + val label: String, + val detail: String?, +) + +@Composable +private fun AgentFormWebHero( + title: String, + subtitle: String, + editing: Boolean, + zh: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Agent Form Web Hero" }, + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.74f), RoundedCornerShape(12.dp)) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(MulticaColors.Accent.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MulticaColors.Accent, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (editing) if (zh) "编辑" else "Edit" else if (zh) "新建" else "Create", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + maxLines = 1, + ) + } + Text( + text = subtitle, + modifier = Modifier.semantics { contentDescription = "Agent Form Web Subtitle" }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AgentFormWebMenuRow( + eyebrow: String, + title: String, + subtitle: String?, + contentDescription: String, + options: List, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val haptic = LocalHapticFeedback.current + val shape = RoundedCornerShape(10.dp) + Box(modifier = modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + expanded = true + } + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + }, + shape = shape, + color = MulticaColors.SurfaceElevated, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .border(1.dp, MulticaColors.Border, shape) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + } + } + MulticaCupertinoActionSheet( + visible = expanded, + title = eyebrow, + message = subtitle, + items = options.map { option -> + MulticaCupertinoActionSheetItem( + title = option.label, + subtitle = option.detail, + selected = option.selected, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + option.onClick() + }, + ) + }, + cancelText = "Cancel", + onDismissRequest = { expanded = false }, + ) + } +} + +@Composable +private fun AgentDetailWebSectionLabel(title: String, contentDescription: String) { + Text( + text = title.uppercase(Locale.getDefault()), + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 11.sp, + letterSpacing = 0.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextTertiary, + modifier = Modifier + .padding(top = 18.dp, bottom = 6.dp, start = 16.dp) + .semantics { this.contentDescription = contentDescription }, + ) +} + +@Composable +private fun AgentDetailActivityWebSection( + agent: Models.Agent, + data: PilotAgentSettingsData, + zh: Boolean, + contentDescription: String = "Agent Detail Web Activity Section", +) { + val WEB_SSOT_AGENT_OVERVIEW_PANE = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/agent-overview-pane.tsx" + val WEB_SSOT_AGENT_ACTIVITY_TAB = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/tabs/activity-tab.tsx" + val recentTask = agentRecentTask(agent, data) + val recentTaskSummary = recentTask?.let { agentTaskSummary(it, zh) } + ?: if (zh) "暂无最近完成任务" else "No recent completed work" + val activeTaskCount = data.taskSnapshot.count { + it.agentId == agent.id && it.status in setOf("queued", "dispatched", "running") + } + val nowSummary = if (activeTaskCount == 0) { + if (zh) "当前没有活跃任务" else "No active tasks right now" + } else { + agentTasksSummary(agent, data, zh) + } + val shape = RoundedCornerShape(12.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { + this.contentDescription = "$contentDescription $WEB_SSOT_AGENT_OVERVIEW_PANE $WEB_SSOT_AGENT_ACTIVITY_TAB" + }, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + AgentDetailWebSectionLabel(if (zh) "Activity" else "Activity", contentDescription) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.72f), shape) + .padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + AgentActionInfoRow( + label = if (zh) "现在" else "Now", + value = nowSummary, + icon = Icons.Outlined.Bolt, + contentDescription = "Agent Detail Activity Now", + ) + AgentActionInfoRow( + label = if (zh) "最近 30 天" else "Last 30 days", + value = agentMetricsSummary(agent, data, zh), + icon = Icons.Outlined.Dashboard, + contentDescription = "Agent Detail Activity Last 30 Days", + ) + AgentActionInfoRow( + label = if (zh) "最近工作" else "Recent work", + value = recentTaskSummary, + icon = Icons.Outlined.DoneAll, + contentDescription = "Agent Detail Activity Recent Work", + ) + } + } + } +} + +@Composable +private fun AgentFormWebModelPicker( + model: String, + modelOptions: List, + zh: Boolean, + onModelSelected: (String) -> Unit, +) { + val WEB_SSOT_AGENT_DETAIL_INSPECTOR = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/agent-detail-inspector.tsx" + val WEB_SSOT_CREATE_AGENT_DIALOG = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/create-agent-dialog.tsx" + val current = clean(model) + AgentFormWebMenuRow( + eyebrow = "Model", + title = current.ifBlank { if (zh) "默认模型" else "Default model" }, + subtitle = if (zh) { + "对齐 Web ModelPicker,可清空为 runtime 默认值。" + } else { + "Matches Web ModelPicker. Clear to use the runtime default." + }, + contentDescription = "Agent Form Model Web Picker $WEB_SSOT_AGENT_DETAIL_INSPECTOR $WEB_SSOT_CREATE_AGENT_DIALOG", + options = modelOptions.map { option -> + AgentFormMenuOption( + label = option.label, + detail = option.detail, + selected = option.value == current, + onClick = { onModelSelected(option.value) }, + ) + }, + ) +} + +@Composable +private fun AgentFormWebConcurrencyPicker( + maxTasksText: String, + zh: Boolean, + onConcurrencySelected: (Int) -> Unit, +) { + val WEB_SSOT_AGENT_DETAIL_INSPECTOR = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/agent-detail-inspector.tsx" + val current = maxTasksText.toIntOrNull()?.coerceIn(1, 50) ?: 1 + AgentFormWebMenuRow( + eyebrow = if (zh) "并发" else "Concurrency", + title = current.toString(), + subtitle = if (zh) "Web ConcurrencyPicker 范围 1-50。" else "Web ConcurrencyPicker range: 1-50.", + contentDescription = "Agent Form Concurrency Web Picker $WEB_SSOT_AGENT_DETAIL_INSPECTOR", + options = agentConcurrencyOptions(maxTasksText).map { value -> + AgentFormMenuOption( + label = value.toString(), + detail = if (zh) "最多 $value 个并行任务" else "Up to $value concurrent tasks", + selected = value == current, + onClick = { onConcurrencySelected(value) }, + ) + }, + ) +} + +@Composable +private fun AgentSkillWebToggleRow( + skill: Models.Skill, + attached: Boolean, + zh: Boolean, + onToggle: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + val shape = RoundedCornerShape(10.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onToggle() + } + .semantics(mergeDescendants = true) { + contentDescription = "${if (attached) "Detach" else "Attach"} Skill ${skill.name} Agent Skill Web Toggle Row" + }, + shape = shape, + color = if (attached) MulticaColors.AccentSoft.copy(alpha = 0.72f) else MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border( + 1.dp, + if (attached) MulticaColors.Accent.copy(alpha = 0.22f) else MulticaColors.Border, + shape, + ) + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = if (attached) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + tint = if (attached) MulticaColors.Accent else MulticaColors.Muted, + modifier = Modifier.size(18.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = skill.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = clean(skill.description).ifBlank { + if (attached) { + if (zh) "已绑定到当前 Agent" else "Attached to this agent" + } else { + if (zh) "点击绑定到当前 Agent" else "Tap to attach to this agent" + } + }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = if (attached) { + if (zh) "已绑定" else "Attached" + } else { + if (zh) "绑定" else "Attach" + }, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (attached) MulticaColors.Accent else MulticaColors.Muted, + maxLines = 1, + ) + } + } +} + +@Composable +private fun AgentArchiveConfirmWebPanel( + agentName: String, + archiving: Boolean, + zh: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Agent Archive Web Alert Panel $agentName" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Danger.copy(alpha = 0.24f), shape) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Danger.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Archive, contentDescription = null, tint = MulticaColors.Danger, modifier = Modifier.size(18.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = if (zh) "归档 Agent?" else "Archive agent?", + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (zh) "归档 $agentName,可从 Archived 中恢复。" else "Archive $agentName. You can restore it from Archived.", + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.Muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (archiving) "..." else if (zh) "归档" else "Archive", + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "Agent Archive Confirm Destructive $agentName" }, + tone = MulticaButtonTone.Destructive, + ) + } + } + } +} + +@Composable +private fun AgentActionWebPanel( + agentName: String, + metricsSummary: String, + recentTaskSummary: String?, + archived: Boolean, + tasksSummary: String, + cancelResult: String?, + cancelling: Boolean, + restoring: Boolean, + zh: Boolean, + onEdit: () -> Unit, + onArchive: () -> Unit, + onCancelTasks: () -> Unit, + onRestore: () -> Unit, +) { + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + .semantics { contentDescription = "Agent Action Web Panel $agentName" }, + shape = shape, + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.72f), shape) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + AgentActionInfoRow( + label = if (zh) "活动指标" else "Activity Metrics", + value = metricsSummary, + icon = Icons.Outlined.Dashboard, + contentDescription = "Agent Action Metrics $agentName", + ) + if (!recentTaskSummary.isNullOrBlank()) { + AgentActionInfoRow( + label = if (zh) "最近任务" else "Recent Task", + value = recentTaskSummary, + icon = Icons.Outlined.DoneAll, + contentDescription = "Agent Action Recent Task $agentName", + ) + } + AgentActionCommandRow( + title = if (zh) "编辑 Agent" else "Edit Agent", + subtitle = agentName, + icon = Icons.Outlined.Edit, + contentDescription = "${if (zh) "编辑 Agent" else "Edit Agent"} $agentName", + onClick = onEdit, + ) + if (archived) { + AgentActionCommandRow( + title = if (restoring) if (zh) "恢复中..." else "Restoring..." else if (zh) "恢复 Agent" else "Restore Agent", + subtitle = agentName, + icon = Icons.Outlined.Archive, + contentDescription = "${if (zh) "恢复 Agent" else "Restore Agent"} $agentName", + onClick = onRestore, + ) + } else { + AgentActionCommandRow( + title = if (zh) "归档 Agent" else "Archive Agent", + subtitle = agentName, + icon = Icons.Outlined.Archive, + destructive = true, + contentDescription = "${if (zh) "归档 Agent" else "Archive Agent"} $agentName", + onClick = onArchive, + ) + AgentActionCommandRow( + title = if (cancelling) if (zh) "取消中..." else "Cancelling..." else if (zh) "取消任务" else "Cancel Tasks", + subtitle = tasksSummary, + icon = Icons.Outlined.Delete, + destructive = true, + contentDescription = "${if (zh) "取消任务" else "Cancel Tasks"} $agentName", + onClick = onCancelTasks, + ) + } + if (!cancelResult.isNullOrBlank()) { + PilotInlineResultState( + message = cancelResult, + isError = false, + contentDescription = "Agent Action Result Web Banner", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + } +} + +@Composable +private fun AgentActionInfoRow( + label: String, + value: String, + icon: ImageVector, + contentDescription: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription } + .padding(horizontal = 10.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = value, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun AgentActionCommandRow( + title: String, + subtitle: String, + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + destructive: Boolean = false, +) { + val haptic = LocalHapticFeedback.current + val tint = if (destructive) MulticaColors.Danger else MulticaColors.Accent + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription } + .padding(horizontal = 10.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.SemiBold), + color = if (destructive) MulticaColors.Danger else MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon(Icons.Outlined.KeyboardArrowRight, contentDescription = null, tint = MulticaColors.Muted, modifier = Modifier.size(16.dp)) + } +} + +@Composable +private fun PilotAgentsSettings(api: ApiClient, workspaceId: String, zh: Boolean, onBack: () -> Unit) { + var state by remember(workspaceId) { mutableStateOf?>(null) } + var refresh by remember(workspaceId) { mutableIntStateOf(0) } + val listState = rememberLazyListState() + val focusManager = LocalFocusManager.current + var agentMode by remember(workspaceId) { mutableStateOf(AgentSettingsMode.List) } + var editing by remember(workspaceId) { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var instructions by remember { mutableStateOf("") } + var model by remember { mutableStateOf("") } + var avatarUrl by remember { mutableStateOf("") } + var agentQuery by remember(workspaceId) { mutableStateOf("") } + var agentArchiveView by remember(workspaceId) { mutableStateOf("active") } + var agentActionMenuId by remember(workspaceId) { mutableStateOf(null) } + var maxTasksText by remember { mutableStateOf("1") } + var customEnvText by remember { mutableStateOf("") } + var customEnvDirty by remember { mutableStateOf(false) } + var customArgsText by remember { mutableStateOf("") } + var runtimeIndex by remember { mutableIntStateOf(0) } + var visibilityIndex by remember { mutableIntStateOf(0) } + var selectedSkillIds by remember(workspaceId) { mutableStateOf>(emptySet()) } + val selectedSkillGeneration = remember(workspaceId) { java.util.concurrent.atomic.AtomicInteger(0) } + var saving by remember { mutableStateOf(false) } + var uploadingAvatar by remember { mutableStateOf(false) } + var archiving by remember { mutableStateOf(false) } + var restoringAgentId by remember { mutableStateOf(null) } + var pendingArchive by remember { mutableStateOf(null) } + var cancellingAgentTasks by remember { mutableStateOf(null) } + var cancelTaskResults by remember(workspaceId) { mutableStateOf>(emptyMap()) } + var error by remember { mutableStateOf(null) } + var resultText by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val visibilityOptions = listOf("private", "workspace", "public") + val avatarLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri == null || uploadingAvatar) return@rememberLauncherForActivityResult + uploadingAvatar = true + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + api.uploadFile( + workspaceId, + displayNameForUri(context, uri), + context.contentResolver.getType(uri) ?: "application/octet-stream", + bytes, + ) + } + } + uploadingAvatar = false + result.onSuccess { uploadedUrl -> + avatarUrl = uploadedUrl + resultText = if (zh) "头像已上传,请保存 Agent" else "Avatar uploaded. Save agent to apply." + }.onFailure { + error = "${if (zh) "头像上传失败" else "Avatar upload failed"}: ${it.message ?: it.toString()}" + } + } + } + + fun resetForm() { + focusManager.clearFocus(force = true) + agentMode = AgentSettingsMode.List + editing = null + name = "" + description = "" + instructions = "" + model = "" + avatarUrl = "" + maxTasksText = "1" + customEnvText = "" + customEnvDirty = false + customArgsText = "" + visibilityIndex = 0 + selectedSkillIds = emptySet() + selectedSkillGeneration.incrementAndGet() + agentActionMenuId = null + } + + fun startCreate() { + focusManager.clearFocus(force = true) + agentMode = AgentSettingsMode.Create + editing = null + name = "" + description = "" + instructions = "" + model = "" + avatarUrl = "" + maxTasksText = "1" + customEnvText = "" + customEnvDirty = true + customArgsText = "" + runtimeIndex = 0 + visibilityIndex = 0 + selectedSkillIds = emptySet() + selectedSkillGeneration.incrementAndGet() + error = null + resultText = null + agentActionMenuId = null + scope.launch { listState.scrollToItem(0) } + } + + fun startEdit(agent: Models.Agent, data: PilotAgentSettingsData) { + focusManager.clearFocus(force = true) + agentMode = AgentSettingsMode.Edit + editing = agent + name = agent.name + description = clean(agent.description) + instructions = clean(agent.instructions).ifBlank { clean(agent.description) } + model = clean(agent.model) + avatarUrl = clean(agent.avatarUrl) + maxTasksText = agent.maxConcurrentTasks.coerceAtLeast(1).toString() + customEnvText = agentCustomEnvPlaceholder(agent, zh) + customEnvDirty = false + customArgsText = agent.customArgs.joinToString("\n") + runtimeIndex = data.runtimes.indexOfFirst { it.id == agent.runtimeId }.takeIf { it >= 0 } ?: 0 + visibilityIndex = visibilityOptions.indexOf(agent.visibility).takeIf { it >= 0 } ?: 0 + selectedSkillIds = emptySet() + val loadGeneration = selectedSkillGeneration.incrementAndGet() + error = null + resultText = null + agentActionMenuId = null + scope.launch { listState.scrollToItem(0) } + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.agentSkills(workspaceId, agent.id) } } + result.onSuccess { assigned -> + if (editing?.id == agent.id && selectedSkillGeneration.get() == loadGeneration) { + selectedSkillIds = assigned.map { it.id }.toSet() + } + } + } + } + + fun startDuplicate(agent: Models.Agent, data: PilotAgentSettingsData) { + focusManager.clearFocus(force = true) + agentMode = AgentSettingsMode.Create + editing = null + name = if (zh) "${agent.name} 副本" else "Copy of ${agent.name}" + description = clean(agent.description) + instructions = clean(agent.instructions).ifBlank { clean(agent.description) } + model = clean(agent.model) + avatarUrl = clean(agent.avatarUrl) + maxTasksText = agent.maxConcurrentTasks.coerceAtLeast(1).toString() + customEnvText = "" + customEnvDirty = true + customArgsText = agent.customArgs.joinToString("\n") + runtimeIndex = data.runtimes.indexOfFirst { it.id == agent.runtimeId }.takeIf { it >= 0 } ?: 0 + visibilityIndex = visibilityOptions.indexOf(agent.visibility).takeIf { it >= 0 } ?: 0 + selectedSkillIds = emptySet() + val loadGeneration = selectedSkillGeneration.incrementAndGet() + error = null + resultText = null + agentActionMenuId = null + scope.launch { listState.scrollToItem(0) } + scope.launch { + val result = withContext(Dispatchers.IO) { runCatching { api.agentSkills(workspaceId, agent.id) } } + result.onSuccess { assigned -> + if (agentMode == AgentSettingsMode.Create && selectedSkillGeneration.get() == loadGeneration) { + selectedSkillIds = assigned.map { it.id }.toSet() + } + } + } + } + + LaunchedEffect(workspaceId, refresh) { + state = null + state = withContext(Dispatchers.IO) { + runCatching { + PilotAgentSettingsData( + agents = api.agents(workspaceId, true), + runtimes = api.runtimes(workspaceId), + skills = api.skills(workspaceId), + activity = runCatching { api.agentActivity30d(workspaceId) }.getOrDefault(emptyList()), + runCounts = runCatching { api.agentRunCounts30d(workspaceId) }.getOrDefault(emptyList()), + taskSnapshot = runCatching { api.agentTaskSnapshot(workspaceId) }.getOrDefault(emptyList()), + ) + } + } + } + + fun submitAgentForm(data: PilotAgentSettingsData, editingAgent: Models.Agent?) { + val agentName = name.trim() + if (agentName.isEmpty() || saving || data.runtimes.isEmpty()) return + focusManager.clearFocus(force = true) + saving = true + error = null + resultText = null + val runtime = data.runtimes[runtimeIndex.coerceAtMost(data.runtimes.lastIndex)] + val visibility = visibilityOptions[visibilityIndex.coerceAtMost(visibilityOptions.lastIndex)] + val maxTasks = maxTasksText.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val customEnv = if (editingAgent != null && !customEnvDirty) editingAgent.customEnv else parseCustomEnvLines(customEnvText) + val customArgs = parseCustomArgsLines(customArgsText) + scope.launch { + val result = withContext(Dispatchers.IO) { + if (editingAgent == null) { + runCatching { + val created = api.createAgent( + workspaceId, + agentName, + description, + instructions, + runtime.id, + visibility, + maxTasks, + model, + avatarUrl, + customEnv, + customArgs, + ) + if (selectedSkillIds.isNotEmpty()) { + api.setAgentSkills(workspaceId, created.id, selectedSkillIds.toList()) + } + created + } + } else { + runCatching { + val updated = api.updateAgent( + workspaceId, + editingAgent, + agentName, + description, + instructions, + runtime.id, + visibility, + maxTasks, + model, + avatarUrl, + customEnv, + customArgs, + ) + api.setAgentSkills(workspaceId, updated.id, selectedSkillIds.toList()) + updated + } + } + } + saving = false + result.onSuccess { saved -> + state = state?.map { currentData -> + val updatedAgents = if (editingAgent == null) { + listOf(saved) + currentData.agents.filterNot { it.id == saved.id } + } else { + val replaced = currentData.agents.map { if (it.id == saved.id) saved else it } + if (replaced.any { it.id == saved.id }) replaced else listOf(saved) + replaced + } + currentData.copy(agents = updatedAgents) + } + resetForm() + resultText = if (zh) "Agent 已保存" else "Agent saved" + refresh++ + }.onFailure { + error = "${if (editingAgent == null) if (zh) "创建失败" else "Create failed" else if (zh) "保存失败" else "Save failed"}: ${it.message ?: it.toString()}" + } + } + } + + if (agentMode == AgentSettingsMode.Create || agentMode == AgentSettingsMode.Edit) { + val editingAgent = editing + PilotListPage( + title = if (editingAgent == null) { + if (zh) "新建 Agent" else "New Agent" + } else { + if (zh) "编辑 Agent" else "Edit Agent" + }, + leading = { + PilotSecondaryBackButton( + zh = zh, + onBack = { resetForm() }, + contentDescription = if (zh) "返回 Agents" else "Back to Agents", + ) + }, + listState = listState, + bottomContent = { + val data = state?.getOrNull() + if (data != null && data.runtimes.isNotEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .imePadding(), + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 26.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { resetForm() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + contentDescription = "Agent Form Cancel", + ) + MulticaPillButton( + text = if (saving) "..." else if (editingAgent == null) if (zh) "创建 Agent" else "Create Agent" else if (zh) "保存 Agent" else "Save Agent", + onClick = { submitAgentForm(data, editingAgent) }, + modifier = Modifier.weight(1.2f), + tone = MulticaButtonTone.Primary, + contentDescription = if (editingAgent == null) "Agent Form Bottom Create" else "Agent Form Bottom Save", + ) + } + } + } + }, + ) { + val loaded = state + when { + loaded == null -> item { MulticaLoadingState(if (zh) "正在加载 Agents..." else "Loading agents...") } + loaded.isFailure -> item { + MulticaErrorState( + "${if (zh) "加载失败" else "Load failed"}\n${loaded.exceptionOrNull()?.message}", + onRetry = { refresh++ }, + ) + } + else -> { + val data = loaded.getOrThrow() + if (editingAgent != null) { + item { + val editingRuntimeName = data.runtimes + .firstOrNull { it.id == editingAgent.runtimeId } + ?.name + ?: clean(editingAgent.runtimeId).ifBlank { if (zh) "未绑定 Runtime" else "No runtime" } + val detailParts = mutableListOf() + clean(editingAgent.visibility).takeIf { it.isNotBlank() }?.let { detailParts += it } + clean(editingAgent.status).takeIf { it.isNotBlank() }?.let { detailParts += it } + detailParts += "${if (zh) "最大并发" else "Max tasks"} ${editingAgent.maxConcurrentTasks.coerceAtLeast(1)}" + val formSubtitle = listOf( + clean(editingAgent.description).ifBlank { + clean(editingAgent.status).ifBlank { if (zh) "可编辑 Agent" else "Editable agent" } + }, + editingRuntimeName, + detailParts.joinToString(" · ").ifBlank { agentTasksSummary(editingAgent, data, zh) }, + ).filter { it.isNotBlank() }.joinToString(" · ") + AgentFormWebHero( + title = editingAgent.name, + subtitle = formSubtitle, + editing = true, + zh = zh, + ) + } + } else { + item { + AgentFormWebHero( + title = if (zh) "新建 Agent" else "New Agent", + subtitle = if (zh) "填写名称、Runtime、可见性、模型与 Skills 后保存。" else "Fill in name, runtime, visibility, model, and skills before saving.", + editing = false, + zh = zh, + ) + } + } + if (data.runtimes.isEmpty()) { + item { + MulticaErrorState( + if (zh) "没有可用 Runtime,先连接或创建 runtime 后再创建 Agent。" else "No runtime is available. Connect or create a runtime before saving an agent.", + onRetry = { refresh++ }, + ) + } + } + item { AgentDetailWebSectionLabel(if (zh) "Profile" else "Profile", "Agent Detail Web Profile Section") } + item { + MulticaTextField( + value = name, + onValueChange = { name = it }, + label = if (zh) "名称" else "Name", + singleLine = true, + contentDescription = "Agent Form Name", + ) + } + item { + MulticaTextField( + value = description, + onValueChange = { description = it }, + label = if (zh) "描述" else "Description", + minLines = 2, + maxLines = 4, + contentDescription = "Agent Form Description", + ) + } + item { + MulticaTextField( + value = avatarUrl, + onValueChange = { avatarUrl = it }, + label = if (zh) "头像 URL" else "Avatar URL", + singleLine = true, + contentDescription = "Agent Form Avatar URL", + ) + } + item { + MulticaPillButton( + text = if (uploadingAvatar) "..." else if (zh) "上传头像" else "Upload Avatar", + onClick = { if (!uploadingAvatar) avatarLauncher.launch("image/*") }, + tone = MulticaButtonTone.Secondary, + contentDescription = "Agent Form Upload Avatar", + ) + } + item { AgentDetailWebSectionLabel(if (zh) "Instructions" else "Instructions", "Agent Detail Web Instructions Section") } + item { + MulticaTextField( + value = instructions, + onValueChange = { instructions = it }, + label = if (zh) "指令" else "Instructions", + minLines = 5, + maxLines = 12, + contentDescription = "Agent Detail Instructions Web Editor Agent Form Instructions", + ) + } + item { AgentDetailWebSectionLabel(if (zh) "Runtime / Model" else "Runtime / Model", "Agent Detail Web Runtime Section") } + if (data.runtimes.isNotEmpty()) { + item { + val selectedRuntimeIndex = runtimeIndex.coerceAtMost(data.runtimes.lastIndex) + val selectedRuntime = data.runtimes[selectedRuntimeIndex] + AgentFormWebMenuRow( + eyebrow = if (zh) "Runtime" else "Runtime", + title = selectedRuntime.name, + subtitle = listOf( + clean(selectedRuntime.status), + clean(selectedRuntime.provider), + clean(selectedRuntime.runtimeMode), + clean(selectedRuntime.deviceInfo), + ).filter { it.isNotBlank() }.joinToString(" · "), + contentDescription = "Agent Form Runtime Web Picker", + options = data.runtimes.mapIndexed { index, runtime -> + AgentFormMenuOption( + label = runtime.name, + detail = listOf( + clean(runtime.status), + clean(runtime.provider), + clean(runtime.deviceInfo), + ).filter { it.isNotBlank() }.joinToString(" · "), + selected = index == selectedRuntimeIndex, + onClick = { runtimeIndex = index }, + ) + }, + ) + } + } + item { + AgentFormWebModelPicker( + model = model, + modelOptions = agentModelOptions(data, model, zh), + zh = zh, + onModelSelected = { model = it }, + ) + } + item { + val selectedVisibilityIndex = visibilityIndex.coerceAtMost(visibilityOptions.lastIndex) + val selectedVisibility = visibilityOptions[selectedVisibilityIndex] + AgentFormWebMenuRow( + eyebrow = if (zh) "可见性" else "Visibility", + title = selectedVisibility, + subtitle = when (selectedVisibility.lowercase()) { + "private" -> if (zh) "仅自己可见" else "Only you can access this agent" + else -> if (zh) "工作区成员可见" else "Visible to workspace members" + }, + contentDescription = "Agent Form Visibility Web Picker", + options = visibilityOptions.mapIndexed { index, option -> + AgentFormMenuOption( + label = option, + detail = when (option.lowercase()) { + "private" -> if (zh) "仅自己可见" else "Only you can access this agent" + else -> if (zh) "工作区成员可见" else "Visible to workspace members" + }, + selected = index == selectedVisibilityIndex, + onClick = { visibilityIndex = index }, + ) + }, + ) + } + item { + AgentFormWebConcurrencyPicker( + maxTasksText = maxTasksText, + zh = zh, + onConcurrencySelected = { maxTasksText = it.coerceIn(1, 50).toString() }, + ) + } + if (data.skills.isNotEmpty()) { + item { AgentDetailWebSectionLabel(if (zh) "Skills" else "Skills", "Agent Detail Web Skills Section") } + } + item { AgentDetailWebSectionLabel(if (zh) "Environment" else "Environment", "Agent Detail Web Environment Section") } + item { + MulticaTextField( + value = customEnvText, + onValueChange = { + customEnvText = it + customEnvDirty = true + }, + label = if (zh) "自定义环境变量" else "Custom Env", + minLines = 2, + maxLines = 5, + contentDescription = "Agent Form Custom Env", + ) + } + item { + MulticaTextField( + value = customArgsText, + onValueChange = { customArgsText = it }, + label = if (zh) "自定义参数" else "Custom Args", + minLines = 2, + maxLines = 5, + contentDescription = "Agent Form Custom Args", + ) + } + if (editingAgent != null) { + item { + AgentDetailActivityWebSection( + agent = editingAgent, + data = data, + zh = zh, + contentDescription = "Agent Detail Web Activity Section", + ) + } + } + if (data.skills.isNotEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 4.dp) + .semantics { contentDescription = "Agent Form Web Skill Picker" }, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = if (zh) "Agent Skills" else "Agent Skills", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.Muted, + maxLines = 1, + ) + Text( + text = if (selectedSkillIds.isEmpty()) { + if (zh) "选择要随 Agent 一起启用的 Skill" else "Select skills to enable with this agent" + } else { + if (zh) "已选择 ${selectedSkillIds.size} 个 Skill" else "${selectedSkillIds.size} skills selected" + }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + items(data.skills) { skill -> + val attached = selectedSkillIds.contains(skill.id) + AgentSkillWebToggleRow( + skill = skill, + attached = attached, + zh = zh, + onToggle = { + focusManager.clearFocus(force = true) + selectedSkillGeneration.incrementAndGet() + selectedSkillIds = if (attached) selectedSkillIds - skill.id else selectedSkillIds + skill.id + }, + ) + } + } + if (!error.isNullOrBlank()) item { + PilotInlineResultState( + message = error.orEmpty(), + isError = true, + contentDescription = "Agent Form Result Web Banner", + ) + } + if (!resultText.isNullOrBlank()) item { MulticaSuccessState(resultText.orEmpty()) } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MulticaPillButton( + text = if (zh) "取消" else "Cancel", + onClick = { resetForm() }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Ghost, + ) + MulticaPillButton( + text = if (saving) "..." else if (editingAgent == null) if (zh) "创建 Agent" else "Create Agent" else if (zh) "保存 Agent" else "Save Agent", + onClick = { submitAgentForm(data, editingAgent) }, + modifier = Modifier.weight(1f), + tone = MulticaButtonTone.Primary, + contentDescription = if (editingAgent == null) "Agent Form Create" else "Agent Form Save", + ) + } + } + } + } + } + return + } + + PilotListPage( + title = if (zh) "Agents" else "Agents", + leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) }, + trailing = { + MulticaIconPillButton( + icon = Icons.Outlined.Add, + contentDescription = "Agent Header Create Button", + onClick = { startCreate() }, + tone = MulticaButtonTone.Primary, + ) + }, + listState = listState, + ) { + val loaded = state + if (loaded == null) { + item { MulticaLoadingState(if (zh) "正在加载 Agents..." else "Loading agents...") } + } else { + loaded.onFailure { + item { MulticaErrorState("${if (zh) "加载失败" else "Load failed"}\n${it.message}", onRetry = { refresh++ }) } + }.onSuccess { data -> + item { AgentsOverviewPanel(data, zh) } + item { + MulticaTextField( + value = agentQuery, + onValueChange = { agentQuery = it }, + modifier = Modifier + .fillMaxWidth(), + label = if (zh) "搜索 Agents" else "Search agents", + contentDescription = "Agent List Search", + singleLine = true, + ) + } + item { + val activeCount = data.agents.count { it.archivedAt.isNullOrBlank() } + val archivedCount = data.agents.count { !it.archivedAt.isNullOrBlank() } + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp, bottom = 6.dp) + .semantics { contentDescription = "Agent Active Archived Filter" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + SettingsWebFilterPill( + text = if (zh) "活跃 $activeCount" else "Active $activeCount", + selected = agentArchiveView == "active", + onClick = { + agentArchiveView = "active" + agentActionMenuId = null + }, + contentDescription = "Agent Active Filter", + ) + } + item { + SettingsWebFilterPill( + text = if (zh) "已归档 $archivedCount" else "Archived $archivedCount", + selected = agentArchiveView == "archived", + onClick = { + agentArchiveView = "archived" + agentActionMenuId = null + }, + contentDescription = "Agent Archived Filter", + ) + } + } + } + pendingArchive?.let { agent -> + item { + AgentArchiveConfirmWebPanel( + agentName = agent.name, + archiving = archiving, + zh = zh, + onCancel = { pendingArchive = null }, + onConfirm = { + if (!archiving) { + archiving = true + error = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.archiveAgent(workspaceId, agent.id) } + } + archiving = false + result.onSuccess { + pendingArchive = null + refresh++ + }.onFailure { + error = "${if (zh) "归档失败" else "Archive failed"}: ${it.message ?: it.toString()}" + } + } + } + }, + ) + } + } + item { + if (!error.isNullOrBlank()) { + PilotInlineResultState( + message = error.orEmpty(), + isError = true, + contentDescription = "Agent List Result Web Banner", + ) + } + } + if (!resultText.isNullOrBlank()) item { MulticaSuccessState(resultText.orEmpty()) } + val normalizedAgentQuery = agentQuery.trim() + val visibleAgents = data.agents + .asSequence() + .filter { agent -> + val archived = !agent.archivedAt.isNullOrBlank() + if (agentArchiveView == "archived") archived else !archived + } + .filter { agent -> + normalizedAgentQuery.isBlank() || + clean(agent.name).contains(normalizedAgentQuery, ignoreCase = true) || + clean(agent.description).contains(normalizedAgentQuery, ignoreCase = true) || + clean(agent.status).contains(normalizedAgentQuery, ignoreCase = true) + } + .sortedWith( + compareByDescending { it.name }, + ) + .toList() + if (visibleAgents.isEmpty()) item { + if (data.agents.isEmpty()) { + AgentsWebEmptyState(zh = zh) + } else { + AgentsWebNoMatchesState( + zh = zh, + query = normalizedAgentQuery, + archived = agentArchiveView == "archived", + ) + } + } + items(visibleAgents) { agent -> + val archived = !agent.archivedAt.isNullOrBlank() + val tasksSummary = agentTasksSummary(agent, data, zh) + val activeTaskCount = data.taskSnapshot.count { + it.agentId == agent.id && it.status in setOf("queued", "dispatched", "running") + } + val compactTaskLabel = if (activeTaskCount > 0) { + if (zh) "$activeTaskCount 个活跃任务" else "$activeTaskCount active" + } else { + if (zh) "无活跃任务" else "No active tasks" + } + AgentRosterWebDenseRow( + agent = agent, + archived = archived, + statusLabel = if (archived) if (zh) "已归档" else "Archived" else clean(agent.status).ifBlank { if (zh) "Agent" else "Agent" }, + runtimeLabel = agentRuntimeLabel(agent, data), + modelLabel = clean(agent.model).ifBlank { if (zh) "默认模型" else "Default model" }, + recentTaskSummary = agentRecentTask(agent, data)?.let { agentTaskSummary(it, zh) } + ?: if (zh) "暂无最近任务" else "No recent task", + metricsSummary = agentMetricsSummary(agent, data, zh), + compactTaskLabel = compactTaskLabel, + actionsExpanded = agentActionMenuId == agent.id, + onClick = { startEdit(agent, data) }, + onActionsClick = { + agentActionMenuId = if (agentActionMenuId == agent.id) null else agent.id + pendingArchive = null + error = null + resultText = null + }, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MulticaColors.Border.copy(alpha = 0.74f)) + ) + AgentRowCupertinoMoreMenu( + visible = agentActionMenuId == agent.id, + agentName = agent.name, + archived = archived, + hasActiveTasks = activeTaskCount > 0, + tasksSummary = tasksSummary, + cancelling = cancellingAgentTasks == agent.id, + restoring = restoringAgentId == agent.id, + zh = zh, + onDismissRequest = { agentActionMenuId = null }, + onEdit = { startEdit(agent, data) }, + onDuplicate = { startDuplicate(agent, data) }, + onManageSkills = { startEdit(agent, data) }, + onArchive = { pendingArchive = agent }, + onCancelTasks = { + if (cancellingAgentTasks == null) { + cancellingAgentTasks = agent.id + cancelTaskResults = cancelTaskResults - agent.id + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.cancelAgentTasks(workspaceId, agent.id) } + } + cancellingAgentTasks = null + result.onSuccess { count -> + val message = if (zh) "已取消 $count 个任务" else "Cancelled tasks: $count" + resultText = message + cancelTaskResults = cancelTaskResults + (agent.id to message) + refresh++ + }.onFailure { + error = "${if (zh) "取消任务失败" else "Cancel tasks failed"}: ${it.message ?: it.toString()}" + } + } + } + }, + onRestore = { + if (restoringAgentId == null) { + restoringAgentId = agent.id + error = null + resultText = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.restoreAgent(workspaceId, agent.id) } + } + restoringAgentId = null + result.onSuccess { restored -> + state = state?.map { currentData -> + currentData.copy( + agents = currentData.agents.map { + if (it.id == restored.id) restored else it + } + ) + } + resultText = if (zh) "Agent 已恢复" else "Agent restored" + refresh++ + }.onFailure { + error = "${if (zh) "恢复失败" else "Restore failed"}: ${it.message ?: it.toString()}" + } + } + } + }, + ) + } + } + } + } +} + +@Composable +private fun AgentRowCupertinoMoreMenu( + visible: Boolean, + agentName: String, + archived: Boolean, + hasActiveTasks: Boolean, + tasksSummary: String, + cancelling: Boolean, + restoring: Boolean, + zh: Boolean, + onDismissRequest: () -> Unit, + onEdit: () -> Unit, + onDuplicate: () -> Unit, + onManageSkills: () -> Unit, + onArchive: () -> Unit, + onCancelTasks: () -> Unit, + onRestore: () -> Unit, +) { + Box(modifier = Modifier.semantics { contentDescription = "Agent Row Cupertino More Menu $agentName" }) { + MulticaCupertinoActionSheet( + visible = visible, + title = agentName, + message = if (zh) "选择 Agent 操作" else "Choose an agent action", + cancelText = if (zh) "取消" else "Cancel", + onDismissRequest = onDismissRequest, + items = buildList { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "编辑 Agent" else "Edit Agent", + subtitle = if (zh) "打开完整配置" else "Open full configuration", + contentDescription = "Agent Menu Edit $agentName", + onClick = onEdit, + ) + ) + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "管理 Skills" else "Manage Agent Skills", + subtitle = if (zh) "编辑已绑定技能" else "Edit attached skills", + contentDescription = "Agent Menu Skills $agentName", + onClick = onManageSkills, + ) + ) + if (!archived) { + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "复制 Agent" else "Duplicate Agent", + subtitle = if (zh) "用当前配置创建副本" else "Create from this configuration", + contentDescription = "Agent Menu Duplicate $agentName", + onClick = onDuplicate, + ) + ) + } + if (archived) { + add( + MulticaCupertinoActionSheetItem( + title = if (restoring) if (zh) "恢复中..." else "Restoring..." else if (zh) "恢复 Agent" else "Restore Agent", + subtitle = agentName, + contentDescription = "Agent Menu Restore $agentName", + enabled = !restoring, + onClick = onRestore, + ) + ) + } else { + if (hasActiveTasks) { + add( + MulticaCupertinoActionSheetItem( + title = if (cancelling) if (zh) "取消中..." else "Cancelling..." else if (zh) "取消任务" else "Cancel Tasks", + subtitle = tasksSummary, + contentDescription = "Agent Menu Cancel Tasks $agentName", + destructive = true, + enabled = !cancelling, + onClick = onCancelTasks, + ) + ) + } + add( + MulticaCupertinoActionSheetItem( + title = if (zh) "归档 Agent" else "Archive Agent", + subtitle = agentName, + contentDescription = "Agent Menu Archive $agentName", + destructive = true, + onClick = onArchive, + ) + ) + } + }, + ) + } +} + +@Composable +private fun AgentRosterWebDenseRow( + agent: Models.Agent, + archived: Boolean, + statusLabel: String, + runtimeLabel: String, + modelLabel: String, + recentTaskSummary: String, + metricsSummary: String, + compactTaskLabel: String, + actionsExpanded: Boolean, + onClick: () -> Unit, + onActionsClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics { contentDescription = "Agent ${agent.name}" }, + color = MulticaColors.Background, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Agent Roster Web Dense Row Agent Web Dense Row ${agent.name}" } + .padding(vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .padding(top = 1.dp) + .size(32.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MulticaColors.AccentSoft.copy(alpha = if (archived) 0.14f else 0.26f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = clean(agent.name).take(1).uppercase().ifBlank { "A" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 14.sp), + color = if (archived) MulticaColors.Muted else MulticaColors.Accent, + maxLines = 1, + ) + MulticaRemoteAvatarImage( + avatarUrl = agent.avatarUrl, + size = 32.dp, + shape = RoundedCornerShape(10.dp), + contentDescription = "Agent Row Remote Avatar ${agent.name}", + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(8.dp) + .clip(RoundedCornerShape(999.dp)) + .background(if (archived) MulticaColors.Muted else MulticaColors.Success) + .semantics { contentDescription = "Agent Availability Dot ${agent.name}" }, + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Text( + text = agent.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 19.sp, + ), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold), + color = if (archived) MulticaColors.Muted else MulticaColors.Success, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = clean(agent.description).ifBlank { compactTaskLabel }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp), + color = MulticaColors.Muted.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Agent Runtime Model Row ${agent.name}" }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + AgentRosterMetaPill( + label = runtimeLabel, + contentDescription = "Agent Runtime Pill ${agent.name}", + modifier = Modifier.weight(1.12f), + ) + AgentRosterMetaPill( + label = modelLabel, + contentDescription = "Agent Model Pill ${agent.name}", + modifier = Modifier.weight(0.88f), + ) + } + Text( + text = recentTaskSummary, + modifier = Modifier.semantics { contentDescription = "Agent Recent Task ${agent.name}" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 14.sp), + color = MulticaColors.Muted.copy(alpha = 0.76f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = metricsSummary, + modifier = Modifier.semantics { contentDescription = "Agent Metrics Summary ${agent.name}" }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MulticaIconPillButton( + icon = Icons.Outlined.MoreHoriz, + contentDescription = "Agent Actions ${agent.name}", + onClick = onActionsClick, + tone = if (actionsExpanded) MulticaButtonTone.Primary else MulticaButtonTone.Ghost, + modifier = Modifier + .size(34.dp) + .semantics(mergeDescendants = true) { + contentDescription = "Agent Actions ${agent.name}" + }, + ) + } + } +} + +@Composable +private fun AgentRosterMetaPill( + label: String, + contentDescription: String, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + modifier = modifier + .border(1.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(999.dp)) + .semantics { this.contentDescription = contentDescription }, + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun PilotListPage( + title: String, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + compactHeader: Boolean = false, + listState: LazyListState? = null, + pinnedHeader: @Composable (() -> Unit)? = null, + bottomContent: @Composable (() -> Unit)? = null, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, +) { + val WEB_SSOT_DASHBOARD_LAYOUT = "/Users/park0er/coding/multica-web-source/packages/views/layout/dashboard-layout.tsx" + val WEB_SSOT_PAGE_HEADER = "/Users/park0er/coding/multica-web-source/packages/views/layout/page-header.tsx" + val WEB_SSOT_APP_SIDEBAR = "/Users/park0er/coding/multica-web-source/packages/views/layout/app-sidebar.tsx" + MulticaCupertinoPage( + modifier = Modifier.semantics(mergeDescendants = false) { + contentDescription = "Pilot List Page Web SSOT $WEB_SSOT_DASHBOARD_LAYOUT $WEB_SSOT_PAGE_HEADER $WEB_SSOT_APP_SIDEBAR" + }, + listState = listState, + pinnedToolbar = pinnedHeader, + bottomBar = bottomContent, + header = { + PilotPageHeader(title = title, leading = leading, trailing = trailing, compact = compactHeader) + }, + content = content, + ) +} + +@Composable +private fun PilotGlassTopSlot( + hazeState: HazeState, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp)) + .hazeEffect( + hazeState, + style = HazeDefaults.style( + backgroundColor = MulticaColors.Background.copy(alpha = 0.78f), + blurRadius = 24.dp, + noiseFactor = 0.08f, + ), + ) + .background(MulticaColors.Background.copy(alpha = 0.78f)) + .semantics(mergeDescendants = false) { contentDescription = "Pilot Glass Pinned Header" }, + ) { + content() + } +} + +@Composable +private fun PilotGlassBottomSlot( + hazeState: HazeState, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)) + .hazeEffect( + hazeState, + style = HazeDefaults.style( + backgroundColor = MulticaColors.Background.copy(alpha = 0.72f), + blurRadius = 24.dp, + noiseFactor = 0.08f, + ), + ) + .background(MulticaColors.Background.copy(alpha = 0.72f)) + .imePadding() + .semantics(mergeDescendants = false) { contentDescription = "Pilot Glass Bottom Slot" }, + ) { + content() + } +} + +@Composable +private fun PilotPageHeader( + title: String, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + compact: Boolean = false, +) { + val headerDescription = if (leading == null) "Pilot Web Compact Header" else "Pilot Secondary Page Header" + if (compact) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 18.dp) + .semantics { contentDescription = headerDescription }, + ) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(54.dp) + .height(5.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.Border) + ) + Spacer(modifier = Modifier.height(14.dp)) + Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.align(Alignment.CenterStart)) { + leading?.invoke() + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.Center), + ) + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + trailing?.invoke() + } + } + } + return + } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(bottom = 12.dp) + .semantics { contentDescription = headerDescription }, + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.invoke() + val titleStyle = MaterialTheme.typography.titleLarge.copy( + fontSize = 22.sp, + lineHeight = 27.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = title, + style = titleStyle, + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .then(if (leading == null) Modifier else Modifier.padding(start = 10.dp)), + ) + trailing?.invoke() + } +} + +@Composable +private fun PilotSecondaryBackButton( + zh: Boolean, + onBack: () -> Unit, + contentDescription: String = if (zh) "返回" else "Back", +) { + MulticaIconPillButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = "Pilot Secondary Back Button $contentDescription", + onClick = onBack, + tone = MulticaButtonTone.Ghost, + ) +} + +@Composable +private fun AgentsWebEmptyState( + zh: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_AGENTS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/agents-page.tsx" + val WEB_SSOT_AGENTS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/agents.json" + Box( + modifier = modifier.semantics { + contentDescription = "Agents Web Empty State $WEB_SSOT_AGENTS_PAGE $WEB_SSOT_AGENTS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "还没有智能体" else "No agents yet", + description = if (zh) { + "创建一个智能体,像分配给同事那样把 issue 交给它。本地智能体在你的机器上运行,云智能体在 Multica 运行时上运行。" + } else { + "Create an agent and assign it issues, like any teammate. Local agents run on your machine; cloud agents run on Multica's runtime." + }, + ) + } +} + +@Composable +private fun AgentsWebNoMatchesState( + zh: Boolean, + query: String, + archived: Boolean, + modifier: Modifier = Modifier, +) { + val WEB_SSOT_AGENTS_PAGE = "/Users/park0er/coding/multica-web-source/packages/views/agents/components/agents-page.tsx" + val WEB_SSOT_AGENTS_LOCALE = "/Users/park0er/coding/multica-web-source/packages/views/locales/en/agents.json" + val activeQueryDescription = if (zh) "没有智能体匹配\"$query\"。" else "No agents match \"$query\"." + val description = when { + query.isNotBlank() && archived && zh -> "没有已归档智能体匹配\"$query\"。" + query.isNotBlank() && archived -> "No archived agents match \"$query\"." + query.isNotBlank() -> activeQueryDescription + archived -> if (zh) "还没有已归档智能体。" else "No archived agents yet." + else -> if (zh) "没有智能体匹配当前筛选。" else "No agents match this filter." + } + Box( + modifier = modifier.semantics { + contentDescription = "Agents Web No Matches State $WEB_SSOT_AGENTS_PAGE $WEB_SSOT_AGENTS_LOCALE" + }, + ) { + MulticaEmptyState( + title = if (zh) "无匹配项" else "No matches", + description = description, + ) + } +} + +private fun clean(value: String?): String { + val text = value?.trim().orEmpty() + return if (text.equals("null", ignoreCase = true)) "" else text +} + +private fun agentCustomEnvPlaceholder(agent: Models.Agent, zh: Boolean): String { + val count = agent.customEnv.size + if (count <= 0) return "" + return if (zh) { + "已保存 $count 个环境变量。留空则保持不变,输入 KEY=value 可替换。" + } else { + "$count environment variables saved. Leave unchanged to keep them, or enter KEY=value lines to replace." + } +} + +private fun agentModelOptions(data: PilotAgentSettingsData, model: String, zh: Boolean): List { + val current = clean(model) + val options = mutableListOf( + AgentFormModelOption( + value = "", + label = if (zh) "默认模型" else "Default model", + detail = if (zh) "交给 runtime 使用默认配置" else "Use the runtime default configuration", + ) + ) + val seen = linkedSetOf("") + if (current.isNotBlank() && seen.add(current)) { + options += AgentFormModelOption( + value = current, + label = current, + detail = if (zh) "当前模型" else "Current model", + ) + } + data.agents + .map { clean(it.model) } + .filter { it.isNotBlank() && seen.add(it) } + .sorted() + .forEach { value -> + options += AgentFormModelOption( + value = value, + label = value, + detail = if (zh) "来自现有 Agent" else "Used by an existing agent", + ) + } + return options +} + +private fun agentConcurrencyOptions(maxTasksText: String): List { + val current = maxTasksText.toIntOrNull()?.coerceIn(1, 50) ?: 1 + return (listOf(1, 2, 3, 4, 5, 6, 8, 10, 20, 50) + current) + .map { it.coerceIn(1, 50) } + .distinct() + .sorted() +} + +private fun parseCustomEnvLines(text: String): Map { + return text + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { line -> + val index = line.indexOf('=') + if (index <= 0) null else line.substring(0, index).trim() to line.substring(index + 1) + } + .filter { it.first.isNotEmpty() } + .toMap(LinkedHashMap()) +} + +private fun parseCustomArgsLines(text: String): List { + return text + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() +} + +private fun agentMetricsSummary(agent: Models.Agent, data: PilotAgentSettingsData, zh: Boolean): String { + val runCount = data.runCounts.firstOrNull { it.agentId == agent.id }?.runCount ?: 0 + val activity = data.activity.filter { it.agentId == agent.id } + val taskCount = activity.sumOf { it.taskCount } + val failedCount = activity.sumOf { it.failedCount } + val activeCount = data.taskSnapshot.count { it.agentId == agent.id && it.status in setOf("queued", "dispatched", "running") } + return if (zh) { + "30 天运行 $runCount · 30 天活动 $taskCount · 失败 $failedCount · 活跃 $activeCount" + } else { + "Runs 30d: $runCount · Activity 30d: $taskCount · Failed: $failedCount · Active: $activeCount" + } +} + +private fun agentRuntimeLabel(agent: Models.Agent, data: PilotAgentSettingsData): String { + val runtime = data.runtimes.firstOrNull { it.id == agent.runtimeId } + return listOf( + clean(runtime?.name ?: "").ifBlank { clean(agent.runtimeId).ifBlank { "No runtime" } }, + clean(runtime?.runtimeMode ?: "").ifBlank { clean(runtime?.provider ?: "") }, + ).filter { it.isNotBlank() }.joinToString(" · ") +} + +private fun agentRecentTask(agent: Models.Agent, data: PilotAgentSettingsData): Models.AgentTask? { + return data.taskSnapshot + .filter { it.agentId == agent.id } + .maxByOrNull { clean(it.completedAt).ifBlank { clean(it.startedAt).ifBlank { clean(it.createdAt) } } } +} + +private fun agentTaskSummary(task: Models.AgentTask, zh: Boolean): String { + val source = clean(task.kind).ifBlank { if (task.issueId.isNotBlank()) "issue" else "" } + val time = clean(task.completedAt).ifBlank { clean(task.startedAt).ifBlank { clean(task.createdAt) } } + val reason = clean(task.failureReason).ifBlank { clean(task.triggerSummary) } + val parts = listOf(clean(task.status), source, shortDate(time), reason).filter { it.isNotBlank() } + return parts.joinToString(" · ").ifBlank { if (zh) "暂无任务详情" else "No task detail" } +} + +private fun agentTasksSummary(agent: Models.Agent, data: PilotAgentSettingsData, zh: Boolean): String { + val tasks = data.taskSnapshot.filter { it.agentId == agent.id } + val active = tasks.filter { it.status in setOf("queued", "dispatched", "running") } + val latest = tasks.maxByOrNull { clean(it.completedAt).ifBlank { clean(it.startedAt).ifBlank { clean(it.createdAt) } } } + if (active.isEmpty()) { + val fallback = latest?.let { agentTaskSummary(it, zh) }.orEmpty() + return if (fallback.isBlank()) { + if (zh) "暂无活跃任务" else "No active tasks" + } else { + "${if (zh) "暂无活跃任务" else "No active tasks"} · $fallback" + } + } + val latestActive = active.maxByOrNull { clean(it.startedAt).ifBlank { clean(it.createdAt) } } ?: active.first() + return if (zh) { + "${active.size} 个活跃任务 · ${agentTaskSummary(latestActive, true)}" + } else { + "${active.size} active · ${agentTaskSummary(latestActive, false)}" + } +} + +private fun shortDate(value: String?): String { + val text = clean(value) + if (text.length >= 16) return text.substring(0, 16).replace('T', ' ') + return text +} + +private fun relativeDate(value: String?, zh: Boolean): String { + val millis = parseDueDateMillis(value) ?: return shortDate(value) + val deltaSeconds = ((System.currentTimeMillis() - millis) / 1000).coerceAtLeast(0) + if (deltaSeconds < 60) return if (zh) "刚刚" else "now" + val minutes = deltaSeconds / 60 + if (minutes < 60) return if (zh) "${minutes}分钟前" else "${minutes}m ago" + val hours = minutes / 60 + if (hours < 24) return if (zh) "${hours}小时前" else "${hours}h ago" + val days = hours / 24 + if (days < 7) return if (zh) "${days}天前" else "${days}d ago" + return shortDate(value) +} + +private fun formatDueDateMillis(millis: Long?): String { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + formatter.timeZone = TimeZone.getTimeZone("UTC") + return formatter.format(Date(millis ?: System.currentTimeMillis())) +} + +private fun parseDueDateMillis(value: String?): Long? { + val text = clean(value) + if (text.isBlank()) return null + val patterns = listOf("yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd") + for (pattern in patterns) { + val formatter = SimpleDateFormat(pattern, Locale.US) + formatter.timeZone = TimeZone.getTimeZone("UTC") + val parsed = runCatching { formatter.parse(text)?.time }.getOrNull() + if (parsed != null) return parsed + } + return null +} + +private fun displayNameForUri(context: Context, uri: Uri): String { + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0 && it.moveToFirst()) { + val name = it.getString(index) + if (!name.isNullOrBlank()) return name + } + } + return "avatar.png" +} + +private fun formatBytes(bytes: Long): String { + if (bytes <= 0L) return "" + if (bytes < 1024L) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024.0) return String.format("%.1f KB", kb) + return String.format("%.1f MB", kb / 1024.0) +} + +private fun preview(value: String): String? { + val text = value.replace('\n', ' ').trim() + if (text.isBlank()) return null + return if (text.length > 180) text.substring(0, 180) + "..." else text +} + +private fun messageSummary(message: Models.TaskMessage): String { + val text = clean(message.content).ifBlank { clean(message.output) } + return if (text.length > 1200) text.substring(0, 1200) + "..." else text +} + +private fun roleLabel(role: String?, zh: Boolean): String { + return when (clean(role).lowercase()) { + "user" -> if (zh) "你" else "You" + "assistant" -> if (zh) "助手" else "Assistant" + "agent" -> if (zh) "Agent" else "Agent" + else -> clean(role).ifBlank { if (zh) "消息" else "Message" } + } +} + +private fun settingsPageTitle(page: PilotSettingsPage): String { + return when (page) { + PilotSettingsPage.WorkspaceDetails -> "Workspace Details" + PilotSettingsPage.MyInvitations -> "My Invitations" + PilotSettingsPage.Members -> "Members" + PilotSettingsPage.Notifications -> "Notifications" + PilotSettingsPage.ApiTokens -> "API Tokens" + PilotSettingsPage.Agents -> "Agents" + PilotSettingsPage.Autopilots -> "Autopilots" + PilotSettingsPage.Runtimes -> "Runtimes" + PilotSettingsPage.Labels -> "Labels" + PilotSettingsPage.Skills -> "Skills" + PilotSettingsPage.Feedback -> "Feedback" + } +} diff --git a/app/src/main/java/ai/multica/app/ComposeVisualAuditActivity.kt b/app/src/main/java/ai/multica/app/ComposeVisualAuditActivity.kt new file mode 100644 index 0000000..f6fe571 --- /dev/null +++ b/app/src/main/java/ai/multica/app/ComposeVisualAuditActivity.kt @@ -0,0 +1,237 @@ +package ai.multica.app + +import android.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ai.multica.app.ui.components.MulticaButtonTone +import ai.multica.app.ui.components.MulticaListRow +import ai.multica.app.ui.components.MulticaPillButton +import ai.multica.app.ui.components.MulticaShell +import ai.multica.app.ui.components.MulticaTab +import ai.multica.app.ui.components.StatusPill +import ai.multica.app.ui.theme.MulticaColors +import ai.multica.app.ui.theme.MulticaTheme + +class ComposeVisualAuditActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MulticaTheme { + VisualAuditSandbox() + } + } + } +} + +private data class InboxPreview( + val type: String, + val status: String, + val title: String, + val body: String, + val unread: Boolean, +) + +private data class IssuePreview( + val identifier: String, + val title: String, + val status: String, + val priority: String, + val assignee: String?, +) + +@Composable +fun VisualAuditSandbox() { + val selected = remember { mutableStateOf(MulticaTab.Inbox) } + MulticaShell( + selectedTab = selected.value, + workspaceName = "park0er", + languageLabel = "中文", + onLanguageClick = {}, + onWorkspaceClick = { selected.value = MulticaTab.Settings }, + onSearchClick = {}, + onChatClick = { selected.value = MulticaTab.Inbox }, + onTabClick = { selected.value = it }, + ) { + when (selected.value) { + MulticaTab.Inbox -> InboxPreviewScreen() + MulticaTab.Issues -> IssuesPreviewScreen(personal = false) + MulticaTab.MyIssues -> IssuesPreviewScreen(personal = true) + MulticaTab.Projects -> PlaceholderScreen("Projects", "Project list and detail will use the same list-row system.") + MulticaTab.Settings -> PlaceholderScreen("Settings", "Settings will become grouped rows instead of a button stack.") + } + } +} + +@Composable +private fun InboxPreviewScreen() { + val rows = listOf( + InboxPreview( + type = "New Comment", + status = "Todo", + title = "核心功能:Issue 列表 & 详情页", + body = "已确认 Android 视觉修正回写:Inbox / Issues 的 pill 控件、白底细描边卡片、底部选中态和正文截断都比之前更干净。", + unread = true, + ), + InboxPreview( + type = "Task Failed", + status = "Todo", + title = "核心功能:Issue 列表 & 详情页", + body = "", + unread = false, + ), + InboxPreview( + type = "New Comment", + status = "Done", + title = "Android Markdown table QA", + body = "表格、代码块、引用和普通段落都应该使用同一套 Markdown 视觉系统。", + unread = false, + ), + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + PageHeader( + title = "Inbox", + trailing = { + MulticaPillButton("Chat", onClick = {}, tone = MulticaButtonTone.Secondary) + }, + ) + } + items(rows) { row -> + MulticaListRow( + eyebrow = "${row.type} · ${row.status}", + title = row.title, + subtitle = row.body.ifBlank { null }, + unread = row.unread, + onClick = {}, + ) + } + } +} + +@Composable +private fun IssuesPreviewScreen(personal: Boolean) { + val rows = listOf( + IssuePreview("PAR-62", "了解一下金事通 🍎", "Backlog", "Low", "ZhaoXishengGmail"), + IssuePreview("PAR-68", "填写 W-8BEN 税务表格(税率 30%→10%)", "Backlog", "Low", null), + IssuePreview("PAR-67", "绑定 Stripe 账户(Hong Kong / Individual)", "Backlog", "Low", null), + IssuePreview("PAR-73", "核心功能:Issue 列表 & 详情页", "Todo", "High", "codex"), + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + PageHeader( + title = if (personal) "My Issues" else "Issues", + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MulticaPillButton("↓", onClick = {}, tone = MulticaButtonTone.Secondary) + MulticaPillButton("Board", onClick = {}, tone = MulticaButtonTone.Ghost) + MulticaPillButton("+", onClick = {}, tone = MulticaButtonTone.Primary) + } + }, + ) + } + item { + Text( + text = "Backlog (${rows.count { it.status == "Backlog" }})", + style = MaterialTheme.typography.titleMedium, + color = MulticaColors.Muted, + modifier = Modifier.padding(top = 8.dp), + ) + } + items(rows) { issue -> + IssuePreviewRow(issue) + } + } +} + +@Composable +private fun IssuePreviewRow(issue: IssuePreview) { + MulticaListRow( + eyebrow = issue.identifier, + title = issue.title, + subtitle = buildString { + append(issue.status) + append(" · ") + append(issue.priority) + if (!issue.assignee.isNullOrBlank()) { + append(" · ") + append(issue.assignee) + } + }, + unread = false, + onClick = {}, + ) +} + +@Composable +private fun PlaceholderScreen(title: String, description: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + PageHeader(title = title) + StatusPill("Compose migration sandbox") + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MulticaColors.Muted, + ) + } +} + +@Composable +private fun PageHeader( + title: String, + trailing: @Composable (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (trailing != null) { + Spacer(modifier = Modifier.height(1.dp)) + trailing() + } + } +} diff --git a/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java b/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java new file mode 100644 index 0000000..1cf489a --- /dev/null +++ b/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java @@ -0,0 +1,159 @@ +package ai.multica.app; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class AgentMentionMarkdown { + static String markdown(String name, String agentId) { + String cleanName = name == null ? "" : name.trim() + .replace("[", "\\[") + .replace("]", "\\]"); + if (cleanName.isEmpty()) cleanName = Models.shortId(agentId); + return "[@" + cleanName + "](mention://agent/" + agentId + ")"; + } + + private AgentMentionMarkdown() { + } +} + +final class InboxNotificationDeduper { + static List deduplicateByIssue(Collection items) { + Map newestByIssue = new LinkedHashMap<>(); + for (Models.InboxItem item : items) { + if (item == null || item.archived) continue; + String key = item.issueId == null || item.issueId.isEmpty() ? item.id : item.issueId; + Models.InboxItem existing = newestByIssue.get(key); + if (existing == null || compareCreatedAt(item, existing) > 0) { + newestByIssue.put(key, item); + } + } + ArrayList deduped = new ArrayList<>(newestByIssue.values()); + deduped.sort((left, right) -> compareCreatedAt(right, left)); + return deduped; + } + + private static int compareCreatedAt(Models.InboxItem left, Models.InboxItem right) { + String leftCreatedAt = left.createdAt == null ? "" : left.createdAt; + String rightCreatedAt = right.createdAt == null ? "" : right.createdAt; + return leftCreatedAt.compareTo(rightCreatedAt); + } + + private InboxNotificationDeduper() { + } +} + +final class SkillFileTree { + static final class Node { + final String name; + final String path; + final boolean directory; + final Models.SkillFile file; + final List children = new ArrayList<>(); + + Node(String name, String path, boolean directory, Models.SkillFile file) { + this.name = name; + this.path = path; + this.directory = directory; + this.file = file; + } + } + + static List build(List files) { + Node root = new Node("", "", true, null); + for (Models.SkillFile file : files) { + String normalized = normalizePath(file.path); + if (normalized.isEmpty()) continue; + String[] parts = normalized.split("/"); + Node parent = root; + StringBuilder path = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (parts[i].isEmpty()) continue; + if (path.length() > 0) path.append('/'); + path.append(parts[i]); + boolean leaf = i == parts.length - 1; + Node existing = findChild(parent, parts[i], leaf ? path.toString() : path + "/"); + if (existing == null) { + existing = new Node(parts[i], leaf ? path.toString() : path + "/", !leaf, leaf ? file : null); + parent.children.add(existing); + } + parent = existing; + } + } + sortRecursively(root.children, true); + return root.children; + } + + static List flattenDisplayPaths(List roots) { + ArrayList paths = new ArrayList<>(); + flattenInto(roots, paths); + return paths; + } + + static boolean isMarkdownPath(String path) { + String lower = normalizePath(path).toLowerCase(Locale.US); + return lower.endsWith(".md") || lower.endsWith(".markdown"); + } + + private static void flattenInto(List nodes, List paths) { + for (Node node : nodes) { + paths.add(node.path); + if (node.directory) flattenInto(node.children, paths); + } + } + + private static Node findChild(Node parent, String name, String path) { + for (Node child : parent.children) { + if (child.name.equals(name) && child.path.equals(path)) return child; + } + return null; + } + + private static void sortRecursively(List nodes, boolean root) { + for (Node node : nodes) { + if (node.directory) sortRecursively(node.children, false); + } + nodes.sort(Comparator + .comparingInt((Node node) -> sortBucket(node, root)) + .thenComparing(node -> node.name.toLowerCase(Locale.US))); + } + + private static int sortBucket(Node node, boolean root) { + if (root && !node.directory && node.name.equalsIgnoreCase("SKILL.md")) return 0; + if (node.directory) return 1; + return root ? 2 : 3; + } + + private static String normalizePath(String path) { + if (path == null) return ""; + return path.trim().replace('\\', '/').replaceAll("/+", "/").replaceAll("^/|/$", ""); + } + + private SkillFileTree() { + } +} + +final class ChatTimelineState { + final String taskId; + final String status; + final boolean locallyPending; + + private ChatTimelineState(String taskId, String status, boolean locallyPending) { + this.taskId = taskId == null ? "" : taskId; + this.status = status == null ? "" : status; + this.locallyPending = locallyPending; + } + + static ChatTimelineState afterLocalSend(String taskId, String status) { + return new ChatTimelineState(taskId, status == null || status.isEmpty() ? "queued" : status, true); + } + + boolean shouldShowPendingRow() { + return locallyPending || !taskId.isEmpty(); + } +} diff --git a/app/src/main/java/ai/multica/app/IssueBuckets.java b/app/src/main/java/ai/multica/app/IssueBuckets.java new file mode 100644 index 0000000..f1be788 --- /dev/null +++ b/app/src/main/java/ai/multica/app/IssueBuckets.java @@ -0,0 +1,52 @@ +package ai.multica.app; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +final class IssueBuckets { + private IssueBuckets() {} + + interface Fetcher { + Models.Page fetch(String status, int limit, int offset) throws Exception; + } + + static List loadAll(Fetcher fetcher, int limit) throws Exception { + List all = new ArrayList<>(); + for (String status : Models.STATUS_VALUES) { + all.addAll(loadStatus(fetcher, status, limit)); + } + return all; + } + + static List loadAllConcurrent(Fetcher fetcher, int limit) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(Math.min(4, Models.STATUS_VALUES.length)); + try { + List>> futures = new ArrayList<>(); + for (String status : Models.STATUS_VALUES) { + Callable> task = () -> loadStatus(fetcher, status, limit); + futures.add(pool.submit(task)); + } + List all = new ArrayList<>(); + for (Future> future : futures) all.addAll(future.get()); + return all; + } finally { + pool.shutdownNow(); + } + } + + private static List loadStatus(Fetcher fetcher, String status, int limit) throws Exception { + List issues = new ArrayList<>(); + int offset = 0; + while (true) { + Models.Page page = fetcher.fetch(status, limit, offset); + issues.addAll(page.items); + if (!page.hasMore || page.items.isEmpty()) break; + offset += page.items.size(); + } + return issues; + } +} diff --git a/app/src/main/java/ai/multica/app/IssueDetailLoader.java b/app/src/main/java/ai/multica/app/IssueDetailLoader.java new file mode 100644 index 0000000..da49168 --- /dev/null +++ b/app/src/main/java/ai/multica/app/IssueDetailLoader.java @@ -0,0 +1,74 @@ +package ai.multica.app; + +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +final class IssueDetailLoader { + interface Fetcher { + Models.Issue issue(String issueId, String workspaceId) throws Exception; + + List comments(String issueId, String workspaceId) throws Exception; + + List runs(String issueId, String workspaceId) throws Exception; + + List projects() throws Exception; + + List members() throws Exception; + + List agents() throws Exception; + } + + static Data load(String issueId, String workspaceId, Fetcher fetcher, int poolSize) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, poolSize)); + try { + Future issueFuture = pool.submit(() -> fetcher.issue(issueId, workspaceId)); + Future> commentsFuture = null; + Future> runsFuture = null; + if (workspaceId != null && !workspaceId.isEmpty()) { + commentsFuture = pool.submit(() -> fetcher.comments(issueId, workspaceId)); + runsFuture = pool.submit(() -> fetcher.runs(issueId, workspaceId)); + } + Future> projectsFuture = pool.submit(fetcher::projects); + Future> membersFuture = pool.submit(fetcher::members); + Future> agentsFuture = pool.submit(fetcher::agents); + + Data data = new Data(); + data.issue = get(issueFuture); + String resolvedWorkspaceId = data.issue.workspaceId; + if (resolvedWorkspaceId == null || resolvedWorkspaceId.isEmpty()) resolvedWorkspaceId = workspaceId; + final String finalWorkspaceId = resolvedWorkspaceId; + if (commentsFuture == null) commentsFuture = pool.submit(() -> fetcher.comments(issueId, finalWorkspaceId)); + if (runsFuture == null) runsFuture = pool.submit(() -> fetcher.runs(issueId, finalWorkspaceId)); + data.comments = get(commentsFuture); + data.runs = get(runsFuture); + data.projects = get(projectsFuture); + data.members = get(membersFuture); + data.agents = get(agentsFuture); + return data; + } finally { + pool.shutdownNow(); + } + } + + private static T get(Future future) throws Exception { + try { + return future.get(); + } catch (ExecutionException error) { + Throwable cause = error.getCause(); + if (cause instanceof Exception) throw (Exception) cause; + throw new RuntimeException(cause); + } + } + + static final class Data { + Models.Issue issue; + List comments; + List runs; + List projects; + List members; + List agents; + } +} diff --git a/app/src/main/java/ai/multica/app/IssueDetailSectionOrder.java b/app/src/main/java/ai/multica/app/IssueDetailSectionOrder.java new file mode 100644 index 0000000..9c56cb7 --- /dev/null +++ b/app/src/main/java/ai/multica/app/IssueDetailSectionOrder.java @@ -0,0 +1,42 @@ +package ai.multica.app; + +import java.util.ArrayList; +import java.util.List; + +final class IssueDetailSectionOrder { + enum Section { + HERO, + COMPACT_METADATA, + DESCRIPTION, + LATEST_PROGRESS, + SUB_ISSUES, + SUBSCRIBERS, + COMMENTS, + AGENT_WORK_DETAILS, + MORE_DETAILS, + USAGE, + ACTIVITY + } + + static List
defaultReadingPath( + boolean hasAgentWork, + boolean hasActivity + ) { + ArrayList
sections = new ArrayList<>(); + sections.add(Section.HERO); + sections.add(Section.COMPACT_METADATA); + sections.add(Section.DESCRIPTION); + sections.add(Section.LATEST_PROGRESS); + sections.add(Section.SUB_ISSUES); + sections.add(Section.SUBSCRIBERS); + sections.add(Section.COMMENTS); + if (hasAgentWork) sections.add(Section.AGENT_WORK_DETAILS); + sections.add(Section.MORE_DETAILS); + sections.add(Section.USAGE); + if (hasActivity) sections.add(Section.ACTIVITY); + return sections; + } + + private IssueDetailSectionOrder() { + } +} diff --git a/app/src/main/java/ai/multica/app/MainActivity.java b/app/src/main/java/ai/multica/app/MainActivity.java new file mode 100644 index 0000000..5d97f05 --- /dev/null +++ b/app/src/main/java/ai/multica/app/MainActivity.java @@ -0,0 +1,2433 @@ +package ai.multica.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +public final class MainActivity extends Activity { + private static final int BLUE = 0xFF2563EB; + private static final int GREEN = 0xFF16A34A; + private static final int RED = 0xFFDC2626; + private static final int TEXT = 0xFF111827; + private static final int MUTED = 0xFF6B7280; + private static final int BORDER = 0xFFE5E7EB; + private static final int SURFACE = 0xFFFFFFFF; + private static final int APP_BG = 0xFFF8FAFC; + private static final int SOFT_BLUE = 0xFFEFF6FF; + private static final int SOFT_GRAY = 0xFFF3F4F6; + + private ExecutorService executor = Executors.newFixedThreadPool(4); + private final Handler main = new Handler(Looper.getMainLooper()); + private AuthStore authStore; + private ApiClient api; + private Models.User currentUser; + private Models.Workspace currentWorkspace; + private List workspaces = new ArrayList<>(); + private List projectCache = new ArrayList<>(); + private List memberCache = new ArrayList<>(); + private List agentCache = new ArrayList<>(); + private List runtimeCache = new ArrayList<>(); + private List skillCache = new ArrayList<>(); + private List autopilotCache = new ArrayList<>(); + private List labelCache = new ArrayList<>(); + private List chatSessionCache = new ArrayList<>(); + private List demoInbox = new ArrayList<>(); + private List demoIssues = new ArrayList<>(); + private final Map> demoCommentsByIssueId = new HashMap<>(); + private final Map> demoRunsByIssueId = new HashMap<>(); + private final Map> demoChatMessagesBySessionId = new HashMap<>(); + private FrameLayout content; + private LinearLayout shell; + private String tab = "inbox"; + private boolean boardMode = false; + private boolean issuesDescending = true; + private boolean zh = false; + private boolean demoMode = false; + + interface Success { void accept(T value); } + interface Failure { void accept(Exception error); } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + authStore = new AuthStore(this); + api = new ApiClient(authStore); + zh = authStore.isChinese(); + demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false); + if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData()); + if (demoMode) { + setupDemoData(); + showShell(); + return; + } + if (authStore.token() == null) showLogin(); + else restoreSession(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent != null && intent.getData() != null) handleDeepLink(intent.getData()); + } + + @Override + protected void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + private void restoreSession() { + showLoading(t("loading")); + run(() -> { + SessionData data = new SessionData(); + data.user = api.me(); + data.workspaces = api.workspaces(); + return data; + }, data -> { + currentUser = data.user; + workspaces = data.workspaces; + currentWorkspace = chooseWorkspace(data.workspaces); + showShell(); + }, error -> { + authStore.clearToken(); + toast(t("sessionExpired")); + showLogin(); + }); + } + + private Models.Workspace chooseWorkspace(List list) { + if (list.isEmpty()) return null; + String saved = authStore.workspaceId(); + for (Models.Workspace ws : list) { + if (ws.id.equals(saved)) return ws; + } + authStore.saveWorkspaceId(list.get(0).id); + return list.get(0); + } + + private void showLogin() { + LinearLayout root = vertical(); + root.setGravity(Gravity.CENTER_HORIZONTAL); + root.setPadding(dp(24), dp(40), dp(24), dp(24)); + TextView mark = label("⚡", 54, BLUE, true); + TextView title = label(t("signIn"), 26, TEXT, true); + TextView subtitle = label(t("signInHint"), 15, MUTED, false); + EditText email = input("you@example.com"); + email.setInputType(InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + Button continueButton = button(t("continue"), BLUE, 0xFFFFFFFF); + Button google = button(t("google"), 0xFFE5E7EB, TEXT); + + root.addView(spacer(30)); + root.addView(mark); + root.addView(title); + root.addView(subtitle); + root.addView(spacer(20)); + root.addView(email, matchWrap()); + root.addView(spacer(12)); + root.addView(continueButton, matchWrap()); + root.addView(spacer(12)); + root.addView(google, matchWrap()); + + continueButton.setOnClickListener(v -> { + String value = email.getText().toString().trim(); + if (value.isEmpty()) { + toast(t("emailRequired")); + return; + } + continueButton.setEnabled(false); + run(() -> { + api.sendCode(value); + return value; + }, sentEmail -> showOtp(sentEmail), error -> { + continueButton.setEnabled(true); + toast(t("sendCodeFailed")); + }); + }); + google.setOnClickListener(v -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://app.multica.ai/login")))); + setContentView(root); + } + + private void showOtp(String email) { + LinearLayout root = vertical(); + root.setGravity(Gravity.CENTER_HORIZONTAL); + root.setPadding(dp(24), dp(40), dp(24), dp(24)); + TextView title = label(t("otpTitle"), 24, TEXT, true); + TextView subtitle = label(email, 14, MUTED, false); + EditText code = input("123456"); + code.setInputType(InputType.TYPE_CLASS_NUMBER); + Button verify = button(t("verify"), BLUE, 0xFFFFFFFF); + Button back = button(t("back"), 0xFFE5E7EB, TEXT); + root.addView(spacer(70)); + root.addView(title); + root.addView(subtitle); + root.addView(spacer(18)); + root.addView(code, matchWrap()); + root.addView(spacer(12)); + root.addView(verify, matchWrap()); + root.addView(spacer(8)); + root.addView(back, matchWrap()); + verify.setOnClickListener(v -> { + String value = code.getText().toString().trim(); + if (value.length() < 4) { + toast(t("otpRequired")); + return; + } + verify.setEnabled(false); + run(() -> { + String token = api.verifyCode(email, value); + authStore.saveToken(token); + SessionData data = new SessionData(); + data.user = api.me(); + data.workspaces = api.workspaces(); + return data; + }, data -> { + currentUser = data.user; + workspaces = data.workspaces; + currentWorkspace = chooseWorkspace(data.workspaces); + showShell(); + }, error -> { + verify.setEnabled(true); + toast(t("otpFailed")); + }); + }); + back.setOnClickListener(v -> showLogin()); + setContentView(root); + } + + private void showShell() { + shell = vertical(); + shell.setBackgroundColor(APP_BG); + shell.addView(header(), matchWrap()); + content = new FrameLayout(this); + content.setBackgroundColor(APP_BG); + shell.addView(content, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + shell.addView(bottomNav(), matchWrap()); + setContentView(shell); + renderTab(); + } + + private View header() { + LinearLayout header = horizontal(); + header.setGravity(Gravity.CENTER_VERTICAL); + header.setPadding(dp(18), dp(12), dp(14), dp(10)); + header.setBackgroundColor(0xFFFFFFFF); + TextView title = label("Multica", 22, TEXT, true); + header.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + + if (!workspaces.isEmpty()) { + Spinner spinner = new Spinner(this); + List names = new ArrayList<>(); + int selected = 0; + for (int i = 0; i < workspaces.size(); i++) { + Models.Workspace ws = workspaces.get(i); + names.add(ws.name); + if (currentWorkspace != null && currentWorkspace.id.equals(ws.id)) selected = i; + } + spinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, names)); + spinner.setSelection(selected, false); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { + Models.Workspace ws = workspaces.get(position); + if (currentWorkspace == null || !currentWorkspace.id.equals(ws.id)) { + currentWorkspace = ws; + authStore.saveWorkspaceId(ws.id); + projectCache = new ArrayList<>(); + memberCache = new ArrayList<>(); + agentCache = new ArrayList<>(); + runtimeCache = new ArrayList<>(); + skillCache = new ArrayList<>(); + autopilotCache = new ArrayList<>(); + labelCache = new ArrayList<>(); + chatSessionCache = new ArrayList<>(); + renderTab(); + } + } + @Override public void onNothingSelected(AdapterView parent) {} + }); + header.addView(spinner, new LinearLayout.LayoutParams(dp(150), ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + Button lang = pillButton(zh ? "EN" : "中文", SOFT_BLUE, BLUE); + lang.setOnClickListener(v -> { + zh = !zh; + authStore.setChinese(zh); + showShell(); + }); + header.addView(lang); + return header; + } + + private View bottomNav() { + LinearLayout nav = horizontal(); + nav.setGravity(Gravity.CENTER); + nav.setPadding(dp(10), dp(8), dp(10), dp(8)); + nav.setBackground(roundedStroke(0xFFFFFFFF, BORDER, 0, 0)); + addNav(nav, "inbox", t("inbox")); + addNav(nav, "issues", t("issues")); + addNav(nav, "myIssues", t("myIssues")); + addNav(nav, "projects", t("projects")); + addNav(nav, "settings", t("settings")); + return nav; + } + + private void addNav(LinearLayout nav, String key, String label) { + boolean selected = key.equals(tab); + TextView item = label(label, 12, selected ? BLUE : MUTED, true); + item.setGravity(Gravity.CENTER); + item.setSingleLine(true); + item.setEllipsize(TextUtils.TruncateAt.END); + item.setPadding(dp(4), dp(8), dp(4), dp(8)); + if (selected) item.setBackground(rounded(SOFT_BLUE, 18)); + item.setOnClickListener(v -> { + tab = key; + showShell(); + }); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1); + params.setMargins(dp(3), 0, dp(3), 0); + nav.addView(item, params); + } + + private void renderTab() { + if (content == null) return; + if ("issues".equals(tab)) showIssues(false); + else if ("myIssues".equals(tab)) showIssues(true); + else if ("projects".equals(tab)) showProjects(); + else if ("agents".equals(tab)) showAgents(); + else if ("settings".equals(tab)) showSettings(); + else showInbox(); + } + + private void showInbox() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderInbox(demoInbox); + return; + } + setContent(loadingView(t("loadingInbox"))); + run(() -> api.inbox(currentWorkspace.id, 50, 0), page -> { + renderInbox(page.items); + }, error -> showError(t("loadFailed"), this::showInbox)); + } + + private void renderInbox(List items) { + LinearLayout body = vertical(); + body.setPadding(dp(18), dp(14), dp(18), dp(18)); + body.setBackgroundColor(APP_BG); + LinearLayout tools = horizontal(); + tools.setGravity(Gravity.CENTER_VERTICAL); + tools.addView(screenTitle(t("inbox")), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button chat = pillButton(t("chat"), SOFT_BLUE, BLUE); + chat.setOnClickListener(v -> showChatSessions()); + tools.addView(chat); + body.addView(tools); + if (items.isEmpty()) body.addView(empty(t("emptyInbox"))); + for (Models.InboxItem item : items) { + LinearLayout row = card(); + String displayIdentifier = item.issueIdentifier == null || item.issueIdentifier.isEmpty() || item.issueIdentifier.equals(item.issueId) ? "" : item.issueIdentifier; + String meta = displayIdentifier.isEmpty() ? humanizeType(item.type) : displayIdentifier + " · " + humanizeType(item.type); + if (item.issueStatus != null && !item.issueStatus.isEmpty()) meta += " · " + Models.statusLabel(item.issueStatus, zh); + TextView id = label(meta, 12, MUTED, true); + TextView title = label(item.issueTitle, 16, TEXT, !item.read); + title.setMaxLines(2); + title.setEllipsize(TextUtils.TruncateAt.END); + row.addView(id); + row.addView(title); + String bodyText = cleanDisplayText(item.body); + if (!bodyText.isEmpty()) { + TextView preview = label(bodyText, 14, MUTED, false); + preview.setMaxLines(3); + preview.setEllipsize(TextUtils.TruncateAt.END); + preview.setPadding(0, dp(4), 0, 0); + row.addView(preview); + } + row.setOnClickListener(v -> showIssueDetail(item.issueId)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showIssues() { + showIssues(false); + } + + private void showIssues(boolean assignedToMe) { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + List issues = new ArrayList<>(); + for (Models.Issue issue : demoIssues) { + if (!assignedToMe || currentUser != null && currentUser.id.equals(issue.assigneeId)) { + issues.add(issue); + } + } + renderIssues(issues, assignedToMe); + return; + } + setContent(loadingView(t("loadingIssues"))); + run(() -> { + IssuesData data = new IssuesData(); + String assigneeId = assignedToMe && currentUser != null ? currentUser.id : null; + data.issues = IssueBuckets.loadAllConcurrent((status, limit, offset) -> + api.issues(currentWorkspace.id, limit, offset, assigneeId, status), 100); + data.projects = safeProjects(); + data.agents = safeAgents(); + return data; + }, data -> { + projectCache = data.projects; + agentCache = data.agents; + Collections.sort(data.issues, (a, b) -> issuesDescending ? b.updatedAt.compareTo(a.updatedAt) : a.updatedAt.compareTo(b.updatedAt)); + renderIssues(data.issues, assignedToMe); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), () -> showIssues(assignedToMe))); + } + + private void renderIssues(List issues, boolean assignedToMe) { + Collections.sort(issues, (a, b) -> issuesDescending ? b.updatedAt.compareTo(a.updatedAt) : a.updatedAt.compareTo(b.updatedAt)); + LinearLayout body = vertical(); + body.setPadding(dp(18), dp(14), dp(18), dp(18)); + body.setBackgroundColor(APP_BG); + LinearLayout tools = horizontal(); + tools.setGravity(Gravity.CENTER_VERTICAL); + tools.addView(screenTitle(assignedToMe ? t("myIssues") : t("issues")), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button sort = pillButton(issuesDescending ? "↓" : "↑", SOFT_BLUE, BLUE); + sort.setOnClickListener(v -> { + issuesDescending = !issuesDescending; + showIssues(assignedToMe); + }); + Button mode = pillButton(boardMode ? t("list") : t("board"), SOFT_GRAY, TEXT); + mode.setOnClickListener(v -> { + boardMode = !boardMode; + showIssues(assignedToMe); + }); + Button add = pillButton("+", BLUE, 0xFFFFFFFF); + add.setOnClickListener(v -> showIssueEditor(null, () -> showIssues(assignedToMe))); + tools.addView(sort); + tools.addView(mode); + tools.addView(add); + body.addView(tools); + if (boardMode) body.addView(issueBoard(issues)); + else body.addView(issueGroupedList(issues)); + setContent(scroll(body)); + } + + private View issueGroupedList(List issues) { + LinearLayout list = vertical(); + Map> grouped = new HashMap<>(); + for (Models.Issue issue : issues) grouped.computeIfAbsent(issue.status, k -> new ArrayList<>()).add(issue); + for (String status : Models.STATUS_VALUES) { + List bucket = grouped.get(status); + if (bucket == null) bucket = Models.emptyList(); + TextView header = label(Models.statusLabel(status, zh) + " (" + bucket.size() + ")", 13, MUTED, true); + header.setPadding(0, dp(16), 0, dp(6)); + list.addView(header); + for (Models.Issue issue : bucket) list.addView(issueRow(issue)); + } + return list; + } + + private View issueBoard(List issues) { + HorizontalScrollView scroll = new HorizontalScrollView(this); + scroll.setFillViewport(false); + LinearLayout columns = horizontal(); + Map> grouped = new HashMap<>(); + for (Models.Issue issue : issues) grouped.computeIfAbsent(issue.status, k -> new ArrayList<>()).add(issue); + for (String status : Models.STATUS_VALUES) { + LinearLayout col = vertical(); + col.setPadding(dp(8), dp(8), dp(8), dp(8)); + col.setBackground(roundedStroke(0xFFF3F4F6, BORDER, 14, 1)); + List bucket = grouped.get(status); + if (bucket == null) bucket = Models.emptyList(); + col.addView(label(Models.statusLabel(status, zh) + " (" + bucket.size() + ")", 13, TEXT, true)); + for (Models.Issue issue : bucket) col.addView(issueRow(issue)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(260), ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, dp(8), dp(12), dp(8)); + columns.addView(col, params); + } + scroll.addView(columns); + return scroll; + } + + private View issueRow(Models.Issue issue) { + LinearLayout row = card(); + TextView identifier = label(issue.identifier, 12, MUTED, true); + TextView title = label(issue.title, 16, TEXT, false); + title.setMaxLines(2); + title.setEllipsize(TextUtils.TruncateAt.END); + TextView meta = label(Models.statusLabel(issue.status, zh) + " · " + Models.priorityLabel(issue.priority, zh) + assigneeSuffix(issue), 12, MUTED, false); + meta.setPadding(0, dp(4), 0, 0); + row.addView(identifier); + row.addView(title); + row.addView(meta); + row.setOnClickListener(v -> showIssueDetail(issue.id)); + return row; + } + + private String assigneeSuffix(Models.Issue issue) { + if (issue.assigneeId == null) return ""; + return " · " + assigneeName(issue.assigneeId, issue.assigneeType); + } + + private String cleanDisplayText(String value) { + if (value == null) return ""; + String trimmed = value.trim(); + return "null".equalsIgnoreCase(trimmed) ? "" : trimmed; + } + + private String humanizeType(String value) { + String cleaned = cleanDisplayText(value); + if (cleaned.isEmpty()) return ""; + String[] parts = cleaned.split("_"); + StringBuilder builder = new StringBuilder(); + for (String part : parts) { + if (part.isEmpty()) continue; + if (builder.length() > 0) builder.append(' '); + builder.append(Character.toUpperCase(part.charAt(0))); + if (part.length() > 1) builder.append(part.substring(1)); + } + return builder.length() == 0 ? cleaned : builder.toString(); + } + + private void showIssueDetail(String issueId) { + if (demoMode) { + renderDemoIssueDetail(issueId); + return; + } + setContent(loadingView(t("loadingIssue"))); + tab = "issues"; + run(() -> IssueDetailLoader.load(issueId, currentWorkspace == null ? null : currentWorkspace.id, new IssueDetailLoader.Fetcher() { + @Override + public Models.Issue issue(String requestedIssueId, String workspaceId) throws Exception { + return api.issue(requestedIssueId, workspaceId); + } + + @Override + public List comments(String requestedIssueId, String workspaceId) throws Exception { + return api.comments(requestedIssueId, workspaceId, 100, 0).items; + } + + @Override + public List runs(String requestedIssueId, String workspaceId) throws Exception { + return api.agentRuns(requestedIssueId, workspaceId); + } + + @Override + public List projects() { + return safeProjects(); + } + + @Override + public List members() { + return safeMembers(); + } + + @Override + public List agents() { + return safeAgents(); + } + }, 5), data -> { + projectCache = data.projects; + memberCache = data.members; + agentCache = data.agents; + LinearLayout screen = vertical(); + LinearLayout scrollBody = vertical(); + scrollBody.setPadding(dp(14), dp(8), dp(14), dp(14)); + + LinearLayout top = horizontal(); + top.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹"); + back.setOnClickListener(v -> showIssues(false)); + top.addView(back); + top.addView(label(data.issue.identifier, 18, TEXT, true), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button edit = smallButton(t("edit")); + edit.setOnClickListener(v -> showIssueEditor(data.issue, () -> showIssueDetail(data.issue.id))); + top.addView(edit); + scrollBody.addView(top); + + TextView title = label(data.issue.title, 23, TEXT, true); + title.setPadding(0, dp(10), 0, dp(6)); + scrollBody.addView(title); + scrollBody.addView(label(Models.statusLabel(data.issue.status, zh) + " · " + Models.priorityLabel(data.issue.priority, zh) + assigneeSuffix(data.issue), 13, MUTED, false)); + if (data.issue.description != null && !data.issue.description.trim().isEmpty()) { + LinearLayout md = vertical(); + md.setPadding(0, dp(12), 0, dp(8)); + MarkdownRenderer.render(this, md, data.issue.description, TEXT, MUTED, BORDER); + scrollBody.addView(md); + } + + scrollBody.addView(sectionTitle(t("latestProgress"))); + Collections.sort(data.comments, (a, b) -> b.createdAt.compareTo(a.createdAt)); + if (data.comments.isEmpty()) scrollBody.addView(empty(t("emptyComments"))); + for (Models.Comment comment : data.comments) scrollBody.addView(commentView(comment)); + + scrollBody.addView(sectionTitle(t("agentActivity"))); + if (data.runs.isEmpty()) scrollBody.addView(empty(t("emptyActivity"))); + for (Models.AgentTask run : data.runs) scrollBody.addView(agentRunView(run)); + + ScrollView scroll = scroll(scrollBody); + screen.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + screen.addView(commentInput(issueId), matchWrap()); + setContent(screen); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), () -> showIssueDetail(issueId))); + } + + private void renderDemoIssueDetail(String issueId) { + Models.Issue issue = findDemoIssue(issueId); + if (issue == null) { + showError(t("loadFailed"), () -> showIssues(false)); + return; + } + LinearLayout screen = vertical(); + LinearLayout scrollBody = vertical(); + scrollBody.setPadding(dp(14), dp(8), dp(14), dp(14)); + + LinearLayout top = horizontal(); + top.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹"); + back.setOnClickListener(v -> showIssues(false)); + top.addView(back); + top.addView(label(issue.identifier, 18, TEXT, true), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button edit = smallButton(t("edit")); + edit.setOnClickListener(v -> showIssueEditor(issue, () -> renderDemoIssueDetail(issue.id))); + top.addView(edit); + scrollBody.addView(top); + + TextView title = label(issue.title, 23, TEXT, true); + title.setPadding(0, dp(10), 0, dp(6)); + scrollBody.addView(title); + scrollBody.addView(label(Models.statusLabel(issue.status, zh) + " · " + Models.priorityLabel(issue.priority, zh) + assigneeSuffix(issue), 13, MUTED, false)); + LinearLayout md = vertical(); + md.setPadding(0, dp(12), 0, dp(8)); + MarkdownRenderer.render(this, md, issue.description, TEXT, MUTED, BORDER); + scrollBody.addView(md); + + scrollBody.addView(sectionTitle(t("latestProgress"))); + List comments = demoCommentsByIssueId.get(issueId); + if (comments == null || comments.isEmpty()) scrollBody.addView(empty(t("emptyComments"))); + else for (Models.Comment comment : comments) scrollBody.addView(commentView(comment)); + + scrollBody.addView(sectionTitle(t("agentActivity"))); + List runs = demoRunsByIssueId.get(issueId); + if (runs == null || runs.isEmpty()) scrollBody.addView(empty(t("emptyActivity"))); + else for (Models.AgentTask run : runs) scrollBody.addView(agentRunView(run)); + + screen.addView(scroll(scrollBody), new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + screen.addView(commentInput(issueId), matchWrap()); + setContent(screen); + } + + private View commentView(Models.Comment comment) { + LinearLayout box = card(); + LinearLayout meta = horizontal(); + meta.setGravity(Gravity.CENTER_VERTICAL); + String name = comment.authorName == null || comment.authorName.isEmpty() + ? assigneeName(comment.authorId, comment.authorType) + : comment.authorName; + meta.addView(label(("agent".equals(comment.authorType) ? "⚡ " : "○ ") + name, 13, TEXT, true), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + meta.addView(label(shortDate(comment.createdAt), 11, MUTED, false)); + box.addView(meta); + LinearLayout md = vertical(); + md.setPadding(0, dp(6), 0, 0); + MarkdownRenderer.render(this, md, comment.content, TEXT, MUTED, BORDER); + box.addView(md); + return box; + } + + private View agentRunView(Models.AgentTask run) { + LinearLayout row = card(); + LinearLayout line = horizontal(); + line.setGravity(Gravity.CENTER_VERTICAL); + int color = "completed".equals(run.status) ? GREEN : "failed".equals(run.status) ? RED : BLUE; + line.addView(label("●", 16, color, true)); + line.addView(label(" " + t("agentRun") + " · " + run.status, 15, TEXT, true), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button open = smallButton(t("details")); + open.setOnClickListener(v -> showTranscript(run.id)); + line.addView(open); + row.addView(line); + row.addView(label(shortDate(run.startedAt), 12, MUTED, false)); + if (run.error != null && !run.error.isEmpty()) row.addView(label(run.error, 13, RED, false)); + return row; + } + + private View commentInput(String issueId) { + LinearLayout bar = horizontal(); + bar.setPadding(dp(12), dp(8), dp(12), dp(8)); + bar.setBackgroundColor(0xFFFFFFFF); + EditText input = input(t("commentPlaceholder")); + input.setMinLines(1); + input.setMaxLines(4); + Button send = smallButton("↑"); + send.setTextSize(20); + send.setTextColor(BLUE); + send.setOnClickListener(v -> { + String content = input.getText().toString().trim(); + if (content.isEmpty()) return; + if (demoMode) { + addDemoComment(issueId, content); + renderDemoIssueDetail(issueId); + return; + } + send.setEnabled(false); + String workspaceId = currentWorkspace == null ? null : currentWorkspace.id; + run(() -> api.addComment(issueId, workspaceId, content), comment -> showIssueDetail(issueId), error -> { + send.setEnabled(true); + toast(t("commentFailed")); + }); + }); + bar.addView(input, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + bar.addView(send); + return bar; + } + + private void showTranscript(String runId) { + LinearLayout body = vertical(); + body.setPadding(dp(12), dp(8), dp(12), dp(8)); + body.addView(loadingView(t("loading"))); + AlertDialog dialog = new AlertDialog.Builder(this).setTitle(t("transcript")).setView(scroll(body)).setPositiveButton(t("done"), null).show(); + if (demoMode) { + body.removeAllViews(); + String[] rows = { + "#1 · thinking\nReviewing Android parity checklist.", + "#2 · tool_use · gradle\nassembleDebug", + "#3 · tool_result\nBUILD SUCCESSFUL", + "#4 · text\nMarkdown table renderer and issue editor are ready for inspection." + }; + for (String rowText : rows) { + LinearLayout row = card(); + row.addView(label(rowText, 13, TEXT, false)); + body.addView(row); + } + return; + } + String workspaceId = currentWorkspace == null ? null : currentWorkspace.id; + run(() -> api.runMessages(runId, workspaceId), messages -> { + body.removeAllViews(); + if (messages.isEmpty()) body.addView(empty(t("emptyActivity"))); + for (Models.TaskMessage msg : messages) { + LinearLayout row = card(); + row.addView(label("#" + msg.seq + " · " + msg.type + (msg.tool.isEmpty() ? "" : " · " + msg.tool), 12, MUTED, true)); + row.addView(label(messageSummary(msg), 13, TEXT, false)); + body.addView(row); + } + }, error -> { + body.removeAllViews(); + body.addView(label(t("loadFailed") + "\n" + error.getMessage(), 14, RED, false)); + }); + } + + private String messageSummary(Models.TaskMessage msg) { + String text = msg.content == null || msg.content.isEmpty() ? msg.output : msg.content; + if (text == null) text = ""; + return text.length() > 1200 ? text.substring(0, 1200) + "…" : text; + } + + private void showIssueEditor(Models.Issue issue, Runnable afterSave) { + boolean editing = issue != null; + LinearLayout form = vertical(); + form.setPadding(dp(12), dp(8), dp(12), dp(8)); + EditText title = input(t("title")); + title.setText(editing ? issue.title : ""); + EditText desc = input(t("description")); + desc.setMinLines(4); + desc.setGravity(Gravity.TOP); + desc.setText(editing ? issue.description : ""); + Spinner project = spinner(projectLabels(true)); + Spinner status = spinner(statusLabels()); + Spinner priority = spinner(priorityLabels()); + Spinner assignee = spinner(assigneeLabels(true)); + if (editing) { + selectValue(status, Models.STATUS_VALUES, issue.status); + selectValue(priority, Models.PRIORITY_VALUES, issue.priority); + selectProject(project, issue.projectId); + selectAssignee(assignee, issue.assigneeId, issue.assigneeType); + } else { + selectValue(status, Models.STATUS_VALUES, "todo"); + selectValue(priority, Models.PRIORITY_VALUES, "medium"); + } + form.addView(label(t("title"), 12, MUTED, true)); + form.addView(title); + form.addView(label(t("description"), 12, MUTED, true)); + form.addView(desc); + form.addView(label(t("project"), 12, MUTED, true)); + form.addView(project); + form.addView(label(t("status"), 12, MUTED, true)); + form.addView(status); + form.addView(label(t("priority"), 12, MUTED, true)); + form.addView(priority); + form.addView(label(t("assignee"), 12, MUTED, true)); + form.addView(assignee); + + new AlertDialog.Builder(this) + .setTitle(editing ? t("editIssue") : t("newIssue")) + .setView(scroll(form)) + .setNegativeButton(t("cancel"), null) + .setPositiveButton(editing ? t("save") : t("create"), (dialog, which) -> { + String titleText = title.getText().toString().trim(); + if (titleText.isEmpty()) { + toast(t("titleRequired")); + return; + } + String projectId = projectIdAt(project.getSelectedItemPosition(), true); + String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())]; + String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())]; + Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true); + if (demoMode) { + upsertDemoIssue( + editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1), + titleText, + desc.getText().toString(), + projectId, + statusValue, + priorityValue, + selectedAssignee + ); + afterSave.run(); + return; + } + run(() -> { + if (editing) { + return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee); + } + if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired")); + return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, statusValue, priorityValue, selectedAssignee); + }, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage())); + }).show(); + } + + private void showProjects() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderProjects(projectCache); + return; + } + setContent(loadingView(t("loadingProjects"))); + run(() -> api.projects(currentWorkspace.id, 100, 0).items, projects -> { + projectCache = projects; + renderProjects(projects); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showProjects)); + } + + private void renderProjects(List projects) { + LinearLayout body = vertical(); + body.setPadding(dp(18), dp(14), dp(18), dp(18)); + body.setBackgroundColor(APP_BG); + body.addView(screenTitle(t("projects"))); + if (projects.isEmpty()) body.addView(empty(t("emptyProjects"))); + for (Models.Project project : projects) { + LinearLayout row = card(); + row.addView(label(project.name, 16, TEXT, true)); + String description = cleanDisplayText(project.description); + if (!description.isEmpty()) { + TextView preview = label(description, 13, MUTED, false); + preview.setMaxLines(2); + preview.setEllipsize(TextUtils.TruncateAt.END); + row.addView(preview); + } + row.setOnClickListener(v -> showProjectDetail(project)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showProjectDetail(Models.Project project) { + if (demoMode) { + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + Button back = smallButton("‹ " + t("projects")); + back.setOnClickListener(v -> showProjects()); + body.addView(back); + body.addView(screenTitle(project.name)); + if (project.description != null && !project.description.isEmpty()) body.addView(label(project.description, 14, MUTED, false)); + body.addView(sectionTitle(t("issues"))); + int count = 0; + for (Models.Issue issue : demoIssues) { + if (project.id.equals(issue.projectId)) { + body.addView(issueRow(issue)); + count++; + } + } + if (count == 0) body.addView(empty(t("emptyIssues"))); + setContent(scroll(body)); + return; + } + setContent(loadingView(t("loadingIssues"))); + run(() -> IssueBuckets.loadAllConcurrent((status, limit, offset) -> + api.projectIssues(currentWorkspace.id, project.id, status, limit, offset), 100), issues -> { + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + Button back = smallButton("‹ " + t("projects")); + back.setOnClickListener(v -> showProjects()); + body.addView(back); + body.addView(screenTitle(project.name)); + if (project.description != null && !project.description.isEmpty()) body.addView(label(project.description, 14, MUTED, false)); + body.addView(sectionTitle(t("issues"))); + int count = 0; + for (Models.Issue issue : issues) { + if (project.id.equals(issue.projectId)) { + body.addView(issueRow(issue)); + count++; + } + } + if (count == 0) body.addView(empty(t("emptyIssues"))); + setContent(scroll(body)); + }, error -> showError(t("loadFailed"), () -> showProjectDetail(project))); + } + + private void showAgents() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderAgents(agentCache); + return; + } + setContent(loadingView(t("loadingAgents"))); + run(() -> { + AgentsData data = new AgentsData(); + data.agents = api.agents(currentWorkspace.id); + try { + data.runtimes = api.runtimes(currentWorkspace.id); + } catch (Exception ignored) { + data.runtimes = runtimeCache; + } + return data; + }, data -> { + agentCache = data.agents; + runtimeCache = data.runtimes; + renderAgents(data.agents); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showAgents)); + } + + private void renderAgents(List agents) { + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + LinearLayout tools = horizontal(); + tools.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹"); + back.setOnClickListener(v -> showSettings()); + tools.addView(back); + tools.addView(screenTitle(t("agents")), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button add = smallButton("+"); + add.setOnClickListener(v -> showAgentCreator()); + tools.addView(add); + body.addView(tools); + if (agents.isEmpty()) body.addView(empty(t("emptyAgents"))); + for (Models.Agent agent : agents) { + LinearLayout row = card(); + LinearLayout titleRow = horizontal(); + titleRow.setGravity(Gravity.CENTER_VERTICAL); + titleRow.addView(label("⚡ " + agent.name, 16, TEXT, true), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + if (agent.archivedAt == null || agent.archivedAt.isEmpty()) { + Button archive = smallButton(t("archive")); + archive.setOnClickListener(v -> confirmArchiveAgent(agent)); + titleRow.addView(archive); + } + row.addView(titleRow); + if (agent.description != null && !agent.description.isEmpty()) row.addView(label(agent.description, 13, MUTED, false)); + if (agent.status != null && !agent.status.isEmpty()) row.addView(label(agent.status, 12, MUTED, false)); + if (agent.archivedAt != null && !agent.archivedAt.isEmpty()) row.addView(label(t("archived") + " · " + shortDate(agent.archivedAt), 12, MUTED, false)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void confirmArchiveAgent(Models.Agent agent) { + new AlertDialog.Builder(this) + .setTitle(t("archiveAgent")) + .setMessage(agent.name) + .setNegativeButton(t("cancel"), null) + .setPositiveButton(t("archive"), (dialog, which) -> { + if (demoMode) { + agentCache.remove(agent); + showAgents(); + return; + } + if (currentWorkspace == null) { + toast(t("workspaceRequired")); + return; + } + run(() -> api.archiveAgent(currentWorkspace.id, agent.id), + ignored -> showAgents(), + error -> toast(t("saveFailed") + ": " + error.getMessage())); + }).show(); + } + + private void showAgentCreator() { + LinearLayout form = vertical(); + form.setPadding(dp(12), dp(8), dp(12), dp(8)); + EditText name = input(t("name")); + EditText desc = input(t("description")); + desc.setMinLines(3); + Spinner runtime = spinner(runtimeLabels()); + form.addView(label(t("name"), 12, MUTED, true)); + form.addView(name); + form.addView(label(t("description"), 12, MUTED, true)); + form.addView(desc); + form.addView(label(t("runtime"), 12, MUTED, true)); + form.addView(runtime); + new AlertDialog.Builder(this) + .setTitle(t("newAgent")) + .setView(form) + .setNegativeButton(t("cancel"), null) + .setPositiveButton(t("create"), (dialog, which) -> { + if (name.getText().toString().trim().isEmpty()) return; + String runtimeId = runtimeIdAt(runtime.getSelectedItemPosition()); + if (runtimeId == null) { + toast(t("runtimeRequired")); + return; + } + if (demoMode) { + try { + Models.Agent agent = new Models.Agent(json( + "id", "demo-agent-" + (agentCache.size() + 1), + "name", name.getText().toString().trim(), + "description", desc.getText().toString(), + "status", "idle" + )); + agentCache.add(agent); + showAgents(); + } catch (Exception error) { + toast(error.getMessage()); + } + return; + } + run(() -> api.createAgent(currentWorkspace.id, name.getText().toString().trim(), desc.getText().toString(), runtimeId), + agent -> showAgents(), + error -> toast(t("saveFailed") + ": " + error.getMessage())); + }).show(); + } + + private void showAutopilots() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderAutopilots(autopilotCache); + return; + } + setContent(loadingView(t("loadingAutopilots"))); + run(() -> api.autopilots(currentWorkspace.id), autopilots -> { + autopilotCache = autopilots; + renderAutopilots(autopilots); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showAutopilots)); + } + + private void renderAutopilots(List autopilots) { + LinearLayout body = configPageShell(t("autopilots")); + if (autopilots.isEmpty()) body.addView(empty(t("emptyAutopilots"))); + for (Models.Autopilot autopilot : autopilots) { + LinearLayout row = card(); + row.addView(label(autopilot.title, 16, TEXT, true)); + if (autopilot.description != null && !autopilot.description.isEmpty()) row.addView(label(autopilot.description, 13, MUTED, false)); + if (autopilot.status != null && !autopilot.status.isEmpty()) row.addView(label(autopilot.status, 12, MUTED, false)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showRuntimes() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderRuntimes(runtimeCache); + return; + } + setContent(loadingView(t("loadingRuntimes"))); + run(() -> api.runtimes(currentWorkspace.id), runtimes -> { + runtimeCache = runtimes; + renderRuntimes(runtimes); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showRuntimes)); + } + + private void renderRuntimes(List runtimes) { + LinearLayout body = configPageShell(t("runtimes")); + if (runtimes.isEmpty()) body.addView(empty(t("emptyRuntimes"))); + for (Models.Runtime runtime : runtimes) { + LinearLayout row = card(); + row.addView(label(runtime.name, 16, TEXT, true)); + row.addView(label((runtime.status == null ? "" : runtime.status) + (runtime.version == null || runtime.version.isEmpty() ? "" : " · " + runtime.version), 13, MUTED, false)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showSkills() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderSkills(skillCache); + return; + } + setContent(loadingView(t("loadingSkills"))); + run(() -> api.skills(currentWorkspace.id), skills -> { + skillCache = skills; + renderSkills(skills); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showSkills)); + } + + private void renderSkills(List skills) { + LinearLayout body = configPageShell(t("skills")); + LinearLayout tools = (LinearLayout) body.getChildAt(0); + Button add = smallButton("+"); + add.setOnClickListener(v -> showSkillCreator()); + tools.addView(add); + if (skills.isEmpty()) body.addView(empty(t("emptySkills"))); + for (Models.Skill skill : skills) { + LinearLayout row = card(); + row.addView(label(skill.name, 16, TEXT, true)); + if (skill.description != null && !skill.description.isEmpty()) row.addView(label(skill.description, 13, MUTED, false)); + body.addView(row); + } + setContent(scroll(body)); + } + + private LinearLayout configPageShell(String titleText) { + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + LinearLayout tools = horizontal(); + tools.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹"); + back.setOnClickListener(v -> showSettings()); + tools.addView(back); + tools.addView(screenTitle(titleText), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + body.addView(tools); + return body; + } + + private void showSkillCreator() { + LinearLayout form = vertical(); + form.setPadding(dp(12), dp(8), dp(12), dp(8)); + EditText name = input(t("name")); + EditText desc = input(t("description")); + EditText content = input(t("content")); + content.setMinLines(5); + content.setGravity(Gravity.TOP); + form.addView(label(t("name"), 12, MUTED, true)); + form.addView(name); + form.addView(label(t("description"), 12, MUTED, true)); + form.addView(desc); + form.addView(label(t("content"), 12, MUTED, true)); + form.addView(content); + new AlertDialog.Builder(this) + .setTitle(t("newSkill")) + .setView(scroll(form)) + .setNegativeButton(t("cancel"), null) + .setPositiveButton(t("create"), (dialog, which) -> { + String skillName = name.getText().toString().trim(); + if (skillName.isEmpty()) return; + if (demoMode) { + try { + skillCache.add(new Models.Skill(json( + "id", "demo-skill-" + (skillCache.size() + 1), + "name", skillName, + "description", desc.getText().toString(), + "content", content.getText().toString() + ))); + showSkills(); + } catch (Exception error) { + toast(error.getMessage()); + } + return; + } + run(() -> api.createSkill(currentWorkspace.id, skillName, desc.getText().toString(), content.getText().toString()), + skill -> showSkills(), + error -> toast(t("saveFailed") + ": " + error.getMessage())); + }).show(); + } + + private void showWorkspaceDetails() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderTextRows(t("workspaceDetails"), Collections.singletonList("Mobile QA\nmobile-qa\nAndroid parity acceptance workspace.\nFocus: mobile feature parity.")); + return; + } + setContent(loadingView(t("loading"))); + run(() -> Collections.singletonList(api.workspaceSummary(currentWorkspace.id)), + rows -> renderTextRows(t("workspaceDetails"), rows), + error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showWorkspaceDetails)); + } + + private void showMembers() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + List rows = new ArrayList<>(); + rows.add("Demo Member\ndemo@multica.ai · owner"); + rows.add("Codex Mobile\nagent · workspace automation"); + renderTextRows(t("members"), rows); + return; + } + setContent(loadingView(t("loading"))); + run(() -> api.memberSummaries(currentWorkspace.id), + rows -> renderTextRows(t("members"), rows), + error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showMembers)); + } + + private void showNotifications() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + List rows = new ArrayList<>(); + rows.add("assignments\nall"); + rows.add("comments\nall"); + rows.add("agent_activity\nall"); + renderTextRows(t("notifications"), rows); + return; + } + setContent(loadingView(t("loading"))); + run(() -> api.notificationSummaries(currentWorkspace.id), + rows -> renderTextRows(t("notifications"), rows), + error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showNotifications)); + } + + private void showTokens() { + if (demoMode) { + List rows = new ArrayList<>(); + rows.add("Local CLI\nmca_demo_**** · never used"); + renderTextRows(t("apiTokens"), rows); + return; + } + setContent(loadingView(t("loading"))); + run(() -> api.tokenSummaries(), + rows -> renderTextRows(t("apiTokens"), rows), + error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showTokens)); + } + + private void renderTextRows(String titleText, List rows) { + LinearLayout body = configPageShell(titleText); + if (rows.isEmpty()) body.addView(empty(t("empty"))); + for (String text : rows) { + LinearLayout row = card(); + MarkdownRenderer.render(this, row, text, TEXT, MUTED, BORDER); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showWorkspaces() { + LinearLayout body = configPageShell(t("workspaces")); + if (workspaces.isEmpty()) body.addView(empty(t("empty"))); + for (Models.Workspace workspace : workspaces) { + LinearLayout row = card(); + boolean selected = currentWorkspace != null && currentWorkspace.id.equals(workspace.id); + row.addView(label((selected ? "✓ " : "") + workspace.name, 16, TEXT, true)); + row.addView(label(workspace.slug + " · " + Models.shortId(workspace.id), 12, MUTED, false)); + row.setOnClickListener(v -> { + currentWorkspace = workspace; + authStore.saveWorkspaceId(workspace.id); + projectCache = new ArrayList<>(); + agentCache = new ArrayList<>(); + runtimeCache = new ArrayList<>(); + skillCache = new ArrayList<>(); + autopilotCache = new ArrayList<>(); + labelCache = new ArrayList<>(); + chatSessionCache = new ArrayList<>(); + showShell(); + }); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showLabels() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderLabels(labelCache); + return; + } + setContent(loadingView(t("loading"))); + run(() -> api.labels(currentWorkspace.id), labels -> { + labelCache = labels; + renderLabels(labels); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showLabels)); + } + + private void renderLabels(List labels) { + LinearLayout body = configPageShell(t("labels")); + LinearLayout tools = (LinearLayout) body.getChildAt(0); + Button add = smallButton("+"); + add.setOnClickListener(v -> showLabelCreator()); + tools.addView(add); + if (labels.isEmpty()) body.addView(empty(t("emptyLabels"))); + for (Models.IssueLabel label : labels) { + LinearLayout row = card(); + row.addView(label(label.name, 16, TEXT, true)); + row.addView(label(label.color + " · " + Models.shortId(label.id), 12, MUTED, false)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showLabelCreator() { + LinearLayout form = vertical(); + form.setPadding(dp(12), dp(8), dp(12), dp(8)); + EditText name = input(t("name")); + EditText color = input("#2563EB"); + color.setText("#2563EB"); + form.addView(label(t("name"), 12, MUTED, true)); + form.addView(name); + form.addView(label(t("color"), 12, MUTED, true)); + form.addView(color); + new AlertDialog.Builder(this) + .setTitle(t("newLabel")) + .setView(form) + .setNegativeButton(t("cancel"), null) + .setPositiveButton(t("create"), (dialog, which) -> { + String labelName = name.getText().toString().trim(); + if (labelName.isEmpty()) return; + String rawColor = color.getText().toString().trim(); + final String labelColor = rawColor.isEmpty() ? "#2563EB" : rawColor; + if (demoMode) { + try { + labelCache.add(new Models.IssueLabel(json("id", "demo-label-" + (labelCache.size() + 1), "name", labelName, "color", labelColor))); + showLabels(); + } catch (Exception error) { + toast(error.getMessage()); + } + return; + } + run(() -> api.createLabel(currentWorkspace.id, labelName, labelColor), + label -> showLabels(), + error -> toast(t("saveFailed") + ": " + error.getMessage())); + }).show(); + } + + private void showFeedback() { + LinearLayout form = vertical(); + form.setPadding(dp(14), dp(8), dp(14), dp(14)); + LinearLayout tools = horizontal(); + tools.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹"); + back.setOnClickListener(v -> showSettings()); + tools.addView(back); + tools.addView(screenTitle(t("feedback")), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + form.addView(tools); + EditText message = input(t("feedbackMessage")); + message.setMinLines(5); + message.setGravity(Gravity.TOP); + EditText url = input("https://..."); + Button send = button(t("send"), BLUE, 0xFFFFFFFF); + form.addView(label(t("message"), 12, MUTED, true)); + form.addView(message); + form.addView(label("URL", 12, MUTED, true)); + form.addView(url); + form.addView(spacer(10)); + form.addView(send, matchWrap()); + send.setOnClickListener(v -> { + String text = message.getText().toString().trim(); + if (text.isEmpty()) { + toast(t("feedbackRequired")); + return; + } + if (demoMode) { + toast(t("feedbackSent")); + showSettings(); + return; + } + if (currentWorkspace == null) { + toast(t("workspaceRequired")); + return; + } + send.setEnabled(false); + run(() -> { + api.createFeedback(currentWorkspace.id, text, url.getText().toString()); + return true; + }, + ignored -> { + toast(t("feedbackSent")); + showSettings(); + }, + error -> { + send.setEnabled(true); + toast(t("sendFailed") + ": " + error.getMessage()); + }); + }); + setContent(scroll(form)); + } + + private void showSettings() { + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + body.addView(screenTitle(t("settings"))); + LinearLayout account = card(); + if (currentUser != null) { + account.addView(label(currentUser.name, 17, TEXT, true)); + account.addView(label(currentUser.email, 13, MUTED, false)); + } + if (currentWorkspace != null) account.addView(label(t("workspace") + ": " + currentWorkspace.name, 13, MUTED, false)); + body.addView(account); + Button language = button(zh ? "Language: 中文" : "Language: English", 0xFFE5E7EB, TEXT); + language.setOnClickListener(v -> { + zh = !zh; + authStore.setChinese(zh); + showShell(); + }); + body.addView(language, matchWrap()); + body.addView(spacer(8)); + Button workspacesButton = button(t("workspaces"), 0xFFE5E7EB, TEXT); + workspacesButton.setOnClickListener(v -> showWorkspaces()); + body.addView(workspacesButton, matchWrap()); + body.addView(spacer(8)); + Button workspace = button(t("workspaceDetails"), 0xFFE5E7EB, TEXT); + workspace.setOnClickListener(v -> showWorkspaceDetails()); + body.addView(workspace, matchWrap()); + body.addView(spacer(8)); + Button members = button(t("members"), 0xFFE5E7EB, TEXT); + members.setOnClickListener(v -> showMembers()); + body.addView(members, matchWrap()); + body.addView(spacer(8)); + Button notifications = button(t("notifications"), 0xFFE5E7EB, TEXT); + notifications.setOnClickListener(v -> showNotifications()); + body.addView(notifications, matchWrap()); + body.addView(spacer(8)); + Button tokens = button(t("apiTokens"), 0xFFE5E7EB, TEXT); + tokens.setOnClickListener(v -> showTokens()); + body.addView(tokens, matchWrap()); + body.addView(spacer(8)); + Button labels = button(t("labels"), 0xFFE5E7EB, TEXT); + labels.setOnClickListener(v -> showLabels()); + body.addView(labels, matchWrap()); + body.addView(spacer(8)); + Button agents = button(t("agents"), 0xFFE5E7EB, TEXT); + agents.setOnClickListener(v -> showAgents()); + body.addView(agents, matchWrap()); + body.addView(spacer(8)); + Button autopilots = button(t("autopilots"), 0xFFE5E7EB, TEXT); + autopilots.setOnClickListener(v -> showAutopilots()); + body.addView(autopilots, matchWrap()); + body.addView(spacer(8)); + Button runtimes = button(t("runtimes"), 0xFFE5E7EB, TEXT); + runtimes.setOnClickListener(v -> showRuntimes()); + body.addView(runtimes, matchWrap()); + body.addView(spacer(8)); + Button skills = button(t("skills"), 0xFFE5E7EB, TEXT); + skills.setOnClickListener(v -> showSkills()); + body.addView(skills, matchWrap()); + body.addView(spacer(8)); + Button feedback = button(t("feedback"), 0xFFE5E7EB, TEXT); + feedback.setOnClickListener(v -> showFeedback()); + body.addView(feedback, matchWrap()); + body.addView(spacer(8)); + body.addView(label(t("configureHint"), 13, MUTED, false)); + body.addView(spacer(8)); + Button logout = button(t("logout"), RED, 0xFFFFFFFF); + logout.setOnClickListener(v -> { + authStore.clearToken(); + currentUser = null; + currentWorkspace = null; + showLogin(); + }); + body.addView(logout, matchWrap()); + setContent(scroll(body)); + } + + private void showChatSessions() { + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + if (demoMode) { + renderChatSessions(chatSessionCache); + return; + } + setContent(loadingView(t("loadingChat"))); + run(() -> { + if (agentCache.isEmpty()) agentCache = safeAgents(); + return api.chatSessions(currentWorkspace.id); + }, sessions -> { + chatSessionCache = sessions; + renderChatSessions(sessions); + }, error -> showError(t("loadFailed") + "\n" + error.getMessage(), this::showChatSessions)); + } + + private void renderChatSessions(List sessions) { + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + LinearLayout tools = horizontal(); + tools.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹ " + t("inbox")); + back.setOnClickListener(v -> showInbox()); + tools.addView(back); + tools.addView(screenTitle(t("chat")), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + Button add = smallButton("+"); + add.setOnClickListener(v -> showChatCreator()); + tools.addView(add); + body.addView(tools); + if (sessions.isEmpty()) body.addView(empty(t("emptyChat"))); + for (Models.ChatSession session : sessions) { + LinearLayout row = card(); + row.addView(label((session.hasUnread ? "● " : "") + session.title, 16, TEXT, true)); + row.addView(label(assigneeName(session.agentId, "agent") + " · " + session.status + " · " + shortDate(session.updatedAt), 12, MUTED, false)); + row.setOnClickListener(v -> showChatMessages(session)); + body.addView(row); + } + setContent(scroll(body)); + } + + private void showChatCreator() { + if (agentCache.isEmpty() && !demoMode) { + toast(t("loadAgentsFirst")); + return; + } + LinearLayout form = vertical(); + form.setPadding(dp(12), dp(8), dp(12), dp(8)); + EditText title = input(t("title")); + Spinner agent = spinner(agentLabels()); + form.addView(label(t("title"), 12, MUTED, true)); + form.addView(title); + form.addView(label(t("agent"), 12, MUTED, true)); + form.addView(agent); + new AlertDialog.Builder(this) + .setTitle(t("newChat")) + .setView(form) + .setNegativeButton(t("cancel"), null) + .setPositiveButton(t("create"), (dialog, which) -> { + Models.Agent selectedAgent = agentAt(agent.getSelectedItemPosition()); + if (selectedAgent == null) { + toast(t("agentRequired")); + return; + } + String chatTitle = title.getText().toString().trim(); + if (demoMode) { + try { + Models.ChatSession session = new Models.ChatSession(json( + "id", "demo-chat-" + (chatSessionCache.size() + 1), + "title", chatTitle.isEmpty() ? "Android QA Chat" : chatTitle, + "agent_id", selectedAgent.id, + "status", "active", + "has_unread", false, + "updated_at", "2026-05-08T09:15:00Z" + )); + chatSessionCache.add(0, session); + demoChatMessagesBySessionId.put(session.id, new ArrayList<>()); + showChatMessages(session); + } catch (Exception error) { + toast(error.getMessage()); + } + return; + } + run(() -> api.createChatSession(currentWorkspace.id, selectedAgent.id, chatTitle), + this::showChatMessages, + error -> toast(t("saveFailed") + ": " + error.getMessage())); + }).show(); + } + + private void showChatMessages(Models.ChatSession session) { + if (demoMode) { + List messages = demoChatMessagesBySessionId.get(session.id); + renderChatMessages(session, messages == null ? Models.emptyList() : messages); + return; + } + if (currentWorkspace == null) { + showError(t("workspaceRequired"), this::restoreSession); + return; + } + setContent(loadingView(t("loadingChat"))); + run(() -> api.chatMessages(currentWorkspace.id, session.id), + messages -> renderChatMessages(session, messages), + error -> showError(t("loadFailed") + "\n" + error.getMessage(), () -> showChatMessages(session))); + } + + private void renderChatMessages(Models.ChatSession session, List messages) { + LinearLayout screen = vertical(); + LinearLayout body = vertical(); + body.setPadding(dp(14), dp(8), dp(14), dp(14)); + LinearLayout top = horizontal(); + top.setGravity(Gravity.CENTER_VERTICAL); + Button back = smallButton("‹ " + t("chat")); + back.setOnClickListener(v -> showChatSessions()); + top.addView(back); + top.addView(screenTitle(session.title), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + body.addView(top); + body.addView(label(assigneeName(session.agentId, "agent") + " · " + session.status, 12, MUTED, false)); + if (messages.isEmpty()) body.addView(empty(t("emptyMessages"))); + for (Models.ChatMessage message : messages) { + LinearLayout row = card(); + row.addView(label("user".equals(message.role) ? t("you") : assigneeName(session.agentId, "agent"), 12, MUTED, true)); + LinearLayout md = vertical(); + MarkdownRenderer.render(this, md, message.content, TEXT, MUTED, BORDER); + row.addView(md); + if (message.failureReason != null && !message.failureReason.isEmpty()) row.addView(label(message.failureReason, 12, RED, false)); + body.addView(row); + } + screen.addView(scroll(body), new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + screen.addView(chatInput(session), matchWrap()); + setContent(screen); + } + + private View chatInput(Models.ChatSession session) { + LinearLayout bar = horizontal(); + bar.setPadding(dp(12), dp(8), dp(12), dp(8)); + EditText input = input(t("messagePlaceholder")); + input.setMaxLines(4); + Button send = smallButton("↑"); + send.setTextSize(20); + send.setTextColor(BLUE); + send.setOnClickListener(v -> { + String content = input.getText().toString().trim(); + if (content.isEmpty()) return; + if (demoMode) { + addDemoChatMessage(session, "user", content); + addDemoChatMessage(session, "assistant", "收到,我会基于当前 workspace 继续处理。\n\n| 类型 | 状态 |\n|---|---|\n| Android | ready |"); + showChatMessages(session); + return; + } + send.setEnabled(false); + run(() -> { + api.sendChatMessage(currentWorkspace.id, session.id, content); + return true; + }, + ignored -> showChatMessages(session), + error -> { + send.setEnabled(true); + toast(t("sendFailed") + ": " + error.getMessage()); + }); + }); + input.setImeOptions(EditorInfo.IME_ACTION_SEND); + input.setOnEditorActionListener((view, actionId, event) -> { + boolean enterUp = event != null + && event.getKeyCode() == KeyEvent.KEYCODE_ENTER + && event.getAction() == KeyEvent.ACTION_UP; + if (actionId == EditorInfo.IME_ACTION_SEND || enterUp) { + send.performClick(); + return true; + } + return false; + }); + bar.addView(input, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + bar.addView(send); + return bar; + } + + private void showChatDialog() { + LinearLayout body = vertical(); + body.setPadding(dp(10), dp(8), dp(10), dp(8)); + body.addView(label(t("chatHint"), 14, MUTED, false)); + new AlertDialog.Builder(this) + .setTitle(t("chat")) + .setView(body) + .setPositiveButton(t("done"), null) + .show(); + } + + private void handleDeepLink(Uri uri) { + String host = uri.getHost(); + if ("issues".equals(host)) tab = "issues"; + else if ("inbox".equals(host)) tab = "inbox"; + } + + private void setupDemoData() { + try { + currentUser = new Models.User(json("id", "demo-user", "email", "demo@multica.ai", "name", "Demo Member")); + currentWorkspace = new Models.Workspace(json("id", "demo-workspace", "name", "Mobile QA", "slug", "mobile-qa", "issue_prefix", "MQA")); + workspaces = new ArrayList<>(); + workspaces.add(currentWorkspace); + runtimeCache = new ArrayList<>(); + runtimeCache.add(new Models.Runtime(json("id", "demo-runtime", "name", "Default Runtime", "status", "online", "version", "2026.05"))); + agentCache = new ArrayList<>(); + agentCache.add(new Models.Agent(json("id", "demo-agent", "name", "Codex Mobile", "description", "Handles Android acceptance tasks.", "status", "idle"))); + skillCache = new ArrayList<>(); + skillCache.add(new Models.Skill(json("id", "demo-skill", "name", "Android QA", "description", "Checklist-driven Android acceptance.", "content", "Run build, install, inspect UI."))); + autopilotCache = new ArrayList<>(); + autopilotCache.add(new Models.Autopilot(json("id", "demo-autopilot", "title", "Daily mobile triage", "description", "Creates follow-up issues for mobile regressions.", "status", "active", "assignee_id", "demo-agent"))); + labelCache = new ArrayList<>(); + labelCache.add(new Models.IssueLabel(json("id", "demo-label-1", "name", "android", "color", "#2563EB"))); + labelCache.add(new Models.IssueLabel(json("id", "demo-label-2", "name", "qa", "color", "#16A34A"))); + projectCache = new ArrayList<>(); + projectCache.add(new Models.Project(json("id", "demo-project", "name", "Android Parity", "description", "Match iOS and web mobile workflows.", "workspace_id", currentWorkspace.id, "created_at", "2026-05-08T08:00:00Z"))); + chatSessionCache = new ArrayList<>(); + chatSessionCache.add(new Models.ChatSession(json("id", "demo-chat-1", "title", "Android acceptance sync", "agent_id", "demo-agent", "status", "active", "has_unread", true, "updated_at", "2026-05-08T09:10:00Z"))); + demoIssues = new ArrayList<>(); + demoIssues.add(makeIssue("demo-issue-1", "MQA-101", 101, "Markdown comments render tables", "## 验收内容\n\n| 模块 | 状态 | 备注 |\n|---|---|---|\n| Comments | Pass | 表格可横向滚动 |\n| Code | Pass | `inline` 与代码块 |\n\n> 这条描述用于 Android 本地验收。", "in_progress", "high", "demo-user", "member", "demo-project", "2026-05-08T08:30:00Z")); + demoIssues.add(makeIssue("demo-issue-2", "MQA-102", 102, "Agent activity opens transcript", "Agent activity should open without gray screen.", "todo", "medium", "demo-agent", "agent", "demo-project", "2026-05-08T07:30:00Z")); + demoIssues.add(makeIssue("demo-issue-3", "MQA-103", 103, "Workspace scoped lists stay fast", "Switching tabs keeps the selected workspace.", "done", "low", null, null, null, "2026-05-08T06:00:00Z")); + + demoInbox = new ArrayList<>(); + demoInbox.add(new Models.InboxItem(json("id", "demo-inbox-1", "issue_id", "demo-issue-1", "issue_identifier", "MQA-101", "issue_title", "Markdown comments render tables", "read", false, "created_at", "2026-05-08T08:45:00Z"))); + demoInbox.add(new Models.InboxItem(json("id", "demo-inbox-2", "issue_id", "demo-issue-2", "issue_identifier", "MQA-102", "issue_title", "Agent activity opens transcript", "read", true, "created_at", "2026-05-08T08:15:00Z"))); + + demoCommentsByIssueId.clear(); + List comments = new ArrayList<>(); + comments.add(new Models.Comment(json("id", "demo-comment-1", "content", "**最新进展**\n\n| Step | Result |\n|---|---|\n| Build | Passed |\n| Install | Passed |", "author_id", "demo-user", "author_type", "member", "author_name", "Demo Member", "issue_id", "demo-issue-1", "created_at", "2026-05-08T08:50:00Z"))); + comments.add(new Models.Comment(json("id", "demo-comment-2", "content", "Agent 已补充 `task-runs` 路径。", "author_id", "demo-agent", "author_type", "agent", "author_name", "Codex Mobile", "issue_id", "demo-issue-1", "created_at", "2026-05-08T08:40:00Z"))); + demoCommentsByIssueId.put("demo-issue-1", comments); + + demoRunsByIssueId.clear(); + List runs = new ArrayList<>(); + runs.add(new Models.AgentTask(json("id", "demo-run-1", "issue_id", "demo-issue-1", "agent_id", "demo-agent", "status", "completed", "started_at", "2026-05-08T08:35:00Z", "completed_at", "2026-05-08T08:39:00Z"))); + demoRunsByIssueId.put("demo-issue-1", runs); + + demoChatMessagesBySessionId.clear(); + List chatMessages = new ArrayList<>(); + chatMessages.add(new Models.ChatMessage(json("id", "demo-chat-message-1", "chat_session_id", "demo-chat-1", "role", "user", "content", "帮我检查 Android 工程是否独立。", "created_at", "2026-05-08T09:08:00Z"))); + chatMessages.add(new Models.ChatMessage(json("id", "demo-chat-message-2", "chat_session_id", "demo-chat-1", "role", "assistant", "content", "已确认 Android 在 `/Users/park0er/coding/MulticaAndroid`,不会和 iOS 工程混放。", "created_at", "2026-05-08T09:09:00Z"))); + demoChatMessagesBySessionId.put("demo-chat-1", chatMessages); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } + + private JSONObject json(Object... pairs) throws Exception { + JSONObject object = new JSONObject(); + for (int i = 0; i + 1 < pairs.length; i += 2) { + Object value = pairs[i + 1]; + object.put(String.valueOf(pairs[i]), value == null ? JSONObject.NULL : value); + } + return object; + } + + private Models.Issue makeIssue(String id, String identifier, int number, String title, String description, + String status, String priority, String assigneeId, String assigneeType, + String projectId, String updatedAt) throws Exception { + return new Models.Issue(json( + "id", id, + "identifier", identifier, + "number", number, + "title", title, + "description", description, + "status", status, + "priority", priority, + "assignee_id", assigneeId, + "assignee_type", assigneeType, + "project_id", projectId, + "workspace_id", currentWorkspace.id, + "created_at", "2026-05-08T06:00:00Z", + "updated_at", updatedAt + )); + } + + private Models.Issue findDemoIssue(String issueId) { + for (Models.Issue issue : demoIssues) { + if (issue.id.equals(issueId)) return issue; + } + return null; + } + + private void upsertDemoIssue(String id, String title, String description, String projectId, + String status, String priority, Models.Assignee assignee) { + try { + Models.Issue existing = findDemoIssue(id); + String identifier = existing == null ? "MQA-" + (100 + demoIssues.size() + 1) : existing.identifier; + int number = existing == null ? 100 + demoIssues.size() + 1 : existing.number; + Models.Issue updated = makeIssue( + id, + identifier, + number, + title, + description, + status, + priority, + assignee == null ? null : assignee.id, + assignee == null ? null : assignee.type, + projectId, + "2026-05-08T09:00:00Z" + ); + if (existing == null) { + demoIssues.add(updated); + } else { + for (int i = 0; i < demoIssues.size(); i++) { + if (demoIssues.get(i).id.equals(id)) { + demoIssues.set(i, updated); + break; + } + } + } + } catch (Exception error) { + toast(error.getMessage()); + } + } + + private void addDemoComment(String issueId, String content) { + try { + List comments = demoCommentsByIssueId.computeIfAbsent(issueId, key -> new ArrayList<>()); + comments.add(0, new Models.Comment(json( + "id", "demo-comment-" + System.currentTimeMillis(), + "content", content, + "author_id", "demo-user", + "author_type", "member", + "author_name", "Demo Member", + "issue_id", issueId, + "created_at", "2026-05-08T09:05:00Z" + ))); + } catch (Exception error) { + toast(error.getMessage()); + } + } + + private void addDemoChatMessage(Models.ChatSession session, String role, String content) { + try { + List messages = demoChatMessagesBySessionId.computeIfAbsent(session.id, key -> new ArrayList<>()); + messages.add(new Models.ChatMessage(json( + "id", "demo-chat-message-" + System.currentTimeMillis() + "-" + role, + "chat_session_id", session.id, + "role", role, + "content", content, + "created_at", "2026-05-08T09:20:00Z" + ))); + } catch (Exception error) { + toast(error.getMessage()); + } + } + + private List safeProjects() { + if (currentWorkspace == null) return Models.emptyList(); + try { + return api.projects(currentWorkspace.id, 100, 0).items; + } catch (Exception ignored) { + return projectCache; + } + } + + private List safeAgents() { + if (currentWorkspace == null) return Models.emptyList(); + try { + return api.agents(currentWorkspace.id); + } catch (Exception ignored) { + return agentCache; + } + } + + private List safeMembers() { + if (currentWorkspace == null) return Models.emptyList(); + try { + return api.members(currentWorkspace.id); + } catch (Exception ignored) { + return memberCache; + } + } + + private List assignees(boolean includeEmpty) { + if (memberCache.isEmpty()) memberCache = safeMembers(); + if (agentCache.isEmpty()) agentCache = safeAgents(); + return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache); + } + + private String assigneeName(String id, String type) { + if (id == null || id.isEmpty()) return t("unassigned"); + if (currentUser != null && currentUser.id.equals(id)) return currentUser.name; + for (Models.Member member : memberCache) if (member.id.equals(id)) return member.displayName; + for (Models.Agent agent : agentCache) if (agent.id.equals(id)) return agent.name; + String prefix = "agent".equals(type) ? t("agent") : t("member"); + return prefix + " " + Models.shortId(id); + } + + private List assigneeLabels(boolean includeEmpty) { + List labels = new ArrayList<>(); + for (Models.Assignee assignee : assignees(includeEmpty)) labels.add(assignee.name); + return labels; + } + + private Models.Assignee assigneeAt(int index, boolean includeEmpty) { + List list = assignees(includeEmpty); + if (index < 0 || index >= list.size()) return null; + Models.Assignee assignee = list.get(index); + return assignee.id == null ? null : assignee; + } + + private List agentLabels() { + List labels = new ArrayList<>(); + for (Models.Agent agent : agentCache) labels.add(agent.name); + if (labels.isEmpty()) labels.add(t("emptyAgents")); + return labels; + } + + private Models.Agent agentAt(int index) { + if (index < 0 || index >= agentCache.size()) return null; + return agentCache.get(index); + } + + private void selectAssignee(Spinner spinner, String assigneeId, String assigneeType) { + List list = assignees(true); + for (int i = 0; i < list.size(); i++) { + Models.Assignee a = list.get(i); + if (assigneeId == null && a.id == null || assigneeId != null && assigneeId.equals(a.id) && (assigneeType == null || assigneeType.equals(a.type))) { + spinner.setSelection(i); + return; + } + } + } + + private List projectLabels(boolean includeEmpty) { + List labels = new ArrayList<>(); + if (includeEmpty) labels.add(t("noProject")); + for (Models.Project project : projectCache) labels.add(project.name); + return labels; + } + + private String projectIdAt(int index, boolean includeEmpty) { + int real = includeEmpty ? index - 1 : index; + if (real < 0 || real >= projectCache.size()) return null; + return projectCache.get(real).id; + } + + private void selectProject(Spinner spinner, String projectId) { + if (projectId == null) { + spinner.setSelection(0); + return; + } + for (int i = 0; i < projectCache.size(); i++) { + if (projectId.equals(projectCache.get(i).id)) { + spinner.setSelection(i + 1); + return; + } + } + } + + private List statusLabels() { + List labels = new ArrayList<>(); + for (String status : Models.STATUS_VALUES) labels.add(Models.statusLabel(status, zh)); + return labels; + } + + private List priorityLabels() { + List labels = new ArrayList<>(); + for (String priority : Models.PRIORITY_VALUES) labels.add(Models.priorityLabel(priority, zh)); + return labels; + } + + private List runtimeLabels() { + List labels = new ArrayList<>(); + if (runtimeCache.isEmpty()) { + labels.add(t("noRuntime")); + } else { + for (Models.Runtime runtime : runtimeCache) { + labels.add(runtime.name + (runtime.status == null || runtime.status.isEmpty() ? "" : " · " + runtime.status)); + } + } + return labels; + } + + private String runtimeIdAt(int index) { + if (index < 0 || index >= runtimeCache.size()) return null; + return runtimeCache.get(index).id; + } + + private void selectValue(Spinner spinner, String[] values, String value) { + for (int i = 0; i < values.length; i++) { + if (values[i].equals(value)) { + spinner.setSelection(i); + return; + } + } + } + + private Spinner spinner(List values) { + Spinner spinner = new Spinner(this); + spinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, values)); + return spinner; + } + + private void run(Runnable task, Runnable success, Failure failure) { + run(() -> { + task.run(); + return true; + }, ignored -> success.run(), failure); + } + + private void run(Callable task, Success success, Failure failure) { + try { + worker().submit(() -> { + try { + T value = task.call(); + main.post(() -> { + if (!isFinishing() && !isDestroyed()) success.accept(value); + }); + } catch (Exception error) { + main.post(() -> { + if (!isFinishing() && !isDestroyed()) failure.accept(error); + }); + } + }); + } catch (RejectedExecutionException error) { + main.post(() -> { + if (!isFinishing() && !isDestroyed()) failure.accept(error); + }); + } + } + + private synchronized ExecutorService worker() { + if (executor.isShutdown() || executor.isTerminated()) { + executor = Executors.newFixedThreadPool(4); + } + return executor; + } + + private void setContent(View view) { + if (content == null) { + setContentView(view); + return; + } + content.removeAllViews(); + content.addView(view, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + } + + private void showLoading(String text) { + setContentView(loadingView(text)); + } + + private View loadingView(String text) { + LinearLayout box = vertical(); + box.setGravity(Gravity.CENTER); + box.setBackgroundColor(APP_BG); + box.addView(label(text, 15, MUTED, false)); + return box; + } + + private void showError(String message, Runnable retry) { + LinearLayout body = vertical(); + body.setGravity(Gravity.CENTER); + body.setPadding(dp(24), dp(24), dp(24), dp(24)); + body.addView(label(message, 14, RED, false)); + body.addView(spacer(12)); + Button button = button(t("retry"), BLUE, 0xFFFFFFFF); + button.setOnClickListener(v -> retry.run()); + body.addView(button, matchWrap()); + setContent(body); + } + + private TextView screenTitle(String value) { + TextView tv = label(value, 28, TEXT, true); + tv.setPadding(0, dp(8), 0, dp(12)); + return tv; + } + + private TextView sectionTitle(String value) { + TextView tv = label(value, 17, TEXT, true); + tv.setPadding(0, dp(18), 0, dp(8)); + return tv; + } + + private TextView empty(String value) { + TextView tv = label(value, 14, MUTED, false); + tv.setGravity(Gravity.CENTER); + tv.setPadding(dp(16), dp(24), dp(16), dp(24)); + return tv; + } + + private LinearLayout card() { + LinearLayout card = vertical(); + card.setPadding(dp(14), dp(12), dp(14), dp(12)); + card.setBackground(roundedStroke(SURFACE, BORDER, 14, 1)); + card.setElevation(dp(1)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, dp(6), 0, dp(6)); + card.setLayoutParams(params); + return card; + } + + private LinearLayout vertical() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + return layout; + } + + private LinearLayout horizontal() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.HORIZONTAL); + return layout; + } + + private ScrollView scroll(View child) { + ScrollView scroll = new ScrollView(this); + scroll.setBackgroundColor(APP_BG); + scroll.addView(child); + return scroll; + } + + private TextView label(String value, int sp, int color, boolean bold) { + TextView tv = new TextView(this); + tv.setText(value == null ? "" : value); + tv.setTextSize(sp); + tv.setTextColor(color); + tv.setLineSpacing(dp(1), 1.10f); + if (bold) tv.setTypeface(Typeface.DEFAULT_BOLD); + return tv; + } + + private Button button(String text, int background, int foreground) { + Button button = new Button(this); + button.setText(text); + button.setAllCaps(false); + button.setTextColor(foreground); + button.setBackground(rounded(background, 13)); + button.setPadding(dp(12), dp(7), dp(12), dp(7)); + button.setMinWidth(0); + button.setMinimumWidth(0); + button.setMinHeight(dp(34)); + button.setMinimumHeight(dp(34)); + button.setStateListAnimator(null); + return button; + } + + private Button smallButton(String text) { + Button button = pillButton(text, SOFT_BLUE, BLUE); + button.setMinWidth(dp(44)); + button.setMinimumHeight(dp(36)); + return button; + } + + private Button pillButton(String text, int background, int foreground) { + Button button = button(text, background, foreground); + button.setTextSize(14); + button.setTypeface(Typeface.DEFAULT_BOLD); + button.setMinWidth(dp(36)); + button.setMinimumWidth(dp(36)); + return button; + } + + private EditText input(String hint) { + EditText input = new EditText(this); + input.setHint(hint); + input.setTextSize(15); + input.setSingleLine(false); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); + input.setPadding(dp(12), dp(10), dp(12), dp(10)); + input.setBackground(roundedStroke(0xFFFFFFFF, BORDER, 12, 1)); + return input; + } + + private View spacer(int dp) { + View view = new View(this); + view.setLayoutParams(new LinearLayout.LayoutParams(1, dp(dp))); + return view; + } + + private LinearLayout.LayoutParams matchWrap() { + return new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + private GradientDrawable rounded(int color, float radiusDp) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setCornerRadius(dp(radiusDp)); + return drawable; + } + + private GradientDrawable roundedStroke(int fill, int stroke, float radiusDp, int strokeWidthDp) { + GradientDrawable drawable = rounded(fill, radiusDp); + if (strokeWidthDp > 0) drawable.setStroke(dp(strokeWidthDp), stroke); + return drawable; + } + + private int dp(float value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + + private int dp(int value) { + return MarkdownRenderer.dp(this, value); + } + + private String shortDate(String value) { + if (value == null) return ""; + return value.length() > 10 ? value.substring(0, Math.min(value.length(), 19)).replace("T", " ") : value; + } + + private void toast(String value) { + Toast.makeText(this, value, Toast.LENGTH_LONG).show(); + } + + private String t(String key) { + if (zh) { + switch (key) { + case "loading": return "加载中..."; + case "sessionExpired": return "登录已过期,请重新登录"; + case "signIn": return "登录 Multica"; + case "signInHint": return "输入邮箱获取登录验证码"; + case "continue": return "继续"; + case "google": return "使用 Google 继续"; + case "emailRequired": return "请输入邮箱"; + case "sendCodeFailed": return "验证码发送失败"; + case "otpTitle": return "输入验证码"; + case "verify": return "验证并登录"; + case "back": return "返回"; + case "otpRequired": return "请输入验证码"; + case "otpFailed": return "验证码无效或已过期"; + case "inbox": return "收件箱"; + case "issues": return "Issues"; + case "projects": return "项目"; + case "agents": return "Agent"; + case "settings": return "设置"; + case "chat": return "聊天"; + case "loadingChat": return "正在加载聊天..."; + case "newChat": return "新建聊天"; + case "messagePlaceholder": return "发送消息,支持 Markdown..."; + case "sendFailed": return "发送失败"; + case "you": return "你"; + case "agentRequired": return "请先选择 Agent"; + case "loadAgentsFirst": return "请先加载 Agent 列表"; + case "loadingInbox": return "正在加载收件箱..."; + case "loadingIssues": return "正在加载 Issues..."; + case "loadingIssue": return "正在加载详情..."; + case "loadingProjects": return "正在加载项目..."; + case "loadingAgents": return "正在加载 Agent..."; + case "emptyInbox": return "暂无收件箱消息"; + case "emptyIssues": return "暂无 Issues"; + case "emptyProjects": return "暂无项目"; + case "emptyAgents": return "暂无 Agent"; + case "emptyChat": return "暂无聊天会话"; + case "emptyMessages": return "暂无消息"; + case "emptyComments": return "暂无评论"; + case "emptyActivity": return "暂无 Agent 活动"; + case "loadFailed": return "加载失败"; + case "workspaceRequired": return "缺少 workspace_id,请先选择工作区"; + case "retry": return "重试"; + case "list": return "列表"; + case "board": return "看板"; + case "latestProgress": return "最新进展与评论"; + case "agentActivity": return "Agent 工作详情"; + case "agentRun": return "Agent 运行"; + case "details": return "详情"; + case "transcript": return "Agent 记录"; + case "done": return "完成"; + case "commentPlaceholder": return "添加评论,支持 Markdown..."; + case "commentFailed": return "评论发送失败"; + case "edit": return "编辑"; + case "editIssue": return "编辑 Issue"; + case "newIssue": return "新建 Issue"; + case "title": return "标题"; + case "description": return "描述"; + case "project": return "项目"; + case "status": return "状态"; + case "priority": return "优先级"; + case "assignee": return "负责人"; + case "cancel": return "取消"; + case "save": return "保存"; + case "create": return "创建"; + case "titleRequired": return "请输入标题"; + case "saveFailed": return "保存失败"; + case "unassigned": return "未分配"; + case "member": return "成员"; + case "agent": return "Agent"; + case "archive": return "归档"; + case "archived": return "已归档"; + case "archiveAgent": return "归档 Agent"; + case "noProject": return "不关联项目"; + case "workspace": return "工作区"; + case "workspaces": return "工作区"; + case "logout": return "退出登录"; + case "name": return "名称"; + case "newAgent": return "新建 Agent"; + case "myIssues": return "我的 Issues"; + case "autopilots": return "Autopilots"; + case "runtimes": return "运行时"; + case "skills": return "技能"; + case "runtime": return "运行时"; + case "noRuntime": return "没有可用运行时"; + case "runtimeRequired": return "请先选择运行时"; + case "workspaceDetails": return "Workspace 详情"; + case "members": return "成员"; + case "notifications": return "通知"; + case "apiTokens": return "API Tokens"; + case "labels": return "标签"; + case "newLabel": return "新建标签"; + case "emptyLabels": return "暂无标签"; + case "color": return "颜色"; + case "feedback": return "反馈"; + case "message": return "消息"; + case "feedbackMessage": return "请输入反馈内容"; + case "feedbackRequired": return "请输入反馈内容"; + case "feedbackSent": return "反馈已发送"; + case "send": return "发送"; + case "empty": return "暂无内容"; + case "loadingAutopilots": return "正在加载 Autopilots..."; + case "loadingRuntimes": return "正在加载运行时..."; + case "loadingSkills": return "正在加载技能..."; + case "emptyAutopilots": return "暂无 Autopilot"; + case "emptyRuntimes": return "暂无运行时"; + case "emptySkills": return "暂无技能"; + case "content": return "内容"; + case "newSkill": return "新建技能"; + case "configureHint": return "工作区、成员、通知、Token、标签、Agent、Autopilot、运行时、技能和反馈入口已按 iOS 设置页结构展开。"; + case "chatHint": return "聊天入口已常驻在收件箱左上角,并支持会话列表、消息列表和 Markdown 消息发送。"; + } + } else { + switch (key) { + case "loading": return "Loading..."; + case "sessionExpired": return "Session expired. Please sign in again."; + case "signIn": return "Sign in to Multica"; + case "signInHint": return "Enter your email to get a login code"; + case "continue": return "Continue"; + case "google": return "Continue with Google"; + case "emailRequired": return "Email is required"; + case "sendCodeFailed": return "Failed to send code"; + case "otpTitle": return "Enter code"; + case "verify": return "Verify and sign in"; + case "back": return "Back"; + case "otpRequired": return "Enter the code"; + case "otpFailed": return "Invalid or expired code"; + case "inbox": return "Inbox"; + case "issues": return "Issues"; + case "projects": return "Projects"; + case "agents": return "Agents"; + case "settings": return "Settings"; + case "chat": return "Chat"; + case "loadingChat": return "Loading chat..."; + case "newChat": return "New Chat"; + case "messagePlaceholder": return "Send a Markdown message..."; + case "sendFailed": return "Send failed"; + case "you": return "You"; + case "agentRequired": return "Choose an agent first"; + case "loadAgentsFirst": return "Load Agents first"; + case "loadingInbox": return "Loading inbox..."; + case "loadingIssues": return "Loading issues..."; + case "loadingIssue": return "Loading issue..."; + case "loadingProjects": return "Loading projects..."; + case "loadingAgents": return "Loading agents..."; + case "emptyInbox": return "No inbox items"; + case "emptyIssues": return "No issues"; + case "emptyProjects": return "No projects"; + case "emptyAgents": return "No agents"; + case "emptyChat": return "No chat sessions"; + case "emptyMessages": return "No messages"; + case "emptyComments": return "No comments"; + case "emptyActivity": return "No agent activity"; + case "loadFailed": return "Load failed"; + case "workspaceRequired": return "workspace_id is required. Select a workspace first."; + case "retry": return "Retry"; + case "list": return "List"; + case "board": return "Board"; + case "latestProgress": return "Latest Progress & Comments"; + case "agentActivity": return "Agent Work Details"; + case "agentRun": return "Agent run"; + case "details": return "Details"; + case "transcript": return "Agent Transcript"; + case "done": return "Done"; + case "commentPlaceholder": return "Add a Markdown comment..."; + case "commentFailed": return "Failed to post comment"; + case "edit": return "Edit"; + case "editIssue": return "Edit Issue"; + case "newIssue": return "New Issue"; + case "title": return "Title"; + case "description": return "Description"; + case "project": return "Project"; + case "status": return "Status"; + case "priority": return "Priority"; + case "assignee": return "Assignee"; + case "cancel": return "Cancel"; + case "save": return "Save"; + case "create": return "Create"; + case "titleRequired": return "Title is required"; + case "saveFailed": return "Save failed"; + case "unassigned": return "Unassigned"; + case "member": return "Member"; + case "agent": return "Agent"; + case "archive": return "Archive"; + case "archived": return "Archived"; + case "archiveAgent": return "Archive Agent"; + case "noProject": return "No project"; + case "workspace": return "Workspace"; + case "workspaces": return "Workspaces"; + case "logout": return "Log Out"; + case "name": return "Name"; + case "newAgent": return "New Agent"; + case "myIssues": return "My Issues"; + case "autopilots": return "Autopilots"; + case "runtimes": return "Runtimes"; + case "skills": return "Skills"; + case "runtime": return "Runtime"; + case "noRuntime": return "No available runtime"; + case "runtimeRequired": return "Choose a runtime first"; + case "workspaceDetails": return "Workspace Details"; + case "members": return "Members"; + case "notifications": return "Notifications"; + case "apiTokens": return "API Tokens"; + case "labels": return "Labels"; + case "newLabel": return "New Label"; + case "emptyLabels": return "No labels"; + case "color": return "Color"; + case "feedback": return "Feedback"; + case "message": return "Message"; + case "feedbackMessage": return "Enter feedback"; + case "feedbackRequired": return "Feedback message is required"; + case "feedbackSent": return "Feedback sent"; + case "send": return "Send"; + case "empty": return "No content"; + case "loadingAutopilots": return "Loading autopilots..."; + case "loadingRuntimes": return "Loading runtimes..."; + case "loadingSkills": return "Loading skills..."; + case "emptyAutopilots": return "No autopilots"; + case "emptyRuntimes": return "No runtimes"; + case "emptySkills": return "No skills"; + case "content": return "Content"; + case "newSkill": return "New Skill"; + case "configureHint": return "Workspaces, members, notifications, tokens, labels, agents, autopilots, runtimes, skills, and feedback follow the iOS Settings structure."; + case "chatHint": return "Chat is pinned at the top-left of Inbox and supports sessions, messages, and Markdown sends."; + } + } + return key; + } + + private static final class SessionData { + Models.User user; + List workspaces; + } + + private static final class IssuesData { + List issues; + List projects; + List agents; + } + + private static final class AgentsData { + List agents; + List runtimes; + } +} diff --git a/app/src/main/java/ai/multica/app/MarkdownRenderer.java b/app/src/main/java/ai/multica/app/MarkdownRenderer.java new file mode 100644 index 0000000..40d8cc4 --- /dev/null +++ b/app/src/main/java/ai/multica/app/MarkdownRenderer.java @@ -0,0 +1,273 @@ +package ai.multica.app; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.view.ViewGroup; +import android.view.View; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; + +final class MarkdownRenderer { + private MarkdownRenderer() {} + + static final class Palette { + final int blockBackground; + final int tableHeaderBackground; + final int tableCellBackground; + + private Palette(int blockBackground, int tableHeaderBackground, int tableCellBackground) { + this.blockBackground = blockBackground; + this.tableHeaderBackground = tableHeaderBackground; + this.tableCellBackground = tableCellBackground; + } + } + + static Palette paletteFor(int textColor, int mutedColor, int borderColor) { + boolean darkSurface = luminance(textColor) > luminance(borderColor); + if (darkSurface) { + return new Palette(0xFF1C1C1E, 0xFF1C2638, 0xFF111113); + } + return new Palette(0xFFF3F4F6, 0xFFEFF6FF, 0xFFFFFFFF); + } + + static void render(Context context, LinearLayout parent, String markdown, int textColor, int mutedColor, int borderColor) { + parent.removeAllViews(); + Palette palette = paletteFor(textColor, mutedColor, borderColor); + if (markdown == null || markdown.trim().isEmpty()) { + TextView empty = text(context, "", 15, textColor); + parent.addView(empty); + return; + } + + String[] lines = markdown.replace("\r\n", "\n").split("\n", -1); + List paragraph = new ArrayList<>(); + boolean inCode = false; + StringBuilder code = new StringBuilder(); + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.trim().startsWith("```")) { + flushParagraph(context, parent, paragraph, textColor); + if (inCode) { + addCodeBlock(context, parent, code.toString(), mutedColor, palette); + code.setLength(0); + inCode = false; + } else { + inCode = true; + } + continue; + } + if (inCode) { + code.append(line).append('\n'); + continue; + } + + if (isTableStart(lines, i)) { + flushParagraph(context, parent, paragraph, textColor); + List table = new ArrayList<>(); + table.add(lines[i]); + i += 2; + while (i < lines.length && lines[i].trim().startsWith("|") && lines[i].contains("|")) { + table.add(lines[i]); + i++; + } + i--; + addTable(context, parent, table, textColor, mutedColor, borderColor, palette); + continue; + } + + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + flushParagraph(context, parent, paragraph, textColor); + } else if (trimmed.matches("^(-{3,}|\\*{3,}|_{3,})$")) { + flushParagraph(context, parent, paragraph, textColor); + addDivider(context, parent, borderColor); + } else if (trimmed.startsWith("#")) { + flushParagraph(context, parent, paragraph, textColor); + addHeading(context, parent, trimmed, textColor); + } else if (trimmed.startsWith(">")) { + flushParagraph(context, parent, paragraph, textColor); + TextView quote = text(context, trimmed.replaceFirst("^>\\s?", ""), 15, mutedColor); + quote.setPadding(dp(context, 10), dp(context, 6), dp(context, 8), dp(context, 6)); + quote.setBackgroundColor(palette.blockBackground); + parent.addView(quote); + } else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + flushParagraph(context, parent, paragraph, textColor); + TextView bullet = text(context, "• " + trimmed.substring(2), 15, textColor); + bullet.setPadding(dp(context, 8), dp(context, 2), 0, dp(context, 2)); + parent.addView(bullet); + } else { + paragraph.add(line); + } + } + flushParagraph(context, parent, paragraph, textColor); + if (code.length() > 0) addCodeBlock(context, parent, code.toString(), mutedColor, palette); + } + + private static boolean isTableStart(String[] lines, int index) { + if (index + 1 >= lines.length) return false; + String header = lines[index].trim(); + String divider = lines[index + 1].trim(); + return header.startsWith("|") && header.endsWith("|") + && divider.matches("^\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?$"); + } + + private static void flushParagraph(Context context, LinearLayout parent, List paragraph, int textColor) { + if (paragraph.isEmpty()) return; + TextView p = text(context, String.join("\n", paragraph), 15, textColor); + p.setText(applyInline(p.getText().toString())); + p.setMovementMethod(LinkMovementMethod.getInstance()); + p.setPadding(0, dp(context, 2), 0, dp(context, 6)); + parent.addView(p); + paragraph.clear(); + } + + private static void addHeading(Context context, LinearLayout parent, String raw, int textColor) { + int level = 0; + while (level < raw.length() && raw.charAt(level) == '#') level++; + String title = raw.substring(level).trim(); + TextView h = text(context, title, level <= 1 ? 22 : level == 2 ? 19 : 17, textColor); + h.setTypeface(Typeface.DEFAULT_BOLD); + h.setPadding(0, dp(context, 8), 0, dp(context, 4)); + parent.addView(h); + } + + private static void addCodeBlock(Context context, LinearLayout parent, String code, int mutedColor, Palette palette) { + TextView tv = text(context, code.trim(), 13, mutedColor); + tv.setTypeface(Typeface.MONOSPACE); + tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8)); + tv.setBackgroundColor(palette.blockBackground); + parent.addView(tv); + } + + private static void addDivider(Context context, LinearLayout parent, int borderColor) { + View line = new View(context); + line.setBackgroundColor(borderColor); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.max(1, dp(context, 1))); + params.setMargins(0, dp(context, 8), 0, dp(context, 8)); + parent.addView(line, params); + } + + private static void addTable(Context context, LinearLayout parent, List lines, int textColor, int mutedColor, int borderColor, Palette palette) { + HorizontalScrollView scroll = new HorizontalScrollView(context); + TableLayout table = new TableLayout(context); + table.setShrinkAllColumns(false); + table.setStretchAllColumns(false); + + for (int r = 0; r < lines.size(); r++) { + String[] cells = splitTable(lines.get(r)); + TableRow row = new TableRow(context); + for (String cell : cells) { + TextView tv = text(context, "", 14, r == 0 ? textColor : mutedColor); + tv.setText(applyInline(cell.trim())); + tv.setTypeface(r == 0 ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT); + tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8)); + tv.setBackgroundColor(r == 0 ? palette.tableHeaderBackground : palette.tableCellBackground); + row.addView(tv); + } + table.addView(row); + } + table.setBackgroundColor(borderColor); + table.setPadding(1, 1, 1, 1); + scroll.addView(table); + scroll.setPadding(0, dp(context, 6), 0, dp(context, 8)); + parent.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + static String[] splitTable(String line) { + String trimmed = line.trim(); + if (trimmed.startsWith("|")) trimmed = trimmed.substring(1); + if (endsWithUnescapedPipe(trimmed)) trimmed = trimmed.substring(0, trimmed.length() - 1); + + List cells = new ArrayList<>(); + StringBuilder cell = new StringBuilder(); + boolean inCode = false; + for (int i = 0; i < trimmed.length(); i++) { + char ch = trimmed.charAt(i); + if (ch == '`') { + inCode = !inCode; + cell.append(ch); + } else if (ch == '\\' && i + 1 < trimmed.length() && trimmed.charAt(i + 1) == '|') { + cell.append('|'); + i++; + } else if (ch == '|' && !inCode) { + cells.add(cell.toString()); + cell.setLength(0); + } else { + cell.append(ch); + } + } + cells.add(cell.toString()); + return cells.toArray(new String[0]); + } + + private static boolean endsWithUnescapedPipe(String value) { + if (!value.endsWith("|")) return false; + int slashCount = 0; + for (int i = value.length() - 2; i >= 0 && value.charAt(i) == '\\'; i--) { + slashCount++; + } + return slashCount % 2 == 0; + } + + private static double luminance(int color) { + int red = (color >> 16) & 0xff; + int green = (color >> 8) & 0xff; + int blue = color & 0xff; + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + } + + private static TextView text(Context context, String value, int sp, int color) { + TextView tv = new TextView(context); + tv.setText(value); + tv.setTextSize(sp); + tv.setTextColor(color); + tv.setLineSpacing(0, 1.08f); + return tv; + } + + private static SpannableStringBuilder applyInline(String value) { + SpannableStringBuilder out = new SpannableStringBuilder(); + int i = 0; + while (i < value.length()) { + if (value.startsWith("**", i)) { + int end = value.indexOf("**", i + 2); + if (end > i + 2) { + int start = out.length(); + out.append(value, i + 2, end); + out.setSpan(new StyleSpan(Typeface.BOLD), start, out.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + i = end + 2; + continue; + } + } + if (value.charAt(i) == '`') { + int end = value.indexOf('`', i + 1); + if (end > i + 1) { + int start = out.length(); + out.append(value, i + 1, end); + out.setSpan(new TypefaceSpan("monospace"), start, out.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + i = end + 1; + continue; + } + } + out.append(value.charAt(i)); + i++; + } + return out; + } + + static int dp(Context context, int value) { + return Math.round(value * context.getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/ai/multica/app/Models.java b/app/src/main/java/ai/multica/app/Models.java new file mode 100644 index 0000000..3c19624 --- /dev/null +++ b/app/src/main/java/ai/multica/app/Models.java @@ -0,0 +1,1319 @@ +package ai.multica.app; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class Models { + static final String[] STATUS_VALUES = {"backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"}; + static final String[] PRIORITY_VALUES = {"urgent", "high", "medium", "low", "none"}; + static final String[] PROJECT_STATUS_VALUES = {"planned", "in_progress", "paused", "completed", "cancelled"}; + + static String statusLabel(String value, boolean zh) { + switch (value == null ? "" : value) { + case "backlog": return zh ? "待规划" : "Backlog"; + case "todo": return zh ? "待处理" : "Todo"; + case "in_progress": return zh ? "进行中" : "In Progress"; + case "in_review": return zh ? "评审中" : "In Review"; + case "done": return zh ? "已完成" : "Done"; + case "blocked": return zh ? "阻塞" : "Blocked"; + case "cancelled": return zh ? "已取消" : "Cancelled"; + default: return value == null ? "" : value; + } + } + + static String priorityLabel(String value, boolean zh) { + switch (value == null ? "" : value) { + case "urgent": return zh ? "紧急" : "Urgent"; + case "high": return zh ? "高" : "High"; + case "medium": return zh ? "中" : "Medium"; + case "low": return zh ? "低" : "Low"; + case "none": + case "no_priority": return zh ? "无优先级" : "No Priority"; + default: return value == null ? "" : value; + } + } + + static String projectStatusLabel(String value, boolean zh) { + switch (value == null ? "" : value) { + case "planned": return zh ? "已规划" : "Planned"; + case "in_progress": return zh ? "进行中" : "In Progress"; + case "paused": return zh ? "已暂停" : "Paused"; + case "completed": return zh ? "已完成" : "Completed"; + case "cancelled": return zh ? "已取消" : "Cancelled"; + default: return value == null ? "" : value; + } + } + + static String cleanString(JSONObject json, String key, String fallback) { + String value = json.optString(key, fallback); + return value == null || value.isEmpty() || "null".equals(value) ? fallback : value; + } + + static String shortId(String id) { + if (id == null || id.length() <= 8) return id == null ? "" : id; + return id.substring(0, 8); + } + + static JSONObject objectWith(String key, String value) { + try { + return new JSONObject().put(key, value); + } catch (Exception e) { + return new JSONObject(); + } + } + + static List reactions(JSONObject json) { + List out = new ArrayList<>(); + JSONArray array = json.optJSONArray("reactions"); + if (array == null) return out; + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) out.add(new Reaction(item)); + } + return out; + } + + static List issueLabels(JSONObject json) { + List out = new ArrayList<>(); + JSONArray array = json.optJSONArray("labels"); + if (array == null) return out; + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) out.add(new IssueLabel(item)); + } + return out; + } + + static String commentAuthorDisplayName(Comment comment, List members, List agents, User currentUser) { + if (comment == null) return ""; + if (comment.authorName != null && !comment.authorName.isEmpty()) return comment.authorName; + String authorId = comment.authorId; + if (authorId != null && !authorId.isEmpty()) { + if ("agent".equals(comment.authorType)) { + for (Agent agent : agents) { + if (authorId.equals(agent.id)) return agent.name; + } + } else { + for (Member member : members) { + if (authorId.equals(member.id) || authorId.equals(member.userId)) return member.displayName; + } + if (currentUser != null && authorId.equals(currentUser.id)) return currentUser.name; + } + for (Agent agent : agents) { + if (authorId.equals(agent.id)) return agent.name; + } + for (Member member : members) { + if (authorId.equals(member.id) || authorId.equals(member.userId)) return member.displayName; + } + return (comment.authorType == null || comment.authorType.isEmpty() ? "" : comment.authorType + " ") + + shortId(authorId); + } + return comment.authorType == null ? "" : comment.authorType; + } + + static String commentAuthorAvatarUrl(Comment comment, List members, List agents, User currentUser) { + if (comment == null) return ""; + if (comment.authorAvatarUrl != null && !comment.authorAvatarUrl.isEmpty()) return comment.authorAvatarUrl; + String authorId = comment.authorId; + if (authorId == null || authorId.isEmpty()) return ""; + if ("agent".equals(comment.authorType)) { + for (Agent agent : agents) { + if (authorId.equals(agent.id)) return agent.avatarUrl; + } + } else { + if (currentUser != null && authorId.equals(currentUser.id)) return currentUser.avatarUrl; + for (Member member : members) { + if (authorId.equals(member.id) || authorId.equals(member.userId)) return member.avatarUrl; + } + } + for (Agent agent : agents) { + if (authorId.equals(agent.id)) return agent.avatarUrl; + } + for (Member member : members) { + if (authorId.equals(member.id) || authorId.equals(member.userId)) return member.avatarUrl; + } + return ""; + } + + static String issueSubscriberDisplayName(IssueSubscriber subscriber, List members, List agents, User currentUser) { + if (subscriber == null) return ""; + String userId = subscriber.userId; + if (userId != null && !userId.isEmpty()) { + if ("agent".equals(subscriber.userType)) { + for (Agent agent : agents) { + if (userId.equals(agent.id)) return agent.name; + } + } else { + if (currentUser != null && userId.equals(currentUser.id)) return currentUser.name; + for (Member member : members) { + if (userId.equals(member.id)) return member.displayName; + } + } + for (Agent agent : agents) { + if (userId.equals(agent.id)) return agent.name; + } + for (Member member : members) { + if (userId.equals(member.id)) return member.displayName; + } + return (subscriber.userType == null || subscriber.userType.isEmpty() ? "" : subscriber.userType + " ") + + shortId(userId); + } + return subscriber.userType == null ? "" : subscriber.userType; + } + + static String issueAssigneeDisplayName(Issue issue, List members, List agents, User currentUser) { + if (issue == null || issue.assigneeId == null || issue.assigneeId.isEmpty()) return ""; + String assigneeId = issue.assigneeId; + if ("agent".equals(issue.assigneeType)) { + for (Agent agent : agents) { + if (assigneeId.equals(agent.id)) return agent.name; + } + } else { + if (currentUser != null && assigneeId.equals(currentUser.id)) return currentUser.name; + for (Member member : members) { + if (assigneeId.equals(member.id) || assigneeId.equals(member.userId)) return member.displayName; + } + } + for (Agent agent : agents) { + if (assigneeId.equals(agent.id)) return agent.name; + } + for (Member member : members) { + if (assigneeId.equals(member.id) || assigneeId.equals(member.userId)) return member.displayName; + } + String prefix; + if ("agent".equals(issue.assigneeType)) { + prefix = "Agent "; + } else if ("member".equals(issue.assigneeType)) { + prefix = "Member "; + } else { + prefix = ""; + } + return prefix + shortId(assigneeId); + } + + static List issueAssignees(boolean includeEmpty, String emptyLabel, User currentUser, + List members, List agents) { + List list = new ArrayList<>(); + List seenMemberIds = new ArrayList<>(); + if (includeEmpty) list.add(new Assignee(null, null, emptyLabel)); + if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) { + list.add(new Assignee(currentUser.id, "member", currentUser.name)); + seenMemberIds.add(currentUser.id); + } + for (Member member : members) { + if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue; + list.add(new Assignee(member.id, "member", member.displayName)); + seenMemberIds.add(member.id); + } + for (Agent agent : agents) { + if (agent.id == null || agent.id.isEmpty()) continue; + list.add(new Assignee(agent.id, "agent", agent.name)); + } + return list; + } + + static final class Page { + final List items; + final boolean hasMore; + + Page(List items, boolean hasMore) { + this.items = items; + this.hasMore = hasMore; + } + } + + static final class User { + final String id; + final String email; + final String name; + final String avatarUrl; + final String language; + final String onboardedAt; + final String starterContentState; + final JSONObject onboardingQuestionnaire; + + User(JSONObject json) { + id = json.optString("id"); + email = json.optString("email"); + name = json.optString("name", email); + avatarUrl = cleanString(json, "avatar_url", cleanString(json, "avatarUrl", "")); + language = cleanString(json, "language", ""); + onboardedAt = cleanString(json, "onboarded_at", ""); + starterContentState = cleanString(json, "starter_content_state", ""); + JSONObject questionnaire = json.optJSONObject("onboarding_questionnaire"); + onboardingQuestionnaire = questionnaire == null ? new JSONObject() : questionnaire; + } + } + + static final class StarterContentImportResponse { + final User user; + final String projectId; + final String welcomeIssueId; + + StarterContentImportResponse(JSONObject json) { + JSONObject userJson = json.optJSONObject("user"); + user = new User(userJson == null ? new JSONObject() : userJson); + projectId = cleanString(json, "project_id", ""); + welcomeIssueId = cleanString(json, "welcome_issue_id", ""); + } + } + + static final class Workspace { + final String id; + final String name; + final String slug; + final String description; + final String context; + final String issuePrefix; + final String createdAt; + final String updatedAt; + final List repos; + final JSONObject settings; + + Workspace(JSONObject json) { + id = json.optString("id"); + name = json.optString("name", json.optString("slug")); + slug = json.optString("slug"); + description = cleanString(json, "description", ""); + context = cleanString(json, "context", ""); + issuePrefix = json.optString("issue_prefix"); + createdAt = cleanString(json, "created_at", ""); + updatedAt = cleanString(json, "updated_at", ""); + repos = new ArrayList<>(); + JSONArray repoArray = json.optJSONArray("repos"); + if (repoArray != null) { + for (int i = 0; i < repoArray.length(); i++) { + JSONObject repo = repoArray.optJSONObject(i); + if (repo != null) repos.add(new WorkspaceRepo(repo)); + } + } + JSONObject rawSettings = json.optJSONObject("settings"); + settings = rawSettings == null ? new JSONObject() : rawSettings; + } + + boolean coAuthoredByEnabled() { + return !settings.has("co_authored_by_enabled") || settings.optBoolean("co_authored_by_enabled", true); + } + } + + static final class WorkspaceRepo { + final String url; + + WorkspaceRepo(JSONObject json) { + url = cleanString(json, "url", ""); + } + } + + static final class Member { + final String id; + final String userId; + final String name; + final String email; + final String role; + final String displayName; + final String avatarUrl; + + Member(JSONObject json) { + id = json.optString("id"); + userId = json.optString("user_id"); + name = cleanString(json, "name", ""); + email = cleanString(json, "email", ""); + role = cleanString(json, "role", ""); + avatarUrl = cleanString(json, "avatar_url", cleanString(json, "avatarUrl", "")); + displayName = !name.isEmpty() ? name : (!email.isEmpty() ? email : shortId(id)); + } + } + + static final class Issue { + String id; + String identifier; + int number; + String title; + String description; + String status; + String priority; + String assigneeId; + String assigneeType; + String parentIssueId; + String projectId; + String workspaceId; + String dueDate; + String createdAt; + String updatedAt; + String matchSource; + String matchedSnippet; + final List reactions; + final List labels; + + Issue(JSONObject json) { + id = json.optString("id"); + identifier = json.optString("identifier", "#" + json.optInt("number")); + number = json.optInt("number"); + title = json.optString("title"); + description = cleanString(json, "description", ""); + status = json.optString("status", "todo"); + priority = json.optString("priority", "medium"); + if ("no_priority".equals(priority)) priority = "none"; + assigneeId = json.optString("assignee_id", null); + if ("null".equals(assigneeId) || assigneeId != null && assigneeId.isEmpty()) assigneeId = null; + assigneeType = json.optString("assignee_type", null); + if ("null".equals(assigneeType) || assigneeType != null && assigneeType.isEmpty()) assigneeType = null; + parentIssueId = json.optString("parent_issue_id", null); + if ("null".equals(parentIssueId) || parentIssueId != null && parentIssueId.isEmpty()) parentIssueId = null; + projectId = json.optString("project_id", null); + if ("null".equals(projectId) || projectId != null && projectId.isEmpty()) projectId = null; + workspaceId = json.optString("workspace_id"); + dueDate = cleanString(json, "due_date", ""); + createdAt = json.optString("created_at"); + updatedAt = json.optString("updated_at"); + matchSource = cleanString(json, "match_source", ""); + matchedSnippet = cleanString(json, "matched_snippet", ""); + reactions = reactions(json); + labels = issueLabels(json); + } + } + + static final class QuickCreateTask { + final String taskId; + + QuickCreateTask(JSONObject json) { + taskId = cleanString(json, "task_id", ""); + } + } + + static final class NotificationPreferences { + final String workspaceId; + final Map values; + + NotificationPreferences(JSONObject json) { + workspaceId = cleanString(json, "workspace_id", ""); + values = new LinkedHashMap<>(); + JSONObject preferences = json.optJSONObject("preferences"); + if (preferences != null) { + org.json.JSONArray names = preferences.names(); + if (names != null) { + for (int i = 0; i < names.length(); i++) { + String key = names.optString(i); + if (key != null && !key.isEmpty()) values.put(key, preferences.optString(key, "all")); + } + } + } + } + + String value(String key) { + String value = values.get(key); + return value == null || value.isEmpty() ? "all" : value; + } + } + + static final class Invitation { + final String id; + final String workspaceId; + final String inviteeEmail; + final String inviteeUserId; + final String role; + final String status; + final String inviterName; + final String inviterEmail; + final String workspaceName; + final String createdAt; + final String updatedAt; + final String expiresAt; + + Invitation(JSONObject json) { + id = cleanString(json, "id", ""); + workspaceId = cleanString(json, "workspace_id", ""); + inviteeEmail = cleanString(json, "invitee_email", cleanString(json, "email", "")); + inviteeUserId = cleanString(json, "invitee_user_id", ""); + role = cleanString(json, "role", "member"); + status = cleanString(json, "status", ""); + inviterName = cleanString(json, "inviter_name", ""); + inviterEmail = cleanString(json, "inviter_email", ""); + workspaceName = cleanString(json, "workspace_name", ""); + createdAt = cleanString(json, "created_at", ""); + updatedAt = cleanString(json, "updated_at", ""); + expiresAt = cleanString(json, "expires_at", ""); + } + } + + static final class Comment { + final String id; + final String content; + final String authorId; + final String authorType; + final String parentId; + final String issueId; + final String createdAt; + final String authorName; + final String authorAvatarUrl; + final List attachments; + final List reactions; + + Comment(JSONObject json) { + id = json.optString("id"); + content = json.optString("content"); + authorId = cleanString(json, "author_id", cleanString(json, "actor_id", "")); + authorType = cleanString(json, "author_type", cleanString(json, "actor_type", "member")); + parentId = json.optString("parent_id", null); + issueId = json.optString("issue_id"); + createdAt = json.optString("created_at"); + JSONObject author = json.optJSONObject("author"); + authorName = author == null ? json.optString("author_name", "") : author.optString("name", author.optString("email")); + authorAvatarUrl = author == null + ? cleanString(json, "author_avatar_url", cleanString(json, "avatar_url", "")) + : cleanString(author, "avatar_url", cleanString(author, "avatarUrl", "")); + attachments = new ArrayList<>(); + org.json.JSONArray array = json.optJSONArray("attachments"); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) attachments.add(new Attachment(item)); + } + } + reactions = reactions(json); + } + } + + static final class Reaction { + final String id; + final String targetId; + final String actorId; + final String actorType; + final String emoji; + final String createdAt; + + Reaction(JSONObject json) { + id = json.optString("id"); + targetId = json.optString("comment_id", json.optString("issue_id")); + actorId = json.optString("actor_id"); + actorType = json.optString("actor_type"); + emoji = json.optString("emoji"); + createdAt = json.optString("created_at"); + } + } + + static final class IssueSubscriber { + final String issueId; + final String userType; + final String userId; + final String reason; + final String createdAt; + + IssueSubscriber(JSONObject json) { + issueId = json.optString("issue_id"); + userType = json.optString("user_type"); + userId = json.optString("user_id"); + reason = json.optString("reason", "manual"); + createdAt = json.optString("created_at"); + } + } + + static final class Attachment { + final String id; + final String workspaceId; + final String issueId; + final String commentId; + final String uploaderType; + final String uploaderId; + final String filename; + final String url; + final String downloadUrl; + final String contentType; + final long sizeBytes; + final String createdAt; + + Attachment(JSONObject json) { + id = json.optString("id"); + workspaceId = json.optString("workspace_id"); + issueId = cleanString(json, "issue_id", ""); + commentId = cleanString(json, "comment_id", ""); + uploaderType = cleanString(json, "uploader_type", ""); + uploaderId = cleanString(json, "uploader_id", ""); + filename = cleanString(json, "filename", shortId(id)); + url = cleanString(json, "url", ""); + downloadUrl = cleanString(json, "download_url", url); + contentType = cleanString(json, "content_type", ""); + sizeBytes = json.optLong("size_bytes", 0L); + createdAt = json.optString("created_at"); + } + } + + static final class InboxItem { + final String id; + final String issueId; + final String issueIdentifier; + final String issueTitle; + final String type; + final String body; + final String issueStatus; + final String commentId; + final boolean read; + final boolean archived; + final String createdAt; + + InboxItem(JSONObject json) { + id = json.optString("id"); + JSONObject details = json.optJSONObject("details"); + issueId = json.optString("issue_id", details == null ? "" : details.optString("issue_id", details.optString("id"))); + issueIdentifier = json.optString("issue_identifier", details == null ? issueId : details.optString("identifier", issueId)); + issueTitle = json.optString("issue_title", json.optString("title", details == null ? "" : details.optString("title"))); + type = json.optString("type", "notification"); + body = json.optString("body", ""); + issueStatus = json.optString("issue_status", details == null ? "" : details.optString("status")); + commentId = details == null ? "" : cleanString(details, "comment_id", ""); + read = json.optBoolean("read", false); + archived = json.optBoolean("archived", false); + createdAt = json.optString("created_at"); + } + } + + static final class Project { + final String id; + final String name; + final String description; + final String icon; + final String status; + final String priority; + final String leadType; + final String leadId; + final String workspaceId; + final String createdAt; + final int issueCount; + final int doneCount; + final String matchSource; + final String matchedSnippet; + + Project(JSONObject json) { + id = json.optString("id"); + name = cleanString(json, "name", cleanString(json, "title", shortId(id))); + description = json.optString("description", ""); + icon = cleanString(json, "icon", ""); + status = cleanString(json, "status", "planned"); + priority = cleanString(json, "priority", "none"); + leadType = cleanString(json, "lead_type", ""); + leadId = cleanString(json, "lead_id", ""); + workspaceId = json.optString("workspace_id"); + createdAt = json.optString("created_at"); + issueCount = json.optInt("issue_count", 0); + doneCount = json.optInt("done_count", 0); + matchSource = cleanString(json, "match_source", ""); + matchedSnippet = cleanString(json, "matched_snippet", ""); + } + } + + static final class ProjectResource { + final String id; + final String resourceType; + final String displayTitle; + final String url; + final int position; + + ProjectResource(JSONObject json) { + id = json.optString("id"); + resourceType = json.optString("resource_type", json.optString("type", "")); + position = json.optInt("position", 0); + JSONObject ref = json.optJSONObject("resource_ref"); + url = ref == null ? "" : ref.optString("url", ""); + String title = json.optString("title", ""); + if (title.isEmpty()) title = json.optString("name", ""); + if (title.isEmpty()) title = url; + displayTitle = title.isEmpty() ? shortId(id) : title; + } + } + + static final class PinnedItem { + final String id; + final String workspaceId; + final String userId; + final String itemType; + final String itemId; + final int position; + final String createdAt; + + PinnedItem(JSONObject json) { + id = json.optString("id"); + workspaceId = json.optString("workspace_id"); + userId = json.optString("user_id"); + itemType = json.optString("item_type"); + itemId = json.optString("item_id"); + position = json.optInt("position", 0); + createdAt = json.optString("created_at"); + } + } + + static final class AgentTask { + final String id; + final String agentId; + final String issueId; + final String status; + final String startedAt; + final String completedAt; + final String createdAt; + final String error; + final String failureReason; + final String kind; + final String triggerSummary; + final String workDir; + + AgentTask(JSONObject json) { + id = json.optString("id"); + agentId = cleanString(json, "agent_id", ""); + issueId = json.optString("issue_id"); + status = json.optString("status"); + startedAt = cleanString(json, "started_at", ""); + completedAt = cleanString(json, "completed_at", ""); + createdAt = cleanString(json, "created_at", ""); + error = cleanString(json, "error", ""); + failureReason = cleanString(json, "failure_reason", ""); + kind = cleanString(json, "kind", ""); + triggerSummary = cleanString(json, "trigger_summary", ""); + workDir = cleanString(json, "work_dir", ""); + } + } + + static final class AgentActivityBucket { + final String agentId; + final String bucketAt; + final int taskCount; + final int failedCount; + + AgentActivityBucket(JSONObject json) { + agentId = cleanString(json, "agent_id", ""); + bucketAt = cleanString(json, "bucket_at", ""); + taskCount = json.optInt("task_count", 0); + failedCount = json.optInt("failed_count", 0); + } + } + + static final class AgentRunCount { + final String agentId; + final int runCount; + + AgentRunCount(JSONObject json) { + agentId = cleanString(json, "agent_id", ""); + runCount = json.optInt("run_count", 0); + } + } + + static final class TaskMessage { + final String id; + final int seq; + final String type; + final String tool; + final String content; + final String output; + final String createdAt; + + TaskMessage(JSONObject json) { + id = json.optString("id"); + seq = json.optInt("seq"); + type = json.optString("type"); + tool = json.optString("tool"); + content = json.optString("content"); + output = json.optString("output"); + createdAt = cleanString(json, "created_at", cleanString(json, "timestamp", "")); + } + } + + static final class Agent { + final String id; + final String name; + final String description; + final String instructions; + final String runtimeId; + final String status; + final String visibility; + final int maxConcurrentTasks; + final String model; + final String avatarUrl; + final String ownerId; + final String archivedAt; + final Map customEnv; + final List customArgs; + final boolean customEnvRedacted; + + Agent(JSONObject json) { + id = json.optString("id"); + name = json.optString("name", json.optString("slug", shortId(id))); + description = json.optString("description", ""); + instructions = json.optString("instructions", description); + runtimeId = json.optString("runtime_id", ""); + status = json.optString("status", json.optString("state", "")); + visibility = json.optString("visibility", "private"); + maxConcurrentTasks = json.optInt("max_concurrent_tasks", 1); + model = json.optString("model", ""); + avatarUrl = cleanString(json, "avatar_url", cleanString(json, "avatarUrl", "")); + ownerId = cleanString(json, "owner_id", ""); + archivedAt = cleanString(json, "archived_at", ""); + customEnv = stringMap(json.optJSONObject("custom_env")); + customArgs = stringList(json.optJSONArray("custom_args")); + customEnvRedacted = json.optBoolean("custom_env_redacted", false); + } + } + + static final class Runtime { + final String id; + final String workspaceId; + final String name; + final String status; + final String version; + final String provider; + final String runtimeMode; + final String daemonId; + final String deviceInfo; + final String ownerId; + final String lastSeenAt; + + Runtime(JSONObject json) { + id = json.optString("id"); + workspaceId = json.optString("workspace_id", ""); + name = json.optString("name", shortId(id)); + status = json.optString("status", ""); + version = json.optString("version", ""); + provider = json.optString("provider", ""); + runtimeMode = json.optString("runtime_mode", ""); + daemonId = cleanString(json, "daemon_id", ""); + deviceInfo = cleanString(json, "device_info", ""); + ownerId = cleanString(json, "owner_id", ""); + lastSeenAt = cleanString(json, "last_seen_at", ""); + } + } + + static final class RuntimeUsage { + final String runtimeId; + final String date; + final String provider; + final String model; + final long inputTokens; + final long outputTokens; + final long cacheReadTokens; + final long cacheWriteTokens; + + RuntimeUsage(JSONObject json) { + runtimeId = json.optString("runtime_id", ""); + date = json.optString("date", ""); + provider = json.optString("provider", ""); + model = json.optString("model", ""); + inputTokens = json.optLong("input_tokens", json.optLong("total_input_tokens", 0)); + outputTokens = json.optLong("output_tokens", json.optLong("total_output_tokens", 0)); + cacheReadTokens = json.optLong("cache_read_tokens", json.optLong("total_cache_read_tokens", 0)); + cacheWriteTokens = json.optLong("cache_write_tokens", json.optLong("total_cache_write_tokens", 0)); + } + + long totalTokens() { + return inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + } + } + + static final class IssueUsage { + final long inputTokens; + final long outputTokens; + final long cacheReadTokens; + final long cacheWriteTokens; + final int taskCount; + + IssueUsage(JSONObject json) { + inputTokens = json.optLong("input_tokens", json.optLong("total_input_tokens", 0)); + outputTokens = json.optLong("output_tokens", json.optLong("total_output_tokens", 0)); + cacheReadTokens = json.optLong("cache_read_tokens", json.optLong("total_cache_read_tokens", 0)); + cacheWriteTokens = json.optLong("cache_write_tokens", json.optLong("total_cache_write_tokens", 0)); + taskCount = json.optInt("task_count", 0); + } + + long totalTokens() { + return inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + } + } + + static final class TimelineEntry { + final String type; + final String id; + final String actorType; + final String actorId; + final String createdAt; + final String action; + final String content; + final String parentId; + + TimelineEntry(JSONObject json) { + type = json.optString("type", ""); + id = json.optString("id", ""); + actorType = json.optString("actor_type", ""); + actorId = json.optString("actor_id", ""); + createdAt = json.optString("created_at", ""); + action = cleanString(json, "action", ""); + content = cleanString(json, "content", ""); + parentId = cleanString(json, "parent_id", ""); + } + } + + static final class TimelinePage { + final List entries; + final String nextCursor; + final String prevCursor; + final boolean hasMoreBefore; + final boolean hasMoreAfter; + final int targetIndex; + + TimelinePage(JSONObject json) throws Exception { + entries = new ArrayList<>(); + JSONArray array = json.optJSONArray("entries"); + if (array == null) array = json.optJSONArray("items"); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + entries.add(new TimelineEntry(array.getJSONObject(i))); + } + } + nextCursor = cleanString(json, "next_cursor", ""); + prevCursor = cleanString(json, "prev_cursor", ""); + hasMoreBefore = json.optBoolean("has_more_before", false); + hasMoreAfter = json.optBoolean("has_more_after", false); + targetIndex = json.has("target_index") && !json.isNull("target_index") ? json.optInt("target_index", -1) : -1; + } + } + + static final class RuntimeHourlyActivity { + final String hour; + final int totalTasks; + + RuntimeHourlyActivity(JSONObject json) { + hour = json.optString("hour", String.valueOf(json.optInt("hour", 0))); + totalTasks = json.optInt("total_tasks", json.optInt("count", json.optInt("task_count", 0))); + } + } + + static final class RuntimeUsageByAgent { + final String agentId; + final String agentName; + final String model; + final long inputTokens; + final long outputTokens; + final long cacheReadTokens; + final long cacheWriteTokens; + final int taskCount; + + RuntimeUsageByAgent(JSONObject json) { + agentId = json.optString("agent_id", ""); + agentName = cleanString(json, "agent_name", ""); + model = json.optString("model", ""); + inputTokens = json.optLong("input_tokens", json.optLong("total_input_tokens", 0)); + outputTokens = json.optLong("output_tokens", json.optLong("total_output_tokens", 0)); + cacheReadTokens = json.optLong("cache_read_tokens", json.optLong("total_cache_read_tokens", 0)); + cacheWriteTokens = json.optLong("cache_write_tokens", json.optLong("total_cache_write_tokens", 0)); + taskCount = json.optInt("task_count", 0); + } + + long totalTokens() { + return inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + } + } + + static final class RuntimeUsageByHour { + final String hour; + final String model; + final long inputTokens; + final long outputTokens; + final long cacheReadTokens; + final long cacheWriteTokens; + + RuntimeUsageByHour(JSONObject json) { + hour = json.optString("hour", String.valueOf(json.optInt("hour", 0))); + model = json.optString("model", ""); + inputTokens = json.optLong("input_tokens", json.optLong("total_input_tokens", 0)); + outputTokens = json.optLong("output_tokens", json.optLong("total_output_tokens", 0)); + cacheReadTokens = json.optLong("cache_read_tokens", json.optLong("total_cache_read_tokens", 0)); + cacheWriteTokens = json.optLong("cache_write_tokens", json.optLong("total_cache_write_tokens", 0)); + } + + long totalTokens() { + return inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + } + } + + static final class RuntimeUpdate { + final String id; + final String runtimeId; + final String status; + final String targetVersion; + final String output; + final String error; + + RuntimeUpdate(JSONObject json) { + id = json.optString("id", ""); + runtimeId = json.optString("runtime_id", ""); + status = json.optString("status", "unknown"); + targetVersion = cleanString(json, "target_version", ""); + output = cleanString(json, "output", ""); + error = cleanString(json, "error", ""); + } + } + + static final class RuntimeModelInfo { + final String id; + final String name; + final String provider; + + RuntimeModelInfo(JSONObject json) { + name = cleanString(json, "name", cleanString(json, "id", "")); + id = cleanString(json, "id", name); + provider = cleanString(json, "provider", ""); + } + } + + static final class RuntimeModelListRequest { + final String id; + final String runtimeId; + final String status; + final boolean supported; + final String error; + final List models; + + RuntimeModelListRequest(JSONObject json) { + id = json.optString("id", ""); + runtimeId = json.optString("runtime_id", ""); + status = json.optString("status", "unknown"); + supported = json.optBoolean("supported", false); + error = cleanString(json, "error", ""); + models = new ArrayList<>(); + org.json.JSONArray array = json.optJSONArray("models"); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) models.add(new RuntimeModelInfo(item)); + else models.add(new RuntimeModelInfo(objectWith("name", array.optString(i)))); + } + } + } + } + + static final class RuntimeLocalSkillInfo { + final String id; + final String key; + final String name; + final String path; + final String description; + final String provider; + + RuntimeLocalSkillInfo(JSONObject json) { + key = cleanString(json, "key", cleanString(json, "id", cleanString(json, "path", ""))); + id = cleanString(json, "id", key); + path = cleanString(json, "path", ""); + String fallbackName = !path.isEmpty() && path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : key; + name = cleanString(json, "name", fallbackName); + description = cleanString(json, "description", ""); + provider = cleanString(json, "provider", ""); + } + } + + static final class RuntimeLocalSkillListRequest { + final String id; + final String runtimeId; + final String status; + final boolean supported; + final String error; + final List skills; + + RuntimeLocalSkillListRequest(JSONObject json) { + id = json.optString("id", ""); + runtimeId = json.optString("runtime_id", ""); + status = json.optString("status", "unknown"); + supported = json.optBoolean("supported", false); + error = cleanString(json, "error", ""); + skills = new ArrayList<>(); + org.json.JSONArray array = json.optJSONArray("skills"); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) skills.add(new RuntimeLocalSkillInfo(item)); + else skills.add(new RuntimeLocalSkillInfo(objectWith("path", array.optString(i)))); + } + } + } + } + + static final class RuntimeLocalSkillImportRequest { + final String id; + final String runtimeId; + final String skillKey; + final String name; + final String description; + final String status; + final String error; + final Skill skill; + + RuntimeLocalSkillImportRequest(JSONObject json) { + id = json.optString("id", ""); + runtimeId = json.optString("runtime_id", ""); + skillKey = cleanString(json, "skill_key", ""); + name = cleanString(json, "name", ""); + description = cleanString(json, "description", ""); + status = json.optString("status", "unknown"); + error = cleanString(json, "error", ""); + JSONObject skillJson = json.optJSONObject("skill"); + skill = skillJson == null ? null : new Skill(skillJson); + } + } + + static final class Skill { + final String id; + final String name; + final String description; + final String content; + final String originSourceUrl; + final String originType; + final String createdAt; + final String updatedAt; + final List files; + + Skill(JSONObject json) { + id = json.optString("id"); + name = json.optString("name", shortId(id)); + description = json.optString("description", ""); + content = json.optString("content", ""); + createdAt = cleanString(json, "created_at", ""); + updatedAt = cleanString(json, "updated_at", ""); + JSONObject origin = null; + JSONObject config = json.optJSONObject("config"); + if (config != null) origin = config.optJSONObject("origin"); + originSourceUrl = origin == null ? "" : origin.optString("source_url", ""); + originType = origin == null ? "" : origin.optString("type", ""); + files = new ArrayList<>(); + org.json.JSONArray array = json.optJSONArray("files"); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item != null) files.add(new SkillFile(item)); + } + } + } + } + + static final class SkillFile { + final String id; + final String skillId; + final String path; + final String content; + final String createdAt; + final String updatedAt; + + SkillFile(JSONObject json) { + id = cleanString(json, "id", ""); + skillId = cleanString(json, "skill_id", ""); + path = cleanString(json, "path", ""); + content = cleanString(json, "content", ""); + createdAt = cleanString(json, "created_at", ""); + updatedAt = cleanString(json, "updated_at", ""); + } + } + + static final class Autopilot { + final String id; + final String title; + final String description; + final String status; + final String assigneeId; + final String executionMode; + final String issueTitleTemplate; + final String lastRunAt; + final String createdAt; + + Autopilot(JSONObject json) { + id = json.optString("id"); + title = json.optString("title", json.optString("name", shortId(json.optString("id")))); + description = json.optString("description", ""); + status = json.optString("status", ""); + assigneeId = json.optString("assignee_id", ""); + executionMode = json.optString("execution_mode", "create_issue"); + issueTitleTemplate = cleanString(json, "issue_title_template", ""); + lastRunAt = cleanString(json, "last_run_at", ""); + createdAt = cleanString(json, "created_at", ""); + } + } + + static final class AutopilotRun { + final String id; + final String autopilotId; + final String source; + final String status; + final String issueId; + final String taskId; + final String triggeredAt; + final String failureReason; + + AutopilotRun(JSONObject json) { + id = json.optString("id"); + autopilotId = json.optString("autopilot_id"); + source = json.optString("source", ""); + status = json.optString("status", ""); + issueId = cleanString(json, "issue_id", ""); + taskId = cleanString(json, "task_id", ""); + triggeredAt = cleanString(json, "triggered_at", cleanString(json, "created_at", "")); + failureReason = cleanString(json, "failure_reason", ""); + } + } + + static final class AutopilotTrigger { + final String id; + final String autopilotId; + final String kind; + final boolean enabled; + final String cronExpression; + final String timezone; + final String label; + final String nextRunAt; + + AutopilotTrigger(JSONObject json) { + id = json.optString("id"); + autopilotId = json.optString("autopilot_id"); + kind = json.optString("kind", ""); + enabled = json.optBoolean("enabled", true); + cronExpression = cleanString(json, "cron_expression", ""); + timezone = cleanString(json, "timezone", ""); + label = cleanString(json, "label", ""); + nextRunAt = cleanString(json, "next_run_at", ""); + } + } + + static final class IssueLabel { + final String id; + final String name; + final String color; + + IssueLabel(JSONObject json) { + id = json.optString("id"); + name = json.optString("name", shortId(id)); + color = json.optString("color", "#94A3B8"); + } + } + + static final class ChatSession { + final String id; + final String title; + final String agentId; + final String status; + final boolean hasUnread; + final String updatedAt; + + ChatSession(JSONObject json) { + id = json.optString("id"); + title = json.optString("title", shortId(id)); + agentId = json.optString("agent_id"); + status = json.optString("status", "active"); + hasUnread = json.optBoolean("has_unread", false); + updatedAt = json.optString("updated_at", json.optString("created_at")); + } + } + + static final class ChatMessage { + final String id; + final String role; + final String content; + final String taskId; + final String createdAt; + final String failureReason; + + ChatMessage(JSONObject json) { + id = json.optString("id"); + role = json.optString("role", "assistant"); + content = json.optString("content", ""); + taskId = json.optString("task_id", ""); + createdAt = json.optString("created_at"); + failureReason = cleanString(json, "failure_reason", ""); + } + } + + static final class ChatPendingTask { + final String taskId; + final String status; + final String createdAt; + + ChatPendingTask(JSONObject json) { + taskId = cleanString(json, "task_id", ""); + status = cleanString(json, "status", ""); + createdAt = cleanString(json, "created_at", ""); + } + } + + static final class PendingChatTaskItem { + final String taskId; + final String status; + final String chatSessionId; + + PendingChatTaskItem(JSONObject json) { + taskId = cleanString(json, "task_id", ""); + status = cleanString(json, "status", ""); + chatSessionId = cleanString(json, "chat_session_id", ""); + } + } + + static class PersonalAccessToken { + final String id; + final String name; + final String tokenPrefix; + final String expiresAt; + final String lastUsedAt; + final String createdAt; + + PersonalAccessToken(JSONObject json) { + id = json.optString("id"); + name = json.optString("name", shortId(id)); + tokenPrefix = json.optString("token_prefix", ""); + expiresAt = cleanString(json, "expires_at", ""); + lastUsedAt = cleanString(json, "last_used_at", ""); + createdAt = cleanString(json, "created_at", ""); + } + } + + static final class CreatedPersonalAccessToken extends PersonalAccessToken { + final String token; + + CreatedPersonalAccessToken(JSONObject json) { + super(json); + token = json.optString("token", ""); + } + } + + static final class Assignee { + final String id; + final String type; + final String name; + + Assignee(String id, String type, String name) { + this.id = id; + this.type = type; + this.name = name; + } + } + + static List emptyList() { + return new ArrayList<>(); + } + + private static Map stringMap(JSONObject object) { + Map out = new LinkedHashMap<>(); + if (object == null) return out; + JSONArray names = object.names(); + if (names == null) return out; + for (int i = 0; i < names.length(); i++) { + String key = names.optString(i, ""); + if (key.isEmpty()) continue; + out.put(key, object.optString(key, "")); + } + return out; + } + + private static List stringList(JSONArray array) { + List out = new ArrayList<>(); + if (array == null) return out; + for (int i = 0; i < array.length(); i++) { + String value = array.optString(i, ""); + if (!value.isEmpty()) out.add(value); + } + return out; + } +} diff --git a/app/src/main/java/ai/multica/app/OnboardingPayload.java b/app/src/main/java/ai/multica/app/OnboardingPayload.java new file mode 100644 index 0000000..d3d9603 --- /dev/null +++ b/app/src/main/java/ai/multica/app/OnboardingPayload.java @@ -0,0 +1,89 @@ +package ai.multica.app; + +import org.json.JSONArray; +import org.json.JSONObject; + +final class OnboardingPayload { + private OnboardingPayload() {} + + static JSONObject questionnaire(String teamSize, String role, String useCase, String notes) throws Exception { + JSONObject json = new JSONObject(); + putTrimmed(json, "team_size", teamSize); + putTrimmed(json, "role", role); + putTrimmed(json, "use_case", useCase); + putTrimmed(json, "notes", notes); + return json; + } + + static JSONObject starterContentPayload(String workspaceId, boolean assignToSelf) throws Exception { + JSONObject payload = new JSONObject(); + payload.put("workspace_id", workspaceId); + payload.put("project", new JSONObject() + .put("title", "Getting Started with Multica") + .put("description", "A lightweight starter project for learning Multica on mobile.") + .put("icon", "sparkles")); + payload.put("welcome_issue_template", new JSONObject() + .put("title", "Welcome to Multica") + .put("description", "Use this issue to test comments, Markdown, status changes, and agent collaboration.") + .put("priority", "high")); + payload.put("agent_guided_sub_issues", new JSONArray() + .put(starterIssue( + "Ask an agent to summarize your workspace", + "Open the agent picker, assign this issue to an agent, and watch the activity thread update.", + "todo", + "high", + assignToSelf)) + .put(starterIssue( + "Review Markdown rendering", + "Add a comment with a table, list, quote, and code block. Confirm the mobile detail page remains readable.", + "todo", + "medium", + assignToSelf)) + .put(starterIssue( + "Try Inbox triage", + "Generate a notification, mark it read, then archive it from Inbox.", + "todo", + "medium", + assignToSelf))); + payload.put("self_serve_sub_issues", new JSONArray() + .put(starterIssue( + "Create your first issue", + "Use New Issue to set project, status, priority, and assignee.", + "todo", + "high", + assignToSelf)) + .put(starterIssue( + "Organize issues by status", + "Move an issue across the status groups and verify the list stays sorted.", + "todo", + "medium", + assignToSelf)) + .put(starterIssue( + "Invite a teammate or add an agent later", + "When you are ready, open Settings to manage members, agents, and runtimes.", + "todo", + "low", + assignToSelf))); + return payload; + } + + private static JSONObject starterIssue( + String title, + String description, + String status, + String priority, + boolean assignToSelf) throws Exception { + return new JSONObject() + .put("title", title) + .put("description", description) + .put("status", status) + .put("priority", priority) + .put("assign_to_self", assignToSelf); + } + + private static void putTrimmed(JSONObject json, String key, String value) throws Exception { + if (value == null) return; + String trimmed = value.trim(); + if (!trimmed.isEmpty()) json.put(key, trimmed); + } +} diff --git a/app/src/main/java/ai/multica/app/RuntimeSectionLoader.java b/app/src/main/java/ai/multica/app/RuntimeSectionLoader.java new file mode 100644 index 0000000..361b6ee --- /dev/null +++ b/app/src/main/java/ai/multica/app/RuntimeSectionLoader.java @@ -0,0 +1,21 @@ +package ai.multica.app; + +import java.util.List; + +final class RuntimeSectionLoader { + interface Loader { + T load() throws Exception; + } + + private RuntimeSectionLoader() { + } + + static T load(String label, T fallback, List loadErrors, Loader loader) { + try { + return loader.load(); + } catch (Exception error) { + loadErrors.add(label + ": " + (error.getMessage() == null ? error.toString() : error.getMessage())); + return fallback; + } + } +} diff --git a/app/src/main/java/ai/multica/app/TranscriptRedactor.java b/app/src/main/java/ai/multica/app/TranscriptRedactor.java new file mode 100644 index 0000000..2fb21c4 --- /dev/null +++ b/app/src/main/java/ai/multica/app/TranscriptRedactor.java @@ -0,0 +1,45 @@ +package ai.multica.app; + +import java.util.regex.Pattern; + +final class TranscriptRedactor { + private static final Rule[] RULES = new Rule[] { + new Rule("\\bAKIA[0-9A-Z]{16}\\b", "[REDACTED AWS KEY]"), + new Rule("(?:aws_secret_access_key|secret_?access_?key)\\s*[=:]\\s*[A-Za-z0-9/+=]{40}", "[REDACTED AWS SECRET]", Pattern.CASE_INSENSITIVE), + new Rule("-----BEGIN[A-Z\\s]*PRIVATE KEY-----[\\s\\S]*?-----END[A-Z\\s]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"), + new Rule("\\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\\b", "[REDACTED GITHUB TOKEN]"), + new Rule("\\bglpat-[A-Za-z0-9_-]{20,}\\b", "[REDACTED GITLAB TOKEN]"), + new Rule("\\bsk-[A-Za-z0-9_-]{20,}\\b", "[REDACTED API KEY]"), + new Rule("\\bxox[bporas]-[A-Za-z0-9-]{10,}\\b", "[REDACTED SLACK TOKEN]"), + new Rule("\\bey[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b", "[REDACTED JWT]"), + new Rule("\\bBearer\\s+[A-Za-z0-9\\-._~+/]+=*", "Bearer [REDACTED]", Pattern.CASE_INSENSITIVE), + new Rule("(?:postgres|mysql|mongodb|redis|amqp)(?:ql)?://[^:\\s]+:[^@\\s]+@", "[REDACTED CONNECTION STRING]@", Pattern.CASE_INSENSITIVE), + new Rule("(?:API_KEY|API_SECRET|SECRET_KEY|SECRET|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|DB_URL|REDIS_URL|PASSWORD|TOKEN)\\s*[=:]\\s*(?!\\[REDACTED)\\S+", "[REDACTED CREDENTIAL]", Pattern.CASE_INSENSITIVE) + }; + + private TranscriptRedactor() { + } + + static String redactSecrets(String text) { + if (text == null || text.isEmpty()) return ""; + String result = text; + for (Rule rule : RULES) { + result = rule.pattern.matcher(result).replaceAll(rule.replacement); + } + return result; + } + + private static final class Rule { + final Pattern pattern; + final String replacement; + + Rule(String regex, String replacement) { + this(regex, replacement, 0); + } + + Rule(String regex, String replacement, int flags) { + this.pattern = Pattern.compile(regex, flags); + this.replacement = replacement; + } + } +} diff --git a/app/src/main/java/ai/multica/app/WorkspaceSelection.java b/app/src/main/java/ai/multica/app/WorkspaceSelection.java new file mode 100644 index 0000000..4b3811a --- /dev/null +++ b/app/src/main/java/ai/multica/app/WorkspaceSelection.java @@ -0,0 +1,18 @@ +package ai.multica.app; + +import java.util.List; + +final class WorkspaceSelection { + private WorkspaceSelection() { + } + + static Models.Workspace chooseWorkspace(List workspaces, String preferredWorkspaceId) { + if (workspaces == null || workspaces.isEmpty()) return null; + if (preferredWorkspaceId != null && !preferredWorkspaceId.isEmpty()) { + for (Models.Workspace workspace : workspaces) { + if (preferredWorkspaceId.equals(workspace.id)) return workspace; + } + } + return workspaces.get(0); + } +} diff --git a/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt b/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt new file mode 100644 index 0000000..254ee26 --- /dev/null +++ b/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt @@ -0,0 +1,2038 @@ +package ai.multica.app.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.AssignmentInd +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick as semanticsOnClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.multica.app.ui.theme.MulticaColors +import ai.multica.app.ui.theme.LocalMulticaSpacing +import dev.chrisbanes.haze.HazeDefaults +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import io.github.alexzhirkevich.cupertino.AlertActionStyle +import io.github.alexzhirkevich.cupertino.CupertinoActionSheet +import io.github.alexzhirkevich.cupertino.CupertinoActivityIndicator +import io.github.alexzhirkevich.cupertino.CupertinoAlertDialog +import io.github.alexzhirkevich.cupertino.CupertinoButton +import io.github.alexzhirkevich.cupertino.CupertinoButtonDefaults +import io.github.alexzhirkevich.cupertino.CupertinoButtonSize +import io.github.alexzhirkevich.cupertino.CupertinoBorderedTextField +import io.github.alexzhirkevich.cupertino.CupertinoSwitch +import io.github.alexzhirkevich.cupertino.ExperimentalCupertinoApi + +enum class MulticaTab(val label: String, val zhLabel: String, val icon: ImageVector) { + Inbox("Inbox", "收件箱", Icons.Outlined.Inbox), + Issues("Issues", "Issues", Icons.Outlined.CheckCircle), + MyIssues("My Issues", "我的 Issues", Icons.Outlined.AssignmentInd), + Projects("Projects", "项目", Icons.Outlined.Folder), + Settings("Settings", "设置", Icons.Outlined.Settings), +} + +@Composable +fun MulticaShell( + selectedTab: MulticaTab, + workspaceName: String, + languageLabel: String, + zh: Boolean = false, + onLanguageClick: () -> Unit, + onWorkspaceClick: () -> Unit, + onSearchClick: () -> Unit, + onChatClick: () -> Unit, + onTabClick: (MulticaTab) -> Unit, + showChrome: Boolean = true, + content: @Composable () -> Unit, +) { + val hazeState = rememberHazeState() + Box( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .hazeSource(hazeState) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .then(if (showChrome) Modifier.padding(top = 58.dp) else Modifier), + ) { + content() + } + if (showChrome) { + MulticaGlobalActionsBar( + workspaceName = workspaceName, + hazeState = hazeState, + onWorkspaceClick = onWorkspaceClick, + onSearchClick = onSearchClick, + onChatClick = onChatClick, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 8.dp, start = 14.dp, end = 14.dp), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) { + MulticaBottomNav( + selectedTab = selectedTab, + zh = zh, + hazeState = hazeState, + onTabClick = onTabClick, + ) + } + } + } +} + +@Composable +private fun MulticaGlobalActionsBar( + workspaceName: String, + hazeState: HazeState, + onWorkspaceClick: () -> Unit, + onSearchClick: () -> Unit, + onChatClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + Row( + modifier = modifier + .widthIn(max = 520.dp) + .clip(RoundedCornerShape(24.dp)) + .hazeEffect( + hazeState, + style = HazeDefaults.style( + backgroundColor = MulticaColors.Background.copy(alpha = 0.74f), + blurRadius = 22.dp, + noiseFactor = 0.07f, + ), + ) + .background(MulticaColors.Background.copy(alpha = 0.74f)) + .border(0.5.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(24.dp)) + .padding(horizontal = 8.dp, vertical = 7.dp) + .semantics(mergeDescendants = false) { + contentDescription = "Multica Global Actions Bar Web Sidebar Header Search Chat Workspace" + }, + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MulticaColors.Surface.copy(alpha = 0.82f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + modifier = Modifier + .widthIn(min = 86.dp, max = 168.dp) + .height(36.dp) + .clickable { + performMulticaTapFeedback(haptic) + onWorkspaceClick() + } + .semantics { contentDescription = "Global Workspace Switcher $workspaceName" }, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(MulticaColors.Accent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = workspaceName.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "M", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold), + color = MulticaColors.Accent, + ) + } + Text( + text = workspaceName.ifBlank { "Multica" }, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + MulticaIconPillButton( + icon = Icons.Outlined.Search, + contentDescription = "Global Search Trigger", + onClick = onSearchClick, + tone = MulticaButtonTone.Ghost, + ) + MulticaIconPillButton( + icon = Icons.Outlined.ChatBubbleOutline, + contentDescription = "Global Chat Trigger", + onClick = onChatClick, + tone = MulticaButtonTone.Ghost, + ) + } +} + +@Composable +fun MulticaRootPage( + title: String, + trailing: @Composable (() -> Unit)? = null, + pinnedToolbar: @Composable (() -> Unit)? = null, + bottomContent: @Composable (() -> Unit)? = null, + content: LazyListScope.() -> Unit, +) { + val spacing = LocalMulticaSpacing.current + val hazeState = rememberHazeState() + Column( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding(), + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .hazeSource(hazeState), + contentPadding = PaddingValues( + start = spacing.pageHorizontal, + top = spacing.pageTop, + end = spacing.pageHorizontal, + bottom = if (bottomContent == null) 128.dp else 88.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall, + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + trailing?.invoke() + } + } + if (pinnedToolbar != null) { + item { pinnedToolbar() } + } + content() + } + if (bottomContent != null) { + MulticaGlassBottomSlot(hazeState, bottomContent) + } + } +} + +@Composable +fun MulticaDetailPage( + title: String, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + bottomContent: @Composable (() -> Unit)? = null, + content: LazyListScope.() -> Unit, +) { + val spacing = LocalMulticaSpacing.current + val hazeState = rememberHazeState() + Column( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding(), + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .hazeSource(hazeState), + contentPadding = PaddingValues( + start = spacing.pageHorizontal, + top = spacing.pageTop, + end = spacing.pageHorizontal, + bottom = if (bottomContent == null) 120.dp else 88.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.invoke() + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MulticaColors.TextPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .then(if (leading == null) Modifier else Modifier.padding(start = 10.dp)), + ) + trailing?.invoke() + } + } + content() + } + if (bottomContent != null) { + MulticaGlassBottomSlot(hazeState, bottomContent) + } + } +} + +@Composable +fun MulticaCupertinoPage( + modifier: Modifier = Modifier, + listState: LazyListState? = null, + pinnedToolbar: @Composable (() -> Unit)? = null, + bottomBar: @Composable (() -> Unit)? = null, + header: @Composable () -> Unit, + content: LazyListScope.() -> Unit, +) { + val spacing = LocalMulticaSpacing.current + val hazeState = rememberHazeState() + val rememberedListState = rememberLazyListState() + val effectiveListState = listState ?: rememberedListState + Column( + modifier = modifier + .fillMaxSize() + .background(MulticaColors.Background) + .statusBarsPadding() + .semantics(mergeDescendants = false) { contentDescription = "Multica Cupertino Page" }, + ) { + if (pinnedToolbar != null) { + MulticaCupertinoPinnedHeader(hazeState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = spacing.pageHorizontal, + top = 14.dp, + end = spacing.pageHorizontal, + bottom = 10.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + header() + pinnedToolbar() + } + } + } + LazyColumn( + modifier = Modifier + .weight(1f) + .hazeSource(hazeState), + state = effectiveListState, + contentPadding = PaddingValues( + start = spacing.pageHorizontal, + top = if (pinnedToolbar == null) 18.dp else 10.dp, + end = spacing.pageHorizontal, + bottom = if (bottomBar == null) 148.dp else 220.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + if (pinnedToolbar == null) { + item { header() } + } + content() + } + if (bottomBar != null) { + MulticaHazeBottomBar(hazeState = hazeState, content = bottomBar) + } + } +} + +@Composable +private fun MulticaCupertinoPinnedHeader( + hazeState: HazeState, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp)) + .hazeEffect( + hazeState, + style = HazeDefaults.style( + backgroundColor = MulticaColors.Background.copy(alpha = 0.78f), + blurRadius = 24.dp, + noiseFactor = 0.08f, + ), + ) + .background(MulticaColors.Background.copy(alpha = 0.78f)) + .semantics(mergeDescendants = false) { contentDescription = "Multica Cupertino Pinned Header" }, + ) { + content() + } +} + +@Composable +private fun MulticaGlassBottomSlot( + hazeState: HazeState, + content: @Composable () -> Unit, +) { + MulticaHazeBottomBar( + hazeState = hazeState, + contentDescription = "Multica Glass Bottom Slot", + content = content, + ) +} + +@Composable +fun MulticaHazeBottomBar( + hazeState: HazeState, + modifier: Modifier = Modifier, + contentDescription: String = "Multica Haze Bottom Bar", + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)) + .hazeEffect( + hazeState, + style = HazeDefaults.style( + backgroundColor = MulticaColors.Background.copy(alpha = 0.72f), + blurRadius = 24.dp, + noiseFactor = 0.08f, + ), + ) + .background(MulticaColors.Background.copy(alpha = 0.72f)) + .imePadding() + .semantics(mergeDescendants = false) { this.contentDescription = contentDescription }, + ) { + content() + } +} + +@Composable +fun MulticaGroupedList( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MulticaColors.Border), + ) { + Column(content = content) + } +} + +@Composable +fun MulticaSectionHeader( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.padding(top = 24.dp, bottom = 8.dp, start = 2.dp), + ) +} + +@Composable +fun MulticaListItem( + title: String, + modifier: Modifier = Modifier, + eyebrow: String? = null, + subtitle: String? = null, + leadingIcon: ImageVector? = null, + trailing: @Composable (() -> Unit)? = null, + showDivider: Boolean = false, + onClick: (() -> Unit)? = null, + contentDescription: String? = null, +) { + val rowModifier = modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .then(if (contentDescription.isNullOrBlank()) Modifier else Modifier.semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + }) + Column(modifier = rowModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = MulticaColors.TextTertiary, + modifier = Modifier + .padding(end = 12.dp) + .size(24.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + if (!eyebrow.isNullOrBlank()) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MulticaColors.TextPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MulticaColors.TextSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + trailing?.invoke() + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = if (leadingIcon == null) 16.dp else 52.dp) + .height(1.dp) + .background(MulticaColors.Border), + ) + } + } +} + +@Composable +fun MulticaCupertinoSection( + title: String, + modifier: Modifier = Modifier, + footer: String? = null, + contentDescription: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .semantics(mergeDescendants = false) { + this.contentDescription = contentDescription ?: "Multica Cupertino Section $title" + }, + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Column( + modifier = Modifier.padding(start = 4.dp, top = 16.dp, end = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!footer.isNullOrBlank()) { + Text( + text = footer, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + ), + color = MulticaColors.TextTertiary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MulticaColors.Surface.copy(alpha = 0.92f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(0.5.dp, MulticaColors.Border.copy(alpha = 0.72f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + content = content, + ) + } + } +} + +@Composable +fun MulticaCupertinoListRow( + title: String, + subtitle: String, + leadingIcon: ImageVector, + modifier: Modifier = Modifier, + value: String? = null, + showDivider: Boolean = false, + destructive: Boolean = false, + contentDescription: String? = null, + onClick: () -> Unit, +) { + val tint = if (destructive) MulticaColors.Danger else MulticaColors.Muted + val interactionSource = remember { MutableInteractionSource() } + val haptic = LocalHapticFeedback.current + val performClick = { + performMulticaTapFeedback(haptic) + onClick() + } + Column( + modifier = modifier + .fillMaxWidth() + .multicaSpringPressFeedback(interactionSource) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { performClick() } + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription ?: "Multica Cupertino List Row $title" + semanticsOnClick(label = title) { + performClick() + true + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(28.dp), + shape = RoundedCornerShape(8.dp), + color = if (destructive) { + MulticaColors.Danger.copy(alpha = 0.10f) + } else { + MulticaColors.Border.copy(alpha = 0.36f) + }, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(15.dp), + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Medium, + ), + color = if (destructive) MulticaColors.Danger else MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + ), + color = MulticaColors.TextTertiary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (!value.isNullOrBlank()) { + Text( + text = value, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + ), + color = if (destructive) MulticaColors.Danger else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 120.dp), + textAlign = TextAlign.End, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.Muted, + modifier = Modifier.size(17.dp), + ) + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 38.dp) + .height(0.5.dp) + .background(MulticaColors.Border.copy(alpha = 0.72f)), + ) + } + } +} + +@Composable +fun MulticaCupertinoSwitchRow( + title: String, + subtitle: String, + checked: Boolean, + saving: Boolean, + modifier: Modifier = Modifier, + showDivider: Boolean = false, + contentDescription: String, + onClick: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val performClick = { + if (!saving) { + performMulticaTapFeedback(haptic) + onClick() + } + } + Column( + modifier = modifier + .fillMaxWidth() + .multicaSpringPressFeedback(interactionSource, enabled = !saving) + .clickable( + enabled = !saving, + interactionSource = interactionSource, + indication = null, + ) { performClick() } + .semantics(mergeDescendants = false) { + this.contentDescription = "Multica Cupertino Switch Row $title" + if (!saving) { + semanticsOnClick(label = title) { + performClick() + true + } + } + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Medium, + ), + color = MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 13.sp, + ), + color = MulticaColors.TextTertiary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (saving) { + Text( + text = "...", + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.Muted, + modifier = Modifier.width(48.dp), + textAlign = TextAlign.Center, + ) + } else { + CupertinoSwitch( + checked = checked, + onCheckedChange = { performClick() }, + modifier = Modifier.semantics { this.contentDescription = contentDescription }, + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(MulticaColors.Border.copy(alpha = 0.72f)), + ) + } + } +} + +@Composable +fun MulticaBottomNav( + selectedTab: MulticaTab, + zh: Boolean = false, + hazeState: HazeState? = null, + onTabClick: (MulticaTab) -> Unit, +) { + val personalTabs = listOf(MulticaTab.Inbox, MulticaTab.MyIssues) + val workspaceTabs = listOf(MulticaTab.Issues, MulticaTab.Projects) + val configureTabs = listOf(MulticaTab.Settings) + val orderedTabs = listOf( + MulticaTab.Inbox, + MulticaTab.Issues, + MulticaTab.MyIssues, + MulticaTab.Projects, + MulticaTab.Settings, + ) + val haptic = LocalHapticFeedback.current + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 14.dp, vertical = 10.dp) + .semantics(mergeDescendants = false) { + contentDescription = "Multica Floating Haze Tab Bar Web Navigation Groups Personal Workspace Configure" + }, + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 430.dp) + .clip(RoundedCornerShape(28.dp)) + .then( + if (hazeState == null) { + Modifier + } else { + Modifier.hazeEffect( + hazeState, + style = HazeDefaults.style( + backgroundColor = MulticaColors.SurfaceElevated.copy(alpha = 0.72f), + blurRadius = 28.dp, + noiseFactor = 0.07f, + ), + ) + } + ) + .background(MulticaColors.SurfaceElevated.copy(alpha = 0.72f)) + .border(0.5.dp, MulticaColors.Border.copy(alpha = 0.62f), RoundedCornerShape(28.dp)) + .padding(horizontal = 6.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + orderedTabs.forEach { tab -> + val selected = tab == selectedTab + val visibleLabel = if (zh) tab.zhLabel else tab.label + val groupLabel = when (tab) { + in personalTabs -> "Personal" + in workspaceTabs -> "Workspace" + in configureTabs -> "Configure" + else -> "Workspace" + } + val interactionSource = remember(tab) { MutableInteractionSource() } + val performClick = { + performMulticaTapFeedback(haptic) + onTabClick(tab) + } + Column( + modifier = Modifier + .weight(1f) + .multicaSpringPressFeedback(interactionSource) + .clip(RoundedCornerShape(22.dp)) + .background(if (selected) MulticaColors.AccentSoft.copy(alpha = 0.86f) else Color.Transparent) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { performClick() } + .semantics(mergeDescendants = true) { + contentDescription = "Bottom Tab ${tab.label} Web Group $groupLabel" + semanticsOnClick(label = visibleLabel) { + performClick() + true + } + } + .padding(horizontal = 2.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(width = 38.dp, height = 25.dp) + .clip(RoundedCornerShape(999.dp)) + .background(if (selected) MulticaColors.Accent.copy(alpha = 0.14f) else Color.Transparent), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = tab.icon, + contentDescription = null, + tint = if (selected) MulticaColors.Accent else MulticaColors.Muted, + modifier = Modifier.size(21.dp), + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = visibleLabel, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ), + color = if (selected) MulticaColors.Accent else MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +enum class MulticaButtonTone { Primary, Secondary, Ghost, Destructive } + +private fun performMulticaTapFeedback(haptic: HapticFeedback) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) +} + +@Composable +private fun Modifier.multicaSpringPressFeedback( + interactionSource: MutableInteractionSource, + enabled: Boolean = true, +): Modifier { + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (enabled && pressed) 0.975f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "Multica spring press", + ) + return graphicsLayer { + scaleX = scale + scaleY = scale + } +} + +data class MulticaCupertinoActionSheetItem( + val title: String, + val subtitle: String? = null, + val icon: ImageVector? = null, + val contentDescription: String? = null, + val selected: Boolean = false, + val destructive: Boolean = false, + val enabled: Boolean = true, + val onClick: () -> Unit, +) + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaCupertinoActionSheet( + visible: Boolean, + title: String, + message: String? = null, + items: List, + cancelText: String, + onDismissRequest: () -> Unit, +) { + if (!visible) return + val haptic = LocalHapticFeedback.current + CupertinoActionSheet( + visible = visible, + onDismissRequest = onDismissRequest, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextPrimary, + textAlign = TextAlign.Center, + ) + }, + message = { + if (!message.isNullOrBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + } + }, + buttons = { + items.forEach { item -> + action( + onClick = { + performMulticaTapFeedback(haptic) + item.onClick() + onDismissRequest() + }, + style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default, + enabled = item.enabled, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}" + }, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + item.icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = null, + tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary, + modifier = Modifier.size(16.dp), + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal, + ), + color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + if (!item.subtitle.isNullOrBlank()) { + Text( + text = item.subtitle, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp), + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } + if (item.selected) { + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = null, + tint = MulticaColors.Accent, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + action( + onClick = { + performMulticaTapFeedback(haptic) + onDismissRequest() + }, + style = AlertActionStyle.Cancel, + enabled = true, + ) { + Text( + text = cancelText, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + ) + } + }, + ) +} + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaCupertinoAlertDialog( + visible: Boolean, + title: String, + message: String, + cancelText: String, + confirmText: String, + destructive: Boolean = false, + contentDescription: String, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + if (!visible) return + val haptic = LocalHapticFeedback.current + CupertinoAlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text( + text = title, + modifier = Modifier.semantics { + this.contentDescription = "Multica Cupertino Alert Dialog $contentDescription" + }, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextPrimary, + textAlign = TextAlign.Center, + ) + }, + message = { + Text( + text = message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + }, + buttons = { + action( + onClick = { + performMulticaTapFeedback(haptic) + onDismissRequest() + }, + style = AlertActionStyle.Cancel, + enabled = true, + ) { + Text( + text = cancelText, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Accent, + ) + } + action( + onClick = { + performMulticaTapFeedback(haptic) + onConfirm() + }, + style = if (destructive) AlertActionStyle.Destructive else AlertActionStyle.Default, + enabled = true, + ) { + Text( + text = confirmText, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), + color = if (destructive) MulticaColors.Danger else MulticaColors.Accent, + ) + } + }, + ) +} + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaCupertinoSecretDialog( + visible: Boolean, + title: String, + message: String, + secret: String, + copied: Boolean, + copyText: String, + doneText: String, + copiedText: String, + contentDescription: String, + onDismissRequest: () -> Unit, + onCopy: () -> Unit, +) { + if (!visible) return + val haptic = LocalHapticFeedback.current + CupertinoAlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text( + text = title, + modifier = Modifier.semantics { + this.contentDescription = "Multica Cupertino Secret Dialog $contentDescription" + }, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold, + ), + color = MulticaColors.TextPrimary, + textAlign = TextAlign.Center, + ) + }, + message = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.TextTertiary, + textAlign = TextAlign.Center, + ) + Text( + text = secret, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MulticaColors.Background.copy(alpha = 0.96f)) + .border(0.5.dp, MulticaColors.Border.copy(alpha = 0.72f), RoundedCornerShape(8.dp)) + .padding(horizontal = 9.dp, vertical = 8.dp) + .semantics { this.contentDescription = "Multica Secret Dialog Value" }, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + lineHeight = 14.sp, + fontFamily = FontFamily.Monospace, + ), + color = MulticaColors.TextPrimary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + ) + if (copied) { + MulticaSuccessState( + copiedText, + modifier = Modifier.semantics { this.contentDescription = "Multica Secret Dialog Copy Success Motion" }, + ) + } + } + }, + buttons = { + action( + onClick = { + performMulticaTapFeedback(haptic) + onDismissRequest() + }, + style = AlertActionStyle.Cancel, + enabled = true, + ) { + Text( + text = doneText, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.Accent, + ) + } + action( + onClick = { + performMulticaTapFeedback(haptic) + onCopy() + }, + style = AlertActionStyle.Default, + enabled = true, + ) { + Text( + text = copyText, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Accent, + ) + } + }, + ) +} + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaPillButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tone: MulticaButtonTone = MulticaButtonTone.Ghost, + icon: ImageVector? = null, + contentDescription: String? = null, + enabled: Boolean = true, +) { + val activeBackground = when (tone) { + MulticaButtonTone.Primary -> MulticaColors.Accent + MulticaButtonTone.Secondary -> MulticaColors.AccentSoft + MulticaButtonTone.Ghost -> MulticaColors.SurfaceElevated + MulticaButtonTone.Destructive -> MulticaColors.Danger.copy(alpha = 0.13f) + } + val activeForeground = when (tone) { + MulticaButtonTone.Primary -> Color.White + MulticaButtonTone.Secondary -> MulticaColors.Accent + MulticaButtonTone.Ghost -> MulticaColors.TextPrimary + MulticaButtonTone.Destructive -> MulticaColors.Danger + } + val background = if (enabled) activeBackground else MulticaColors.Surface + val foreground = if (enabled) activeForeground else MulticaColors.Muted + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val performClick = { + performMulticaTapFeedback(haptic) + onClick() + } + val describedModifier = if (contentDescription.isNullOrBlank()) { + modifier + } else { + modifier.clearAndSetSemantics { + this.contentDescription = contentDescription + if (enabled) { + semanticsOnClick(label = text) { + performClick() + true + } + } + } + } + CupertinoButton( + onClick = performClick, + modifier = describedModifier + .heightIn(min = 32.dp) + .multicaSpringPressFeedback(interactionSource, enabled), + enabled = enabled, + size = CupertinoButtonSize.Small, + shape = RoundedCornerShape(10.dp), + colors = CupertinoButtonDefaults.filledButtonColors( + contentColor = foreground, + containerColor = background, + disabledContentColor = MulticaColors.Muted, + disabledContainerColor = MulticaColors.Surface, + ), + contentPadding = PaddingValues(0.dp), + interactionSource = interactionSource, + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = foreground, + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.SemiBold, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = foreground, + ) + } + } +} + +@Composable +fun MulticaIconPillButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tone: MulticaButtonTone = MulticaButtonTone.Ghost, + enabled: Boolean = true, +) { + val activeBackground = when (tone) { + MulticaButtonTone.Primary -> MulticaColors.Accent + MulticaButtonTone.Secondary -> MulticaColors.AccentSoft + MulticaButtonTone.Ghost -> MulticaColors.SurfaceElevated + MulticaButtonTone.Destructive -> MulticaColors.Danger.copy(alpha = 0.13f) + } + val activeForeground = when (tone) { + MulticaButtonTone.Primary -> Color.White + MulticaButtonTone.Secondary -> MulticaColors.Accent + MulticaButtonTone.Ghost -> MulticaColors.TextPrimary + MulticaButtonTone.Destructive -> MulticaColors.Danger + } + val background = if (enabled) activeBackground else MulticaColors.Surface + val foreground = if (enabled) activeForeground else MulticaColors.Muted + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val performClick = { + performMulticaTapFeedback(haptic) + onClick() + } + Box( + modifier = modifier + .multicaSpringPressFeedback(interactionSource, enabled) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + ) { performClick() } + .clearAndSetSemantics { + this.contentDescription = contentDescription + if (enabled) { + semanticsOnClick(label = contentDescription) { + performClick() + true + } + } + } + .heightIn(min = 36.dp) + .widthIn(min = 36.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(background) + .padding(horizontal = 9.dp, vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = foreground, + ) + } + } +} + +@Composable +fun MulticaListRow( + eyebrow: String, + title: String, + subtitle: String?, + modifier: Modifier = Modifier, + unread: Boolean = false, + showLeadingIndicator: Boolean = unread, + contentDescription: String? = null, + onClick: (() -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val clickableModifier = if (onClick == null) { + modifier + } else { + modifier.clickable(onClick = onClick) + } + val rowModifier = if (contentDescription.isNullOrBlank()) { + clickableModifier + } else { + clickableModifier.semantics { + this.contentDescription = contentDescription + if (onClick != null) { + semanticsOnClick(label = title) { + onClick() + true + } + } + } + } + Column( + modifier = rowModifier + .fillMaxWidth(), + ) { + Row(modifier = Modifier.padding(horizontal = 0.dp, vertical = 12.dp)) { + if (showLeadingIndicator) { + Box( + modifier = Modifier + .padding(top = 12.dp) + .size(7.dp) + .clip(CircleShape) + .background(if (unread) MulticaColors.Accent else MulticaColors.Muted.copy(alpha = 0.34f)) + ) + Spacer(modifier = Modifier.width(14.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.TextTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = if (unread) FontWeight.Bold else FontWeight.SemiBold, + ), + color = MulticaColors.TextPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MulticaColors.TextSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier.padding(top = 4.dp), + contentAlignment = Alignment.Center, + ) { + trailing() + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = if (showLeadingIndicator) 21.dp else 0.dp) + .height(1.dp) + .background(MulticaColors.Border) + ) + } +} + +@Composable +fun StatusPill(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MulticaColors.TextSecondary, + modifier = modifier + .clip(RoundedCornerShape(999.dp)) + .background(MulticaColors.SurfaceElevated) + .padding(horizontal = 9.dp, vertical = 5.dp), + ) +} + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaCupertinoTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + enabled: Boolean = true, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, +) { + val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label" + val fieldModifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = fieldDescription } + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.TextTertiary, + ) + CupertinoBorderedTextField( + value = value, + onValueChange = onValueChange, + modifier = fieldModifier, + enabled = enabled, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + placeholder = { Text(label, color = MulticaColors.TextTertiary) }, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 17.sp, lineHeight = 22.sp, color = MulticaColors.TextPrimary), + shape = RoundedCornerShape(12.dp), + ) + } +} + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + enabled: Boolean = true, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, +) { + MulticaCupertinoTextField( + value = value, + onValueChange = onValueChange, + label = label, + modifier = modifier, + contentDescription = contentDescription, + enabled = enabled, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + ) +} + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaLoadingState(message: String) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MulticaColors.Background) + .semantics { contentDescription = "Multica System Loading State" }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(contentAlignment = Alignment.Center) { + MulticaMotionGlyph(kind = MulticaMotionGlyphKind.Loading, size = 48.dp) + CupertinoActivityIndicator( + modifier = Modifier + .size(24.dp) + .semantics { contentDescription = "Multica Cupertino Activity Indicator" }, + ) + } + Text( + text = compactSystemStateTitle(message), + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium), + color = MulticaColors.TextSecondary, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +enum class MulticaMotionGlyphKind { + Loading, + Empty, + Error, + Success, +} + +@Composable +fun MulticaMotionGlyph( + kind: MulticaMotionGlyphKind, + modifier: Modifier = Modifier, + size: androidx.compose.ui.unit.Dp, +) { + val json = when (kind) { + MulticaMotionGlyphKind.Loading -> multicaLoadingLottieJson + MulticaMotionGlyphKind.Empty -> multicaEmptyLottieJson + MulticaMotionGlyphKind.Error -> multicaErrorLottieJson + MulticaMotionGlyphKind.Success -> multicaSuccessLottieJson + } + val composition by rememberLottieComposition( + LottieCompositionSpec.JsonString(json) + ) + val iterations = when (kind) { + MulticaMotionGlyphKind.Loading -> LottieConstants.IterateForever + MulticaMotionGlyphKind.Empty, + MulticaMotionGlyphKind.Error, + MulticaMotionGlyphKind.Success -> 1 + } + val description = when (kind) { + MulticaMotionGlyphKind.Loading -> "Multica Loading Lottie" + MulticaMotionGlyphKind.Empty -> "Multica Empty Lottie" + MulticaMotionGlyphKind.Error -> "Multica Error Lottie" + MulticaMotionGlyphKind.Success -> "Multica Success Lottie" + } + LottieAnimation( + composition = composition, + iterations = iterations, + modifier = modifier + .size(size) + .semantics { contentDescription = description }, + safeMode = true, + ) +} + +private val multicaLoadingLottieJson = """ +{ + "v":"5.7.4", + "fr":60, + "ip":0, + "op":90, + "w":120, + "h":120, + "nm":"multica-loading", + "ddd":0, + "assets":[], + "layers":[ + { + "ddd":0, + "ind":1, + "ty":4, + "nm":"orbit-dot", + "sr":1, + "ks":{ + "o":{"a":0,"k":100}, + "r":{"a":1,"k":[{"t":0,"s":[0],"e":[360]},{"t":90,"s":[360]}]}, + "p":{"a":0,"k":[60,60,0]}, + "a":{"a":0,"k":[0,0,0]}, + "s":{"a":0,"k":[100,100,100]} + }, + "shapes":[ + { + "ty":"gr", + "it":[ + {"ty":"el","p":{"a":0,"k":[0,-38]},"s":{"a":0,"k":[16,16]}}, + {"ty":"fl","c":{"a":0,"k":[0.145,0.388,0.922,1]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}} + ] + } + ], + "ip":0, + "op":90, + "st":0, + "bm":0 + } + ] +} +""".trimIndent() + +@Composable +fun MulticaEmptyState(title: String, description: String? = null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MulticaMotionGlyph(kind = MulticaMotionGlyphKind.Empty, size = 54.dp) + Spacer(modifier = Modifier.height(10.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, color = MulticaColors.TextPrimary) + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text(description, style = MaterialTheme.typography.bodyMedium, color = MulticaColors.TextSecondary) + } + } +} + +@Composable +fun MulticaSuccessState(message: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MulticaColors.Success.copy(alpha = 0.08f)) + .border(1.dp, MulticaColors.Success.copy(alpha = 0.18f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 9.dp) + .semantics { contentDescription = "Multica Success Row" }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MulticaMotionGlyph(kind = MulticaMotionGlyphKind.Success, size = 28.dp) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MulticaColors.Success, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private val multicaSuccessLottieJson = """ +{ + "v":"5.7.4", + "fr":60, + "ip":0, + "op":60, + "w":120, + "h":120, + "nm":"multica-success-state", + "ddd":0, + "assets":[], + "layers":[ + { + "ddd":0, + "ind":1, + "ty":4, + "nm":"success-ring", + "sr":1, + "ks":{ + "o":{"a":1,"k":[{"t":0,"s":[0],"e":[100]},{"t":12,"s":[100],"e":[100]},{"t":60,"s":[100]}]}, + "r":{"a":0,"k":0}, + "p":{"a":0,"k":[60,60,0]}, + "a":{"a":0,"k":[0,0,0]}, + "s":{"a":1,"k":[{"t":0,"s":[72,72,100],"e":[112,112,100]},{"t":18,"s":[112,112,100],"e":[100,100,100]},{"t":34,"s":[100,100,100],"e":[100,100,100]},{"t":60,"s":[100,100,100]}]} + }, + "shapes":[ + { + "ty":"gr", + "it":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[78,78]}}, + {"ty":"st","c":{"a":0,"k":[0.086,0.639,0.290,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":6},"lc":2,"lj":2}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}} + ] + } + ], + "ip":0, + "op":60, + "st":0, + "bm":0 + }, + { + "ddd":0, + "ind":2, + "ty":4, + "nm":"success-check", + "sr":1, + "ks":{ + "o":{"a":1,"k":[{"t":10,"s":[0],"e":[100]},{"t":20,"s":[100],"e":[100]},{"t":60,"s":[100]}]}, + "r":{"a":0,"k":0}, + "p":{"a":0,"k":[60,60,0]}, + "a":{"a":0,"k":[0,0,0]}, + "s":{"a":1,"k":[{"t":10,"s":[84,84,100],"e":[104,104,100]},{"t":28,"s":[104,104,100],"e":[100,100,100]},{"t":60,"s":[100,100,100]}]} + }, + "shapes":[ + { + "ty":"gr", + "it":[ + {"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-21,-1],[-6,15],[24,-19]],"c":false}}}, + {"ty":"st","c":{"a":0,"k":[0.086,0.639,0.290,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2}, + {"ty":"tr","p":{"a":0,"k":[0,2]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}} + ] + } + ], + "ip":10, + "op":60, + "st":0, + "bm":0 + } + ] +} +""".trimIndent() + +private val multicaEmptyLottieJson = """ +{ + "v":"5.7.4", + "fr":60, + "ip":0, + "op":72, + "w":120, + "h":120, + "nm":"multica-empty-state", + "ddd":0, + "assets":[], + "layers":[ + { + "ddd":0, + "ind":1, + "ty":4, + "nm":"soft-ring", + "sr":1, + "ks":{ + "o":{"a":1,"k":[{"t":0,"s":[0],"e":[46]},{"t":18,"s":[46],"e":[46]},{"t":72,"s":[46]}]}, + "r":{"a":0,"k":0}, + "p":{"a":0,"k":[60,60,0]}, + "a":{"a":0,"k":[0,0,0]}, + "s":{"a":1,"k":[{"t":0,"s":[88,88,100],"e":[100,100,100]},{"t":24,"s":[100,100,100],"e":[100,100,100]},{"t":72,"s":[100,100,100]}]} + }, + "shapes":[ + { + "ty":"gr", + "it":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[74,74]}}, + {"ty":"st","c":{"a":0,"k":[0.145,0.388,0.922,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":4}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}} + ] + } + ], + "ip":0, + "op":72, + "st":0, + "bm":0 + }, + { + "ddd":0, + "ind":2, + "ty":4, + "nm":"center-dot", + "sr":1, + "ks":{ + "o":{"a":1,"k":[{"t":0,"s":[0],"e":[100]},{"t":14,"s":[100],"e":[100]},{"t":72,"s":[100]}]}, + "r":{"a":0,"k":0}, + "p":{"a":0,"k":[60,60,0]}, + "a":{"a":0,"k":[0,0,0]}, + "s":{"a":1,"k":[{"t":0,"s":[62,62,100],"e":[100,100,100]},{"t":22,"s":[100,100,100],"e":[100,100,100]},{"t":72,"s":[100,100,100]}]} + }, + "shapes":[ + { + "ty":"gr", + "it":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[18,18]}}, + {"ty":"fl","c":{"a":0,"k":[0.145,0.388,0.922,1]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}} + ] + } + ], + "ip":0, + "op":72, + "st":0, + "bm":0 + } + ] +} +""".trimIndent() + +private val multicaErrorLottieJson = multicaEmptyLottieJson + +@Composable +fun MulticaErrorState( + message: String, + onRetry: () -> Unit, + fullScreen: Boolean = false, +) { + val title = compactSystemStateTitle(message) + val detail = compactSystemStateDetail(message) + Column( + modifier = (if (fullScreen) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) + .then(if (fullScreen) Modifier.background(MulticaColors.Background) else Modifier) + .padding(horizontal = 24.dp, vertical = 40.dp) + .semantics { contentDescription = "Multica System Error State" }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = if (fullScreen) Arrangement.Center else Arrangement.Top, + ) { + Box(contentAlignment = Alignment.Center) { + MulticaMotionGlyph(kind = MulticaMotionGlyphKind.Error, size = 42.dp) + Surface( + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "Multica Error Visible Glyph" }, + shape = CircleShape, + color = MulticaColors.Danger.copy(alpha = 0.10f), + border = BorderStroke(1.dp, MulticaColors.Danger.copy(alpha = 0.18f)), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = MulticaColors.Danger, + modifier = Modifier.size(19.dp), + ) + } + } + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Danger, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (detail.isNotBlank()) { + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = MulticaColors.Muted, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + MulticaPillButton( + text = "Retry", + onClick = onRetry, + tone = MulticaButtonTone.Primary, + contentDescription = "Global Retry Primary Action", + ) + } +} + +private fun compactSystemStateTitle(message: String): String = + message.lineSequence().firstOrNull()?.trim().orEmpty().ifBlank { "Loading" } + +private fun compactSystemStateDetail(message: String): String = + message.lineSequence().drop(1).joinToString(" ").trim() diff --git a/app/src/main/java/ai/multica/app/ui/theme/MulticaTheme.kt b/app/src/main/java/ai/multica/app/ui/theme/MulticaTheme.kt new file mode 100644 index 0000000..cb0419b --- /dev/null +++ b/app/src/main/java/ai/multica/app/ui/theme/MulticaTheme.kt @@ -0,0 +1,230 @@ +package ai.multica.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.alexzhirkevich.cupertino.ExperimentalCupertinoApi +import io.github.alexzhirkevich.cupertino.theme.CupertinoTheme + +object MulticaColors { + var Accent = Color(0xFF2563EB) + private set + var AccentSoft = Color(0xFF1C2638) + private set + var Background = Color.Black + private set + var GroupedBackground = Color.Black + private set + var Surface = Color(0xFF1C1C1E) + private set + var SurfaceElevated = Color(0xFF242426) + private set + var Text = Color(0xFFF5F5F7) + private set + var TextPrimary = Color(0xFFF5F5F7) + private set + var TextSecondary = Color(0xFFA1A1AA) + private set + var TextTertiary = Color(0xFF71717A) + private set + var Muted = Color(0xFF8E8E93) + private set + var Border = Color(0xFF2C2C2E) + private set + var Success = Color(0xFF16A34A) + private set + var Danger = Color(0xFFFF453A) + private set + var Warning = Color(0xFFFF9F0A) + private set + + fun applyDarkTheme(darkTheme: Boolean) { + Accent = Color(0xFF2563EB) + Success = Color(0xFF16A34A) + Danger = if (darkTheme) Color(0xFFFF453A) else Color(0xFFDC2626) + Warning = if (darkTheme) Color(0xFFFF9F0A) else Color(0xFFD97706) + if (darkTheme) { + AccentSoft = Color(0xFF1C2638) + Background = Color.Black + GroupedBackground = Color(0xFF0B0B0D) + Surface = Color(0xFF1C1C1E) + SurfaceElevated = Color(0xFF242426) + TextPrimary = Color(0xFFF5F5F7) + TextSecondary = Color(0xFFA1A1AA) + TextTertiary = Color(0xFF71717A) + Text = TextPrimary + Muted = TextSecondary + Border = Color(0xFF2C2C2E) + } else { + AccentSoft = Color(0xFFEAF2FF) + Background = Color(0xFFF6F7F9) + GroupedBackground = Color(0xFFF2F3F6) + Surface = Color.White + SurfaceElevated = Color.White + TextPrimary = Color(0xFF111827) + TextSecondary = Color(0xFF6B7280) + TextTertiary = Color(0xFF9CA3AF) + Text = TextPrimary + Muted = TextSecondary + Border = Color(0xFFE5E7EB) + } + } +} + +@Immutable +data class MulticaSpacing( + val page: Dp = 18.dp, + val pageHorizontal: Dp = 20.dp, + val pageTop: Dp = 28.dp, + val sectionTop: Dp = 22.dp, + val rowGap: Dp = 10.dp, + val rowPaddingHorizontal: Dp = 14.dp, + val rowPaddingVertical: Dp = 12.dp, + val compactRowVertical: Dp = 9.dp, + val controlHeight: Dp = 44.dp, + val smallControlHeight: Dp = 34.dp, + val bottomBarHorizontal: Dp = 16.dp, + val pillHorizontal: Dp = 12.dp, + val pillVertical: Dp = 7.dp, +) + +val LocalMulticaSpacing = staticCompositionLocalOf { MulticaSpacing() } + +private fun multicaLightScheme(): ColorScheme = lightColorScheme( + primary = MulticaColors.Accent, + onPrimary = Color.White, + primaryContainer = MulticaColors.AccentSoft, + onPrimaryContainer = MulticaColors.Accent, + background = MulticaColors.Background, + onBackground = MulticaColors.Text, + surface = MulticaColors.Surface, + onSurface = MulticaColors.Text, + surfaceVariant = Color(0xFFF3F4F6), + onSurfaceVariant = MulticaColors.Muted, + outline = MulticaColors.Border, + error = MulticaColors.Danger, + onError = Color.White, +) + +private fun multicaDarkScheme(): ColorScheme = darkColorScheme( + primary = MulticaColors.Accent, + onPrimary = Color.White, + primaryContainer = MulticaColors.AccentSoft, + onPrimaryContainer = Color(0xFF7DB1FF), + background = MulticaColors.Background, + onBackground = MulticaColors.Text, + surface = MulticaColors.Surface, + onSurface = MulticaColors.Text, + surfaceVariant = Color(0xFF242426), + onSurfaceVariant = MulticaColors.Muted, + outline = MulticaColors.Border, + error = MulticaColors.Danger, + onError = Color.White, +) + +private val largeTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, +) + +private val pageTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp, +) + +private val rowTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, + lineHeight = 22.sp, +) + +private val bodyText = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 23.sp, +) + +private val subheadline = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 15.sp, + lineHeight = 20.sp, +) + +private val captionText = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + lineHeight = 17.sp, +) + +private val eyebrowText = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + lineHeight = 14.sp, +) + +private val multicaTypography = Typography( + displaySmall = largeTitle, + headlineMedium = pageTitle, + titleMedium = rowTitle, + bodyLarge = bodyText, + bodyMedium = subheadline, + bodySmall = captionText, + labelMedium = eyebrowText, + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + ), +) + +private val multicaShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(14.dp), + large = RoundedCornerShape(18.dp), + extraLarge = RoundedCornerShape(24.dp), +) + +@Composable +@OptIn(ExperimentalCupertinoApi::class) +fun MulticaTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MulticaColors.applyDarkTheme(darkTheme) + CupertinoTheme( + content = { + MaterialTheme( + colorScheme = if (darkTheme) multicaDarkScheme() else multicaLightScheme(), + typography = multicaTypography, + shapes = multicaShapes, + content = content, + ) + }, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..95f4679 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Multica + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..93314f6 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7d8ef75 --- /dev/null +++ b/build.gradle @@ -0,0 +1,5 @@ +plugins { + id "com.android.application" version "8.9.2" apply false + id "org.jetbrains.kotlin.android" version "2.0.21" apply false + id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fee364a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties.example b/local.properties.example new file mode 100644 index 0000000..193daad --- /dev/null +++ b/local.properties.example @@ -0,0 +1,13 @@ +## Copy this file to local.properties. local.properties is gitignored. +## Keep real analytics keys out of the public repository. + +multicaUmengPublicAppKey= +multicaPostHogPublicApiKey= +multicaPostHogPublicHost=https://us.i.posthog.com +multicaAnalyticsRoute=auto + +## Optional release signing. Keep real values only in local.properties or CI secrets. +multicaReleaseStoreFile= +multicaReleaseStorePassword= +multicaReleaseKeyAlias= +multicaReleaseKeyPassword= diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9a1c90f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "MulticaAndroid" +include ":app"