From afe8ca67cd6ba0e75a51086ce4ad92533681adf0 Mon Sep 17 00:00:00 2001 From: "block-open-source[bot]" <201011344+block-open-source[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:44:17 +0000 Subject: [PATCH 001/149] Initial commit --- .../.github/ISSUE_TEMPLATE/bug-report.md | 31 +++ ui/goose2/.github/ISSUE_TEMPLATE/config.yml | 4 + ui/goose2/CODEOWNERS | 24 +++ ui/goose2/GOVERNANCE.md | 1 + ui/goose2/LICENSE | 201 ++++++++++++++++++ ui/goose2/README.md | 36 ++++ ui/goose2/renovate.json | 4 + 7 files changed, 301 insertions(+) create mode 100644 ui/goose2/.github/ISSUE_TEMPLATE/bug-report.md create mode 100644 ui/goose2/.github/ISSUE_TEMPLATE/config.yml create mode 100644 ui/goose2/CODEOWNERS create mode 100644 ui/goose2/GOVERNANCE.md create mode 100644 ui/goose2/LICENSE create mode 100644 ui/goose2/README.md create mode 100644 ui/goose2/renovate.json diff --git a/ui/goose2/.github/ISSUE_TEMPLATE/bug-report.md b/ui/goose2/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000000..25f0bc5ed806 --- /dev/null +++ b/ui/goose2/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,31 @@ +--- +name: 🐛 Bug Report +about: Thank you for taking the time, please report a reproducible bug +title: "[Bug] " +labels: bug +assignees: add codeowner's @name here + +--- + +**Describe the bug** +*A clear and concise description of what the bug is.* + +**To Reproduce:** +*Steps to reproduce the behavior:* +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior:** +*A clear and concise description of what you expected to happen.* + +**Supporting Material** +*If applicable, add screenshots, output log and/or other documentation to help explain your problem.* + +**Environment (please complete the following information):** + - OS: [ex: iOS] + - Version + +**Additional context** +Add any other context that you feel is relevant about the problem here. diff --git a/ui/goose2/.github/ISSUE_TEMPLATE/config.yml b/ui/goose2/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..0ba9db25329d --- /dev/null +++ b/ui/goose2/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +contact_links: + - name: ❓ Questions and Help 🤔 + url: https://discord.gg/block-opensource (/add your discord channel if applicable) + about: This issue tracker is not for support questions. Please refer to the community for more help. diff --git a/ui/goose2/CODEOWNERS b/ui/goose2/CODEOWNERS new file mode 100644 index 000000000000..21ecf13a6fc9 --- /dev/null +++ b/ui/goose2/CODEOWNERS @@ -0,0 +1,24 @@ +# This CODEOWNERS file denotes the project leads +# and encodes their responsibilities for code review. + +# Instructions: At a minimum, replace the '@GITHUB_USER_NAME_GOES_HERE' +# here with at least one project lead. + +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. +# The format is described: https://github.blog/2017-07-06-introducing-code-owners/ + +# These owners will be the default owners for everything in the repo. +* @GITHUB_USER_NAME_GOES_HERE + + +# ----------------------------------------------- +# BELOW THIS LINE ARE TEMPLATES, UNUSED +# ----------------------------------------------- +# Order is important. The last matching pattern has the most precedence. +# So if a pull request only touches javascript files, only these owners +# will be requested to review. +# *.js @octocat @github/js + +# You can also use email addresses if you prefer. +# docs/* docs@example.com \ No newline at end of file diff --git a/ui/goose2/GOVERNANCE.md b/ui/goose2/GOVERNANCE.md new file mode 100644 index 000000000000..6bccf43368fd --- /dev/null +++ b/ui/goose2/GOVERNANCE.md @@ -0,0 +1 @@ +## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md) \ No newline at end of file diff --git a/ui/goose2/LICENSE b/ui/goose2/LICENSE new file mode 100644 index 000000000000..862ee3c28647 --- /dev/null +++ b/ui/goose2/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + 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 + + http://www.apache.org/licenses/LICENSE-2.0 + +Copyright 2026 Block, Inc. + + 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. diff --git a/ui/goose2/README.md b/ui/goose2/README.md new file mode 100644 index 000000000000..4bc7ee849568 --- /dev/null +++ b/ui/goose2/README.md @@ -0,0 +1,36 @@ +# $PROJECT_NAME README + +Congrats, project leads! You got a new project to grow! + +This stub is meant to help you form a strong community around your work. It's yours to adapt, and may +diverge from this initial structure. Just keep the files seeded in this repo, and the rest is yours to evolve! + +## Introduction + +Orient users to the project here. This is a good place to start with an assumption +that the user knows very little - so start with the Big Picture and show how this +project fits into it. + +Then maybe a dive into what this project does. + +Diagrams and other visuals are helpful here. Perhaps code snippets showing usage. + +Project leads should complete, alongside this `README`: + +* [CODEOWNERS](./CODEOWNERS) - set project lead(s) +* [CONTRIBUTING.md](./CONTRIBUTING.md) - Fill out how to: install prereqs, build, test, run, access CI, chat, discuss, file issues +* [Bug-report.md](.github/ISSUE_TEMPLATE/bug-report.md) - Fill out `Assignees` add codeowners @names +* [config.yml](.github/ISSUE_TEMPLATE/config.yml) - remove "(/add your discord channel..)" and replace the url with your Discord channel if applicable + +The other files in this template repo may be used as-is: + +* [GOVERNANCE.md](./GOVERNANCE.md) +* [LICENSE](./LICENSE) + +## Project Resources + +| Resource | Description | +| ------------------------------------------ | ------------------------------------------------------------------------------ | +| [CODEOWNERS](./CODEOWNERS) | Outlines the project lead(s) | +| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | +| [LICENSE](./LICENSE) | Apache License, Version 2.0 | diff --git a/ui/goose2/renovate.json b/ui/goose2/renovate.json new file mode 100644 index 000000000000..9582fc588754 --- /dev/null +++ b/ui/goose2/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", "helpers:pinGitHubActionDigests"] +} From c4c3728da7dea7b5e0a3addf6ed1447dffa08e12 Mon Sep 17 00:00:00 2001 From: "block-open-source[bot]" <1159699+block-open-source[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:44:20 +0000 Subject: [PATCH 002/149] prepare repo template --- ui/goose2/CODEOWNERS | 2 +- ui/goose2/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/goose2/CODEOWNERS b/ui/goose2/CODEOWNERS index 21ecf13a6fc9..fdd6cfc2de36 100644 --- a/ui/goose2/CODEOWNERS +++ b/ui/goose2/CODEOWNERS @@ -9,7 +9,7 @@ # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for everything in the repo. -* @GITHUB_USER_NAME_GOES_HERE +* @wesbillman # ----------------------------------------------- diff --git a/ui/goose2/README.md b/ui/goose2/README.md index 4bc7ee849568..39cfb4b6ffe1 100644 --- a/ui/goose2/README.md +++ b/ui/goose2/README.md @@ -1,4 +1,4 @@ -# $PROJECT_NAME README +# goose2 README Congrats, project leads! You got a new project to grow! From 70acf22cb8be8936b2d0865f209bed5fe04bd7b9 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 29 Mar 2026 09:34:05 -0700 Subject: [PATCH 003/149] feat: scaffold Goose2 desktop app with full UI and testing infrastructure (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: scaffold Tauri + React app with window dragging and app icon Sets up the goose2 desktop app with: - Tauri 2 + React 19 + Vite + Tailwind CSS - Custom titlebar with drag-to-move and double-click maximize/restore - Window state persistence via tauri-plugin-window-state - App icon ported from goose-desktop (cyan/amber yin-yang design) - Theme system with dark/light/system modes - Hermit toolchain (node, pnpm, biome, just, lefthook) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add app shell layout with tabs, sidebar, and status bar Adds the main application layout ported from goose2.0's visual design: - TabBar with session tabs, home button, and sidebar toggle - Collapsible sidebar with smooth width transition - Status bar with model name, token count, and status indicator - AppShell component wiring everything together Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add settings modal with theme section + quality fixes Settings: - Settings modal with sidebar nav (Appearance, General, About) - Theme selector (Light/Dark/System) wired to ThemeProvider - Accent color picker and density selector (visual placeholders) - Open via Cmd+, or Settings button in sidebar Quality fixes: - Fix stale state bug in tab close handler - Fix unreachable useTheme guard, add OS theme change listener - Improve accessibility (ARIA roles, labels, semantic elements) - Add prefers-reduced-motion support - Extract shared Tab type to features/tabs/types.ts Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove references to nonexistent theme-tokens module The previous commit introduced imports for a theme-tokens module that doesn't exist. Remove those imports and define the needed types locally in ThemeProvider. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: wire up accent colors and density, add AGENTS.md Theming: - ThemeProvider now manages accent color and density (persisted to localStorage) - Accent color applied via --color-accent CSS variable on :root - Density applied via --density-spacing CSS variable (multiplier) - AppearanceSettings uses ThemeProvider context instead of local state - Tailwind config maps accent color variable - Settings modal animations enhanced to match goose2.0 reference - Component classes updated to use semantic token names Documentation: - AGENTS.md captures all project conventions, patterns, and decisions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace ambiguous Tailwind arbitrary durations with config values Add duration-400 and duration-600 to Tailwind config to avoid ambiguous class warnings from arbitrary value syntax. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: sidebar minimizes to icon strip instead of hiding The sidebar now shrinks to a 48px icon-only column when toggled, instead of disappearing completely. Icons stay visible with tooltips, labels and section headers are hidden. Smooth width transition. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve settings button alignment and styling in sidebar Match goose2.0's footer pattern with proper padding, full-opacity border, flex alignment, and smooth transition between states. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add home screen with clock, greeting, and hidden status bar Home screen shows a live digital clock, time-based greeting, and placeholder chat input. Status bar hides when on the home view (no active tab), matching goose2.0's clean home experience. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: improve home screen to match goose2.0, add chat view Home screen: large mono clock with AM/PM, time-based greeting, goose2.0-style chat input (rounded-2xl, shadow-lg, model badge). Chat view: input at bottom with scrollable messages area. Home vs chat differentiated by active tab state. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: chat view now fills the full available height Remove flex from main element so child components can properly resolve h-full against the block parent. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add testing infrastructure (Vitest, Playwright, CI) Testing: - Vitest for unit/component tests with jsdom + React Testing Library - 13 tests across 3 test files (ThemeProvider, cn utility, HomeScreen) - Playwright for E2E smoke tests (4 tests) - Test setup with matchMedia mock for jsdom Tooling: - Just targets: test, test-watch, test-coverage, test-e2e - Lefthook pre-push now runs unit tests - CI gate includes tests CI/CD: - GitHub Actions workflow with 4 jobs: lint, test, desktop, rust-lint - Playwright artifacts uploaded on failure - Rust caching for faster builds Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve all Biome formatting and lint issues Auto-format all files with Biome. Fix lint issues: - Add type="button" to all interactive buttons - Replace span[role=button] with proper button element - Add dialog role to settings modal - Biome-ignore for standard accessibility !important pattern All checks pass: pnpm check, pnpm test, pnpm build. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: add src-tauri/target/ to .gitignore Prevent Rust/Tauri build artifacts from being committed. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: exclude Tauri generated schemas from Biome formatting * chore: update .gitignore for full project coverage Co-Authored-By: Claude Opus 4.6 (1M context) * fix: pin CI actions to commit SHAs for Semgrep compliance Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve CI failures and improve lefthook auto-formatting - Fix dtolnay/rust-toolchain action SHA (was invalid, causing all Rust CI jobs to fail) - Fix Biome folder ignore pattern (remove trailing /**) - Update lefthook pre-commit to auto-format with biome instead of just checking Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use vite preview directly with explicit host for E2E server The Playwright webServer was using `pnpm preview --port 4173` which invoked vite preview without --host, causing it to bind to localhost (IPv6 ::1) instead of 127.0.0.1. This caused ERR_CONNECTION_REFUSED in CI and locally. Switch to npx vite preview with --host 127.0.0.1 and --strictPort to ensure reliable binding. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gracefully handle non-Tauri browser environment Check for Tauri runtime before calling window APIs so the app renders correctly in browser-based E2E tests. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make E2E smoke tests more robust with longer timeouts - Add 10s timeout for element visibility (CI can be slow to render) - Use .first() for model badge to handle multiple matches - Fix strict mode violation for duplicate text matches Co-Authored-By: Claude Opus 4.6 (1M context) * feat: smooth transition for status bar show/hide Status bar now slides and fades in/out with a 300ms transition instead of appearing/disappearing abruptly when switching views. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use dynamic import for Tauri API to prevent browser crash Static import of @tauri-apps/api/window fails at module load time in non-Tauri environments. Use dynamic import() guarded by __TAURI_INTERNALS__ check so the app renders in Playwright. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use python http.server for E2E tests, start with empty tabs Switch Playwright webServer from npx vite preview to python3 http.server for more reliable static file serving in CI. Start app with no tabs open (home screen by default). Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align E2E setup with Sprout's proven configuration - Use url instead of port for webServer readiness check - Remove -b 127.0.0.1 flag from python http.server command - Add explicit cwd to webServer config - Use pnpm exec instead of npx for Playwright in CI Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove unused @tauri-apps/plugin-opener JS dependency The Rust-side tauri-plugin-opener is still registered, but the JS binding package was never imported from frontend code. Removing it keeps the dependency list honest. Dark mode colors already match goose2.0 — no changes needed there. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/.github/workflows/ci.yml | 142 + ui/goose2/.gitignore | 42 + ui/goose2/AGENTS.md | 112 + ui/goose2/app-icon.png | Bin 0 -> 5336 bytes ui/goose2/bin/.biome-2.4.9.pkg | 1 + ui/goose2/bin/.just-1.48.1.pkg | 1 + ui/goose2/bin/.lefthook-2.1.4.pkg | 1 + ui/goose2/bin/.node-24.14.1.pkg | 1 + ui/goose2/bin/.pnpm-10.33.0.pkg | 1 + ui/goose2/bin/README.hermit.md | 7 + ui/goose2/bin/activate-hermit | 21 + ui/goose2/bin/activate-hermit.fish | 24 + ui/goose2/bin/biome | 1 + ui/goose2/bin/corepack | 1 + ui/goose2/bin/hermit | 43 + ui/goose2/bin/hermit.hcl | 2 + ui/goose2/bin/just | 1 + ui/goose2/bin/lefthook | 1 + ui/goose2/bin/node | 1 + ui/goose2/bin/npm | 1 + ui/goose2/bin/npx | 1 + ui/goose2/bin/pnpm | 1 + ui/goose2/biome.json | 38 + ui/goose2/components.json | 19 + ui/goose2/index.html | 13 + ui/goose2/justfile | 89 + ui/goose2/lefthook.yml | 22 + ui/goose2/package.json | 53 + ui/goose2/playwright.config.ts | 33 + ui/goose2/pnpm-lock.yaml | 2843 +++++++++ ui/goose2/postcss.config.js | 6 + ui/goose2/scripts/check-file-sizes.mjs | 65 + ui/goose2/src-tauri/Cargo.lock | 5485 +++++++++++++++++ ui/goose2/src-tauri/Cargo.toml | 20 + ui/goose2/src-tauri/build.rs | 3 + ui/goose2/src-tauri/capabilities/default.json | 16 + .../src-tauri/gen/schemas/acl-manifests.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 1 + .../src-tauri/gen/schemas/desktop-schema.json | 2519 ++++++++ .../src-tauri/gen/schemas/macOS-schema.json | 2519 ++++++++ ui/goose2/src-tauri/icons/128x128.png | Bin 0 -> 3512 bytes ui/goose2/src-tauri/icons/128x128@2x.png | Bin 0 -> 7012 bytes ui/goose2/src-tauri/icons/32x32.png | Bin 0 -> 974 bytes ui/goose2/src-tauri/icons/64x64.png | Bin 0 -> 159 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 2863 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 3858 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 3966 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 7737 bytes ui/goose2/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 903 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 8591 bytes ui/goose2/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1299 bytes ui/goose2/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 2011 bytes ui/goose2/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 2468 bytes ui/goose2/src-tauri/icons/StoreLogo.png | Bin 0 -> 1523 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 173 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 373 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 278 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 173 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 238 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 274 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 282 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 514 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 486 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 426 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 901 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 714 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 563 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 1355 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 902 bytes .../android/values/ic_launcher_background.xml | 4 + ui/goose2/src-tauri/icons/icon.icns | Bin 0 -> 98451 bytes ui/goose2/src-tauri/icons/icon.ico | Bin 0 -> 86642 bytes ui/goose2/src-tauri/icons/icon.png | Bin 0 -> 14183 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 91 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 115 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 115 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 145 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 103 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 141 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 141 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 194 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 115 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 183 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 183 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 263 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 5693 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 263 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 417 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 178 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 351 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 386 bytes ui/goose2/src-tauri/src/lib.rs | 14 + ui/goose2/src-tauri/src/main.rs | 6 + ui/goose2/src-tauri/tauri.conf.json | 44 + ui/goose2/src-tauri/tauri.dev.conf.json | 4 + ui/goose2/src/app/App.tsx | 18 + ui/goose2/src/app/AppShell.tsx | 78 + ui/goose2/src/env.d.ts | 7 + ui/goose2/src/features/chat/ui/ChatView.tsx | 40 + .../src/features/home/ui/HomeScreen.test.tsx | 33 + ui/goose2/src/features/home/ui/HomeScreen.tsx | 79 + .../settings/ui/AppearanceSettings.tsx | 140 + .../features/settings/ui/SettingsModal.tsx | 154 + ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 90 + .../src/features/status/ui/StatusBar.tsx | 39 + ui/goose2/src/features/tabs/types.ts | 4 + ui/goose2/src/features/tabs/ui/TabBar.tsx | 91 + ui/goose2/src/main.tsx | 29 + ui/goose2/src/shared/api/.gitkeep | 0 ui/goose2/src/shared/constants/.gitkeep | 0 ui/goose2/src/shared/context/.gitkeep | 0 ui/goose2/src/shared/lib/cn.test.ts | 20 + ui/goose2/src/shared/lib/cn.ts | 6 + ui/goose2/src/shared/styles/globals.css | 199 + .../src/shared/theme/ThemeProvider.test.tsx | 78 + ui/goose2/src/shared/theme/ThemeProvider.tsx | 144 + ui/goose2/src/shared/ui/button.tsx | 56 + ui/goose2/src/test/setup.ts | 16 + ui/goose2/src/vite-env.d.ts | 1 + ui/goose2/tailwind.config.js | 93 + ui/goose2/tests/e2e/smoke.spec.ts | 35 + ui/goose2/tsconfig.json | 25 + ui/goose2/tsconfig.node.json | 10 + ui/goose2/vite.config.ts | 30 + ui/goose2/vitest.config.ts | 19 + 126 files changed, 15762 insertions(+) create mode 100644 ui/goose2/.github/workflows/ci.yml create mode 100644 ui/goose2/.gitignore create mode 100644 ui/goose2/AGENTS.md create mode 100644 ui/goose2/app-icon.png create mode 120000 ui/goose2/bin/.biome-2.4.9.pkg create mode 120000 ui/goose2/bin/.just-1.48.1.pkg create mode 120000 ui/goose2/bin/.lefthook-2.1.4.pkg create mode 120000 ui/goose2/bin/.node-24.14.1.pkg create mode 120000 ui/goose2/bin/.pnpm-10.33.0.pkg create mode 100644 ui/goose2/bin/README.hermit.md create mode 100755 ui/goose2/bin/activate-hermit create mode 100755 ui/goose2/bin/activate-hermit.fish create mode 120000 ui/goose2/bin/biome create mode 120000 ui/goose2/bin/corepack create mode 100755 ui/goose2/bin/hermit create mode 100644 ui/goose2/bin/hermit.hcl create mode 120000 ui/goose2/bin/just create mode 120000 ui/goose2/bin/lefthook create mode 120000 ui/goose2/bin/node create mode 120000 ui/goose2/bin/npm create mode 120000 ui/goose2/bin/npx create mode 120000 ui/goose2/bin/pnpm create mode 100644 ui/goose2/biome.json create mode 100644 ui/goose2/components.json create mode 100644 ui/goose2/index.html create mode 100644 ui/goose2/justfile create mode 100644 ui/goose2/lefthook.yml create mode 100644 ui/goose2/package.json create mode 100644 ui/goose2/playwright.config.ts create mode 100644 ui/goose2/pnpm-lock.yaml create mode 100644 ui/goose2/postcss.config.js create mode 100644 ui/goose2/scripts/check-file-sizes.mjs create mode 100644 ui/goose2/src-tauri/Cargo.lock create mode 100644 ui/goose2/src-tauri/Cargo.toml create mode 100644 ui/goose2/src-tauri/build.rs create mode 100644 ui/goose2/src-tauri/capabilities/default.json create mode 100644 ui/goose2/src-tauri/gen/schemas/acl-manifests.json create mode 100644 ui/goose2/src-tauri/gen/schemas/capabilities.json create mode 100644 ui/goose2/src-tauri/gen/schemas/desktop-schema.json create mode 100644 ui/goose2/src-tauri/gen/schemas/macOS-schema.json create mode 100644 ui/goose2/src-tauri/icons/128x128.png create mode 100644 ui/goose2/src-tauri/icons/128x128@2x.png create mode 100644 ui/goose2/src-tauri/icons/32x32.png create mode 100644 ui/goose2/src-tauri/icons/64x64.png create mode 100644 ui/goose2/src-tauri/icons/Square107x107Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square142x142Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square150x150Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square284x284Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square30x30Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square310x310Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square44x44Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square71x71Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square89x89Logo.png create mode 100644 ui/goose2/src-tauri/icons/StoreLogo.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 ui/goose2/src-tauri/icons/icon.icns create mode 100644 ui/goose2/src-tauri/icons/icon.ico create mode 100644 ui/goose2/src-tauri/icons/icon.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 ui/goose2/src-tauri/src/lib.rs create mode 100644 ui/goose2/src-tauri/src/main.rs create mode 100644 ui/goose2/src-tauri/tauri.conf.json create mode 100644 ui/goose2/src-tauri/tauri.dev.conf.json create mode 100644 ui/goose2/src/app/App.tsx create mode 100644 ui/goose2/src/app/AppShell.tsx create mode 100644 ui/goose2/src/env.d.ts create mode 100644 ui/goose2/src/features/chat/ui/ChatView.tsx create mode 100644 ui/goose2/src/features/home/ui/HomeScreen.test.tsx create mode 100644 ui/goose2/src/features/home/ui/HomeScreen.tsx create mode 100644 ui/goose2/src/features/settings/ui/AppearanceSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/SettingsModal.tsx create mode 100644 ui/goose2/src/features/sidebar/ui/Sidebar.tsx create mode 100644 ui/goose2/src/features/status/ui/StatusBar.tsx create mode 100644 ui/goose2/src/features/tabs/types.ts create mode 100644 ui/goose2/src/features/tabs/ui/TabBar.tsx create mode 100644 ui/goose2/src/main.tsx create mode 100644 ui/goose2/src/shared/api/.gitkeep create mode 100644 ui/goose2/src/shared/constants/.gitkeep create mode 100644 ui/goose2/src/shared/context/.gitkeep create mode 100644 ui/goose2/src/shared/lib/cn.test.ts create mode 100644 ui/goose2/src/shared/lib/cn.ts create mode 100644 ui/goose2/src/shared/styles/globals.css create mode 100644 ui/goose2/src/shared/theme/ThemeProvider.test.tsx create mode 100644 ui/goose2/src/shared/theme/ThemeProvider.tsx create mode 100644 ui/goose2/src/shared/ui/button.tsx create mode 100644 ui/goose2/src/test/setup.ts create mode 100644 ui/goose2/src/vite-env.d.ts create mode 100644 ui/goose2/tailwind.config.js create mode 100644 ui/goose2/tests/e2e/smoke.spec.ts create mode 100644 ui/goose2/tsconfig.json create mode 100644 ui/goose2/tsconfig.node.json create mode 100644 ui/goose2/vite.config.ts create mode 100644 ui/goose2/vitest.config.ts diff --git a/ui/goose2/.github/workflows/ci.yml b/ui/goose2/.github/workflows/ci.yml new file mode 100644 index 000000000000..77a162fc410f --- /dev/null +++ b/ui/goose2/.github/workflows/ci.yml @@ -0,0 +1,142 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - run: pnpm typecheck + + test: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + desktop: + name: Desktop Build & E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Cache Rust + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + + - run: pnpm install --frozen-lockfile + + - name: Build frontend + run: pnpm build + + - name: Check Tauri + run: cd src-tauri && cargo check + + - name: Clippy + run: cd src-tauri && cargo clippy -- -D warnings + + - name: Format check + run: cd src-tauri && cargo fmt --check + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run E2E smoke tests + run: pnpm exec playwright test --project=smoke + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 + + rust-lint: + name: Rust Lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt, clippy + + - name: Cache Rust + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + + - name: Format check + run: cd src-tauri && cargo fmt --check + + - name: Clippy + run: cd src-tauri && cargo clippy -- -D warnings diff --git a/ui/goose2/.gitignore b/ui/goose2/.gitignore new file mode 100644 index 000000000000..852b309e6e12 --- /dev/null +++ b/ui/goose2/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Rust/Tauri build artifacts +/target/ +src-tauri/target/ + +# Environment files +.env +.env.* +!.env.example + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.*.sw? + +# OS artifacts +.DS_Store +Thumbs.db + +# Scratch / working files +.scratch/ + +# Playwright artifacts +playwright-report/ +test-results/ + +# Testing coverage +coverage/ + +# Logs +*.log + +# Hermit (toolchain manager cache) +.hermit/ diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md new file mode 100644 index 000000000000..a5bf039e6b75 --- /dev/null +++ b/ui/goose2/AGENTS.md @@ -0,0 +1,112 @@ +# AGENTS.md + +Guidelines for AI agents (and developers) working on this codebase. + +## Project Overview + +Goose2 is a Tauri 2 + React 19 desktop app. It uses TypeScript strict mode, Vite, and Tailwind CSS 3. The codebase follows a feature-sliced architecture organized under `src/app/`, `src/features/`, and `src/shared/`. + +## Architecture & File Structure + +``` +src/ + app/ — App shell, entry point, top-level providers + features/ — Feature modules (tabs, sidebar, settings, status) + / + ui/ — React components + types.ts — Shared types for the feature + shared/ + ui/ — Reusable UI components (button, etc.) + lib/ — Utilities (cn.ts for class merging) + theme/ — Theme provider, appearance settings + styles/ — Global CSS, design tokens + hooks/ — Shared hooks + api/ — API integration + constants/ — Shared constants + context/ — Shared contexts +``` + +## Coding Conventions + +- Use `cn()` from `@/shared/lib/cn` for Tailwind class merging. +- Import paths use the `@/` alias (maps to `./src`). +- Components are controlled where possible (state lifted to parent). +- Use `lucide-react` for icons. +- All ` + + + + + + ); +} diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx new file mode 100644 index 000000000000..a1e21b685f65 --- /dev/null +++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { HomeScreen } from "./HomeScreen"; + +// Mock the ThemeProvider since HomeScreen doesn't use it directly +// but its children might +describe("HomeScreen", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 2, 29, 14, 30, 0)); // 2:30 PM + }); + + it("renders the clock", () => { + render(); + expect(screen.getByText("2:30")).toBeInTheDocument(); + expect(screen.getByText("PM")).toBeInTheDocument(); + }); + + it("shows afternoon greeting at 2:30 PM", () => { + render(); + expect(screen.getByText("Good afternoon")).toBeInTheDocument(); + }); + + it("renders the chat input placeholder", () => { + render(); + expect(screen.getByText("Ask Goose anything...")).toBeInTheDocument(); + }); + + it("renders the model badge", () => { + render(); + expect(screen.getByText("Claude Sonnet 4")).toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx new file mode 100644 index 000000000000..6dd5904b076b --- /dev/null +++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx @@ -0,0 +1,79 @@ +import { useState, useEffect } from "react"; +import { ArrowUp } from "lucide-react"; + +function HomeClock() { + const [time, setTime] = useState(new Date()); + + useEffect(() => { + const interval = setInterval(() => setTime(new Date()), 1000); + return () => clearInterval(interval); + }, []); + + const hours = time + .toLocaleTimeString("en-US", { hour: "numeric", hour12: true }) + .replace(/\s?(AM|PM)$/i, ""); + const minutes = time + .toLocaleTimeString("en-US", { minute: "2-digit" }) + .padStart(2, "0"); + const period = time.getHours() >= 12 ? "PM" : "AM"; + + return ( +
+ + {hours}:{minutes} + + {period} +
+ ); +} + +function getGreeting(hour: number): string { + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; +} + +export function HomeScreen() { + const [hour] = useState(() => new Date().getHours()); + const greeting = getGreeting(hour); + + return ( +
+
+
+ {/* Clock */} + + + {/* Greeting */} +

+ {greeting} +

+ + {/* Chat input */} +
+
+ {/* Textarea placeholder */} +
+ Ask Goose anything... +
+ {/* Bottom bar */} +
+
+ + Claude Sonnet 4 + +
+ +
+
+
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx new file mode 100644 index 000000000000..54a05cf649d8 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx @@ -0,0 +1,140 @@ +import { cn } from "@/shared/lib/cn"; +import { useTheme } from "@/shared/theme/ThemeProvider"; +import { Sun, Moon, Monitor, Check } from "lucide-react"; + +const THEME_OPTIONS = [ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Monitor, label: "System" }, +] as const; + +const ACCENT_COLORS = [ + { name: "Blue", value: "#3b82f6" }, + { name: "Cyan", value: "#06b6d4" }, + { name: "Green", value: "#22c55e" }, + { name: "Orange", value: "#f97316" }, + { name: "Red", value: "#ef4444" }, + { name: "Pink", value: "#ec4899" }, + { name: "Purple", value: "#a855f7" }, + { name: "Indigo", value: "#6366f1" }, +]; + +const DENSITY_OPTIONS = [ + { value: "compact", label: "Compact" }, + { value: "comfortable", label: "Comfortable" }, + { value: "spacious", label: "Spacious" }, +] as const; + +function SettingRow({ + label, + description, + children, +}: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {description && ( +

+ {description} +

+ )} +
+
{children}
+
+ ); +} + +export function AppearanceSettings() { + const { theme, setTheme, accentColor, setAccentColor, density, setDensity } = + useTheme(); + + return ( +
+

Appearance

+

+ Customize the look and feel of Goose +

+ +
+ + +
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+
+ +
+ + +
+ {ACCENT_COLORS.map((color) => ( + + ))} +
+
+ +
+ + +
+ {DENSITY_OPTIONS.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx new file mode 100644 index 000000000000..66e36c42eb9a --- /dev/null +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from "react"; +import { cn } from "@/shared/lib/cn"; +import { Palette, Settings2, Info, X } from "lucide-react"; +import { AppearanceSettings } from "./AppearanceSettings"; + +const NAV_ITEMS = [ + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "general", label: "General", icon: Settings2 }, + { id: "about", label: "About", icon: Info }, +] as const; + +type SectionId = (typeof NAV_ITEMS)[number]["id"]; + +interface SettingsModalProps { + onClose: () => void; +} + +export function SettingsModal({ onClose }: SettingsModalProps) { + const [activeSection, setActiveSection] = useState("appearance"); + const [isLoaded, setIsLoaded] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + + // Trigger entrance animations after mount + useEffect(() => { + const timer = setTimeout(() => setIsLoaded(true), 50); + return () => clearTimeout(timer); + }, []); + + // Content transition on section change + // biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally + useEffect(() => { + setIsTransitioning(true); + const timer = setTimeout(() => setIsTransitioning(false), 150); + return () => clearTimeout(timer); + }, [activeSection]); + + return ( +
{ + if (e.key === "Escape") onClose(); + }} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation on inner container is not a meaningful interaction */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: click handler only prevents backdrop dismiss propagation */} +
e.stopPropagation()} + > + {/* Sidebar */} +
+
+

Settings

+
+ +
+ + {/* Content */} +
+ + +
+
+ {activeSection === "appearance" && } + {activeSection === "general" && ( +
+

General

+

+ General settings will appear here. +

+
+ )} + {activeSection === "about" && ( +
+

About

+

+ About information will appear here. +

+
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx new file mode 100644 index 000000000000..d47f58b4e267 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -0,0 +1,90 @@ +import { Plus, MessageCircle, Hash, Settings2 } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; + +interface SidebarProps { + isOpen: boolean; + onSettingsClick?: () => void; +} + +const recentItems = [ + { id: "1", icon: MessageCircle, label: "Debug login flow" }, + { id: "2", icon: Hash, label: "API refactor notes" }, + { id: "3", icon: MessageCircle, label: "Weekend deploy plan" }, + { id: "4", icon: Hash, label: "Design review" }, +]; + +export function Sidebar({ isOpen, onSettingsClick }: SidebarProps) { + return ( + + ); +} diff --git a/ui/goose2/src/features/status/ui/StatusBar.tsx b/ui/goose2/src/features/status/ui/StatusBar.tsx new file mode 100644 index 000000000000..e07f3fe4fd23 --- /dev/null +++ b/ui/goose2/src/features/status/ui/StatusBar.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/shared/lib/cn"; + +interface StatusBarProps { + modelName?: string; + tokenCount?: number; + status?: "connected" | "disconnected" | "loading"; +} + +const statusColor = { + connected: "bg-green-500", + disconnected: "bg-red-500", + loading: "bg-yellow-500", +} as const; + +export function StatusBar({ + modelName = "Claude Sonnet 4", + tokenCount = 0, + status = "disconnected", +}: StatusBarProps) { + return ( +
+ {modelName} + +
+ {tokenCount.toLocaleString()} tokens +
+
+
+ ); +} diff --git a/ui/goose2/src/features/tabs/types.ts b/ui/goose2/src/features/tabs/types.ts new file mode 100644 index 000000000000..ccf2c6e32866 --- /dev/null +++ b/ui/goose2/src/features/tabs/types.ts @@ -0,0 +1,4 @@ +export interface Tab { + id: string; + title: string; +} diff --git a/ui/goose2/src/features/tabs/ui/TabBar.tsx b/ui/goose2/src/features/tabs/ui/TabBar.tsx new file mode 100644 index 000000000000..46b9664f3e51 --- /dev/null +++ b/ui/goose2/src/features/tabs/ui/TabBar.tsx @@ -0,0 +1,91 @@ +import { Home, PanelLeft, Plus, X } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import type { Tab } from "@/features/tabs/types"; + +interface TabBarProps { + tabs: Tab[]; + activeTabId: string | null; + onTabSelect: (id: string) => void; + onTabClose: (id: string) => void; + onNewTab: () => void; + onHomeClick: () => void; + onSidebarToggle: () => void; +} + +export function TabBar({ + tabs, + activeTabId, + onTabSelect, + onTabClose, + onNewTab, + onHomeClick, + onSidebarToggle, +}: TabBarProps) { + return ( +
+ + + + +
+ {tabs.map((tab) => ( + + + ))} +
+ + +
+ ); +} diff --git a/ui/goose2/src/main.tsx b/ui/goose2/src/main.tsx new file mode 100644 index 000000000000..903e192034ab --- /dev/null +++ b/ui/goose2/src/main.tsx @@ -0,0 +1,29 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "@/app/App"; +import { ThemeProvider } from "@/shared/theme/ThemeProvider"; +import "@/shared/styles/globals.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +ReactDOM.createRoot(root).render( + + + + + + + , +); diff --git a/ui/goose2/src/shared/api/.gitkeep b/ui/goose2/src/shared/api/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/ui/goose2/src/shared/constants/.gitkeep b/ui/goose2/src/shared/constants/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/ui/goose2/src/shared/context/.gitkeep b/ui/goose2/src/shared/context/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/ui/goose2/src/shared/lib/cn.test.ts b/ui/goose2/src/shared/lib/cn.test.ts new file mode 100644 index 000000000000..6eaf69cbe6f3 --- /dev/null +++ b/ui/goose2/src/shared/lib/cn.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { cn } from "./cn"; + +describe("cn", () => { + it("merges class names", () => { + expect(cn("foo", "bar")).toBe("foo bar"); + }); + + it("handles conditional classes", () => { + expect(cn("base", false && "hidden", "visible")).toBe("base visible"); + }); + + it("resolves Tailwind conflicts", () => { + expect(cn("px-4", "px-2")).toBe("px-2"); + }); + + it("handles empty inputs", () => { + expect(cn()).toBe(""); + }); +}); diff --git a/ui/goose2/src/shared/lib/cn.ts b/ui/goose2/src/shared/lib/cn.ts new file mode 100644 index 000000000000..a5ef193506d0 --- /dev/null +++ b/ui/goose2/src/shared/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css new file mode 100644 index 000000000000..21132d324927 --- /dev/null +++ b/ui/goose2/src/shared/styles/globals.css @@ -0,0 +1,199 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Cash Sans font */ +@font-face { + font-family: "Cash Sans"; + src: + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSans-Regular.woff2) + format("woff2"), + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSans-Regular.woff) + format("woff"); + font-weight: 400; + font-style: normal; +} +@font-face { + font-family: "Cash Sans"; + src: + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSans-Medium.woff2) + format("woff2"), + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSans-Medium.woff) + format("woff"); + font-weight: 500; + font-style: normal; +} +@font-face { + font-family: "Cash Sans"; + src: + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSans-Bold.woff2) + format("woff2"), + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSans-Bold.woff) + format("woff"); + font-weight: 700; + font-style: normal; +} + +@layer base { + :root { + /* Backgrounds */ + --color-background-primary: #ffffff; + --color-background-secondary: #f4f6f7; + --color-background-tertiary: #e3e6ea; + --color-background-inverse: #000000; + --color-background-ghost: transparent; + --color-background-info: #5c98f9; + --color-background-danger: #f94b4b; + --color-background-success: #91cb80; + --color-background-warning: #fbcd44; + --color-background-disabled: #e3e6ea; + + /* Text */ + --color-text-primary: #3f434b; + --color-text-secondary: #878787; + --color-text-tertiary: #a7b0b9; + --color-text-inverse: #ffffff; + --color-text-ghost: #878787; + --color-text-info: #5c98f9; + --color-text-danger: #f94b4b; + --color-text-success: #91cb80; + --color-text-warning: #fbcd44; + --color-text-disabled: #cbd1d6; + + /* Borders */ + --color-border-primary: #e3e6ea; + --color-border-secondary: #e3e6ea; + --color-border-tertiary: #cbd1d6; + --color-border-inverse: #000000; + --color-border-ghost: transparent; + --color-border-info: #5c98f9; + --color-border-danger: #f94b4b; + --color-border-success: #91cb80; + --color-border-warning: #fbcd44; + --color-border-disabled: #e3e6ea; + + /* Rings */ + --color-ring-primary: #e3e6ea; + --color-ring-secondary: #cbd1d6; + --color-ring-inverse: #ffffff; + --color-ring-info: #5c98f9; + --color-ring-danger: #f94b4b; + --color-ring-success: #91cb80; + --color-ring-warning: #fbcd44; + + /* Shadows */ + --shadow-hairline: 0 0 0 1px rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + + /* Border radius */ + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + --color-accent: #3b82f6; + --color-accent-foreground: #ffffff; + --density-spacing: 1; + } + + .dark { + /* Backgrounds */ + --color-background-primary: #22252a; + --color-background-secondary: #3f434b; + --color-background-tertiary: #474e57; + --color-background-inverse: #cbd1d6; + --color-background-ghost: transparent; + --color-background-info: #7cacff; + --color-background-danger: #ff6b6b; + --color-background-success: #a3d795; + --color-background-warning: #ffd966; + --color-background-disabled: #474e57; + + /* Text */ + --color-text-primary: #ffffff; + --color-text-secondary: #878787; + --color-text-tertiary: #606c7a; + --color-text-inverse: #000000; + --color-text-ghost: #878787; + --color-text-info: #7cacff; + --color-text-danger: #ff6b6b; + --color-text-success: #a3d795; + --color-text-warning: #ffd966; + --color-text-disabled: #525b68; + + /* Borders */ + --color-border-primary: #3f434b; + --color-border-secondary: #525b68; + --color-border-tertiary: #474e57; + --color-border-inverse: #ffffff; + --color-border-ghost: transparent; + --color-border-info: #7cacff; + --color-border-danger: #ff6b6b; + --color-border-success: #a3d795; + --color-border-warning: #ffd966; + --color-border-disabled: #3f434b; + + /* Rings */ + --color-ring-primary: #525b68; + --color-ring-secondary: #474e57; + --color-ring-inverse: #000000; + --color-ring-info: #7cacff; + --color-ring-danger: #ff6b6b; + --color-ring-success: #a3d795; + --color-ring-warning: #ffd966; + + /* Shadows */ + --shadow-hairline: 0 0 0 1px rgba(0, 0, 0, 0.2); + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.2); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2); + + --color-accent: #3b82f6; + --color-accent-foreground: #ffffff; + --density-spacing: 1; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground font-sans antialiased; + } +} + +/* Density-aware spacing utilities */ +.gap-density { + gap: calc(0.5rem * var(--density-spacing)); +} +.p-density { + padding: calc(0.5rem * var(--density-spacing)); +} +.py-density { + padding-top: calc(0.5rem * var(--density-spacing)); + padding-bottom: calc(0.5rem * var(--density-spacing)); +} +.px-density { + padding-left: calc(0.5rem * var(--density-spacing)); + padding-right: calc(0.5rem * var(--density-spacing)); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + /* biome-ignore lint/complexity/noImportantStyles: required to override all animations for reduced-motion accessibility */ + animation-duration: 0.01ms !important; + /* biome-ignore lint/complexity/noImportantStyles: required to override all transitions for reduced-motion accessibility */ + transition-duration: 0.01ms !important; + } +} diff --git a/ui/goose2/src/shared/theme/ThemeProvider.test.tsx b/ui/goose2/src/shared/theme/ThemeProvider.test.tsx new file mode 100644 index 000000000000..120f468c38e2 --- /dev/null +++ b/ui/goose2/src/shared/theme/ThemeProvider.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach } from "vitest"; +import { ThemeProvider, useTheme } from "./ThemeProvider"; + +function ThemeConsumer() { + const { theme, setTheme, accentColor, density } = useTheme(); + return ( +
+ {theme} + {accentColor} + {density} + + +
+ ); +} + +describe("ThemeProvider", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("light", "dark"); + }); + + it("provides default theme as system", () => { + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("system"); + }); + + it("switches to dark theme", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByText("Set Dark")); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("persists theme to localStorage", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByText("Set Light")); + expect(localStorage.getItem("goose-theme")).toBe("light"); + }); + + it("provides default accent color", () => { + render( + + + , + ); + expect(screen.getByTestId("accent")).toHaveTextContent("#3b82f6"); + }); + + it("provides default density", () => { + render( + + + , + ); + expect(screen.getByTestId("density")).toHaveTextContent("comfortable"); + }); +}); diff --git a/ui/goose2/src/shared/theme/ThemeProvider.tsx b/ui/goose2/src/shared/theme/ThemeProvider.tsx new file mode 100644 index 000000000000..0958d921434f --- /dev/null +++ b/ui/goose2/src/shared/theme/ThemeProvider.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; + +type ThemePreference = "light" | "dark" | "system"; +type ResolvedTheme = "light" | "dark"; +type Density = "compact" | "comfortable" | "spacious"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: ThemePreference; +}; + +type ThemeProviderState = { + theme: ThemePreference; + resolvedTheme: ResolvedTheme; + setTheme: (theme: ThemePreference) => void; + accentColor: string; + setAccentColor: (color: string) => void; + density: Density; + setDensity: (d: Density) => void; +}; + +const ThemeProviderContext = React.createContext< + ThemeProviderState | undefined +>(undefined); + +function resolveTheme(preference: ThemePreference): ResolvedTheme { + if (preference === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return preference; +} + +export function ThemeProvider({ + children, + defaultTheme = "system", +}: ThemeProviderProps) { + const [theme, setThemeState] = React.useState(() => { + const stored = localStorage.getItem( + "goose-theme", + ) as ThemePreference | null; + return stored ?? defaultTheme; + }); + + const [resolvedTheme, setResolvedTheme] = React.useState(() => + resolveTheme(theme), + ); + + const [accentColor, setAccentColorState] = React.useState(() => { + return localStorage.getItem("goose-accent-color") ?? "#3b82f6"; + }); + + const [density, setDensityState] = React.useState(() => { + const stored = localStorage.getItem("goose-density") as Density | null; + return stored ?? "comfortable"; + }); + + const setTheme = React.useCallback((newTheme: ThemePreference) => { + localStorage.setItem("goose-theme", newTheme); + setThemeState(newTheme); + }, []); + + const setAccentColor = React.useCallback((color: string) => { + localStorage.setItem("goose-accent-color", color); + setAccentColorState(color); + }, []); + + const setDensity = React.useCallback((d: Density) => { + localStorage.setItem("goose-density", d); + setDensityState(d); + }, []); + + React.useEffect(() => { + const root = window.document.documentElement; + const resolved = resolveTheme(theme); + setResolvedTheme(resolved); + + root.classList.remove("light", "dark"); + root.classList.add(resolved); + root.style.colorScheme = resolved; + + if (theme === "system") { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => { + const updated = mq.matches ? "dark" : "light"; + setResolvedTheme(updated); + root.classList.remove("light", "dark"); + root.classList.add(updated); + root.style.colorScheme = updated; + }; + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + } + }, [theme]); + + React.useEffect(() => { + const root = window.document.documentElement; + root.style.setProperty("--color-accent", accentColor); + root.style.setProperty("--color-accent-foreground", "#ffffff"); + + const spacingScale: Record = { + compact: "0.75", + comfortable: "1", + spacious: "1.25", + }; + root.style.setProperty("--density-spacing", spacingScale[density]); + }, [accentColor, density]); + + const value = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + accentColor, + setAccentColor, + density, + setDensity, + }), + [ + theme, + resolvedTheme, + setTheme, + accentColor, + setAccentColor, + density, + setDensity, + ], + ); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = React.useContext(ThemeProviderContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/ui/goose2/src/shared/ui/button.tsx b/ui/goose2/src/shared/ui/button.tsx new file mode 100644 index 000000000000..9ad0c101fae9 --- /dev/null +++ b/ui/goose2/src/shared/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/shared/lib/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-background-inverse text-foreground-inverse shadow hover:bg-background-inverse/90", + destructive: + "bg-background-danger text-foreground-inverse shadow-sm hover:bg-background-danger/90", + outline: + "border border-border bg-background shadow-sm hover:bg-background-secondary hover:text-foreground", + secondary: + "bg-background-secondary text-foreground shadow-sm hover:bg-background-secondary/80", + ghost: "hover:bg-background-secondary hover:text-foreground", + link: "text-foreground underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export type ButtonProps = React.ButtonHTMLAttributes & + VariantProps & { + asChild?: boolean; + }; + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/ui/goose2/src/test/setup.ts b/ui/goose2/src/test/setup.ts new file mode 100644 index 000000000000..0ef1ad256314 --- /dev/null +++ b/ui/goose2/src/test/setup.ts @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom/vitest"; + +// Mock matchMedia for jsdom (not available by default) +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); diff --git a/ui/goose2/src/vite-env.d.ts b/ui/goose2/src/vite-env.d.ts new file mode 100644 index 000000000000..11f02fe2a006 --- /dev/null +++ b/ui/goose2/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/goose2/tailwind.config.js b/ui/goose2/tailwind.config.js new file mode 100644 index 000000000000..cc2c34b6df12 --- /dev/null +++ b/ui/goose2/tailwind.config.js @@ -0,0 +1,93 @@ +import tailwindcssAnimate from "tailwindcss-animate"; + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + fontFamily: { + sans: ["'Cash Sans'", "sans-serif"], + mono: ["monospace"], + }, + borderRadius: { + xs: "var(--radius-xs)", + sm: "var(--radius-sm)", + md: "var(--radius-md)", + lg: "var(--radius-lg)", + xl: "var(--radius-xl)", + full: "var(--radius-full)", + }, + boxShadow: { + hairline: "var(--shadow-hairline)", + sm: "var(--shadow-sm)", + md: "var(--shadow-md)", + lg: "var(--shadow-lg)", + }, + colors: { + background: { + DEFAULT: "var(--color-background-primary)", + primary: "var(--color-background-primary)", + secondary: "var(--color-background-secondary)", + tertiary: "var(--color-background-tertiary)", + inverse: "var(--color-background-inverse)", + ghost: "var(--color-background-ghost)", + info: "var(--color-background-info)", + danger: "var(--color-background-danger)", + success: "var(--color-background-success)", + warning: "var(--color-background-warning)", + disabled: "var(--color-background-disabled)", + }, + foreground: { + DEFAULT: "var(--color-text-primary)", + primary: "var(--color-text-primary)", + secondary: "var(--color-text-secondary)", + tertiary: "var(--color-text-tertiary)", + inverse: "var(--color-text-inverse)", + ghost: "var(--color-text-ghost)", + info: "var(--color-text-info)", + danger: "var(--color-text-danger)", + success: "var(--color-text-success)", + warning: "var(--color-text-warning)", + disabled: "var(--color-text-disabled)", + }, + border: { + DEFAULT: "var(--color-border-primary)", + primary: "var(--color-border-primary)", + secondary: "var(--color-border-secondary)", + tertiary: "var(--color-border-tertiary)", + inverse: "var(--color-border-inverse)", + ghost: "var(--color-border-ghost)", + info: "var(--color-border-info)", + danger: "var(--color-border-danger)", + success: "var(--color-border-success)", + warning: "var(--color-border-warning)", + disabled: "var(--color-border-disabled)", + }, + ring: { + DEFAULT: "var(--color-ring-primary)", + primary: "var(--color-ring-primary)", + secondary: "var(--color-ring-secondary)", + inverse: "var(--color-ring-inverse)", + info: "var(--color-ring-info)", + danger: "var(--color-ring-danger)", + success: "var(--color-ring-success)", + warning: "var(--color-ring-warning)", + }, + accent: { + DEFAULT: "var(--color-accent)", + foreground: "var(--color-accent-foreground)", + }, + // Semantic status colors for inline use (status dots, badges) + green: { 500: "#91cb80" }, + red: { 500: "#f94b4b" }, + yellow: { 500: "#fbcd44" }, + }, + transitionDuration: { + 400: "400ms", + 600: "600ms", + }, + }, + }, + plugins: [tailwindcssAnimate], +}; diff --git a/ui/goose2/tests/e2e/smoke.spec.ts b/ui/goose2/tests/e2e/smoke.spec.ts new file mode 100644 index 000000000000..14111fe11b7b --- /dev/null +++ b/ui/goose2/tests/e2e/smoke.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Smoke tests", () => { + test("app loads and shows home screen", async ({ page }) => { + await page.goto("/"); + + // Wait for the app to render — greeting should appear + await expect( + page.getByText(/Good (morning|afternoon|evening)/), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("home screen shows clock", async ({ page }) => { + await page.goto("/"); + + // Should show AM or PM once the clock renders + await expect(page.getByText(/[AP]M/)).toBeVisible({ timeout: 10_000 }); + }); + + test("home screen shows chat input placeholder", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Ask Goose anything...")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("home screen shows model badge", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Claude Sonnet 4").first()).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/ui/goose2/tsconfig.json b/ui/goose2/tsconfig.json new file mode 100644 index 000000000000..6715b819a011 --- /dev/null +++ b/ui/goose2/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/ui/goose2/tsconfig.node.json b/ui/goose2/tsconfig.node.json new file mode 100644 index 000000000000..42872c59f5b0 --- /dev/null +++ b/ui/goose2/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/ui/goose2/vite.config.ts b/ui/goose2/vite.config.ts new file mode 100644 index 000000000000..defd75fde392 --- /dev/null +++ b/ui/goose2/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +export default defineConfig(async () => ({ + plugins: [react()], + resolve: { + alias: { + "@": "/src", + }, + }, + clearScreen: false, + server: { + port: 1520, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1521, + } + : undefined, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/ui/goose2/vitest.config.ts b/ui/goose2/vitest.config.ts new file mode 100644 index 000000000000..6f66312cda2a --- /dev/null +++ b/ui/goose2/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + css: true, + }, +}); From efec8897d25f23648228c0e00323eb060f6a6b24 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 29 Mar 2026 14:00:45 -0700 Subject: [PATCH 004/149] feat: port floating sidebar from goose2.0 (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: port floating sidebar from goose2.0 - Rewrite Sidebar.tsx with floating panel design (backdrop blur, rounded corners, shadow) - Smooth collapse/expand animation (48px ↔ 240px) with staggered label transitions - Search bar morphs between icon-only and full bar with ⌘K badge - Nav items with staggered fade-in animation delays - Collapsible recent chats section - User avatar footer as settings trigger - GooseIcon SVG in header (replaces emoji) - Update AppShell layout: sidebar wrapped in padding div for floating effect - Add ⌘B keyboard shortcut for sidebar toggle - All color tokens mapped to goose2 design system * fix: add missing GooseIcon and GooseLogo files * fix: remove duplicate sidebar toggle, fix traffic lights on blur - Remove PanelLeft sidebar toggle from TabBar (sidebar has its own toggle) - Add solid background behind macOS traffic light area so window controls remain visible when the window loses focus - Clean up unused onSidebarToggle prop from TabBar/AppShell * fix: make TabBar fully opaque so macOS traffic lights stay visible on blur The previous fix added a solid background div behind the traffic light area, but it rendered within the semi-transparent parent context (bg-background/80 backdrop-blur-sm). The OS compositor still saw transparency and hid the traffic lights on window blur. Simply making the TabBar background fully opaque fixes this properly. * fix: clean up nav items, subtle ⌘K badge, remove backdrop-blur for traffic lights - Nav items: keep only New Chat, Agents, Skills (removed Tasks, Artifacts, Apps) - ⌘K badge: lighter text color (foreground-secondary/40), no background - Sidebar: remove backdrop-blur-xl and make bg fully opaque — fixes macOS traffic lights disappearing on window blur * fix: improve sidebar border contrast in dark mode In dark mode, border-primary (#3f434b) and background-secondary (#3f434b) are identical, making sidebar dividers and the search border invisible. Switched all internal sidebar borders/dividers to border-secondary (#525b68) which provides clear contrast against the sidebar background in both themes. * fix: align dark mode palette with goose2.0 Updated dark mode color tokens to match goose2.0's much darker palette: - background-primary: #22252a → #0d0d0d (near black, was too light) - background-secondary: #3f434b → #141414 (sidebar, card-like) - background-tertiary: #474e57 → #262626 (muted surfaces) - Borders, text, and ring colors adjusted accordingly - This also fixes the dark mode border visibility issue since border-primary (#262626) is now distinct from background-secondary (#141414) * fix: revert custom traffic light code, rely on Tauri's built-in trafficLightPosition The custom Rust code that called setHidden:NO on focus events was interfering with Tauri's native traffic light positioning, causing them to shift down. Removed the custom code entirely — Tauri's trafficLightPosition config in tauri.conf.json handles positioning correctly on its own. --- ui/goose2/src/app/AppShell.tsx | 47 ++- ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 353 ++++++++++++++--- ui/goose2/src/features/tabs/ui/TabBar.tsx | 15 +- ui/goose2/src/shared/styles/globals.css | 42 +- ui/goose2/src/shared/ui/GooseLogo.tsx | 51 +++ ui/goose2/src/shared/ui/icons/GooseIcon.tsx | 370 ++++++++++++++++++ 6 files changed, 786 insertions(+), 92 deletions(-) create mode 100644 ui/goose2/src/shared/ui/GooseLogo.tsx create mode 100644 ui/goose2/src/shared/ui/icons/GooseIcon.tsx diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 85a57919ad40..132bfd4ab03c 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -7,8 +7,11 @@ import { ChatView } from "@/features/chat/ui/ChatView"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; import type { Tab } from "@/features/tabs/types"; +const SIDEBAR_WIDTH = 240; +const SIDEBAR_COLLAPSED_WIDTH = 48; + export function AppShell({ children }: { children?: React.ReactNode }) { - const [sidebarOpen, setSidebarOpen] = useState(true); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); @@ -30,12 +33,20 @@ export function AppShell({ children }: { children?: React.ReactNode }) { }); }; + const toggleSidebar = () => setSidebarCollapsed((prev) => !prev); + useEffect(() => { const handler = (e: KeyboardEvent) => { + // Cmd+, for settings if (e.key === "," && e.metaKey) { e.preventDefault(); setSettingsOpen((prev) => !prev); } + // Cmd+B for sidebar toggle + if (e.key === "b" && e.metaKey) { + e.preventDefault(); + setSidebarCollapsed((prev) => !prev); + } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); @@ -43,6 +54,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return (
+ {/* Tab bar — full width across the top */} setActiveTabId(null)} - onSidebarToggle={() => setSidebarOpen((prev) => !prev)} /> -
- setSettingsOpen(true)} - /> + + {/* Main content area — sidebar + content as flex row */} +
+ {/* Sidebar wrapper — padding creates the floating effect */} +
+ setSettingsOpen(true)} + className="h-full shadow-xl rounded-xl" + /> +
+ + {/* Content area */}
{children ?? (isHome ? : )}
+ + {/* Status bar — conditional with animation */}
+ + {/* Settings modal */} {settingsOpen && setSettingsOpen(false)} />}
); diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index d47f58b4e267..30f34c1a3bae 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -1,90 +1,341 @@ -import { Plus, MessageCircle, Hash, Settings2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + BookOpen, + Bot, + MessageSquare, + PanelLeft, + PanelLeftClose, + Plus, + Search, +} from "lucide-react"; import { cn } from "@/shared/lib/cn"; +import { GooseIcon } from "@/shared/ui/icons/GooseIcon"; interface SidebarProps { - isOpen: boolean; + collapsed: boolean; + width?: number; + onCollapse: () => void; onSettingsClick?: () => void; + onSearchClick?: () => void; + onNewChat?: () => void; + className?: string; } -const recentItems = [ - { id: "1", icon: MessageCircle, label: "Debug login flow" }, - { id: "2", icon: Hash, label: "API refactor notes" }, - { id: "3", icon: MessageCircle, label: "Weekend deploy plan" }, - { id: "4", icon: Hash, label: "Design review" }, -]; +const NAV_ITEMS = [ + { id: "agents", label: "Agents", icon: Bot }, + { id: "skills", label: "Skills", icon: BookOpen }, +] as const; + +const RECENT_CHATS = [ + { id: "1", name: "Debug login flow", time: "2m" }, + { id: "2", name: "API refactor notes", time: "1h" }, + { id: "3", name: "Weekend deploy plan", time: "3h" }, + { id: "4", name: "Design review", time: "1d" }, +] as const; + +export function Sidebar({ + collapsed, + width = 240, + onCollapse, + onSettingsClick, + onSearchClick, + onNewChat, + className, +}: SidebarProps) { + const [expanded, setExpanded] = useState(!collapsed); + const prevCollapsed = useRef(collapsed); + + useEffect(() => { + if (collapsed) { + setExpanded(false); + } else if (prevCollapsed.current && !collapsed) { + const timer = setTimeout(() => setExpanded(true), 60); + return () => clearTimeout(timer); + } else { + setExpanded(true); + } + prevCollapsed.current = collapsed; + }, [collapsed]); + + const labelTransition = "transition-all duration-300 ease-out"; + const labelVisible = expanded && !collapsed; -export function Sidebar({ isOpen, onSettingsClick }: SidebarProps) { return ( - +
); } diff --git a/ui/goose2/src/features/tabs/ui/TabBar.tsx b/ui/goose2/src/features/tabs/ui/TabBar.tsx index 46b9664f3e51..47dc7432914b 100644 --- a/ui/goose2/src/features/tabs/ui/TabBar.tsx +++ b/ui/goose2/src/features/tabs/ui/TabBar.tsx @@ -1,4 +1,4 @@ -import { Home, PanelLeft, Plus, X } from "lucide-react"; +import { Home, Plus, X } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import type { Tab } from "@/features/tabs/types"; @@ -9,7 +9,6 @@ interface TabBarProps { onTabClose: (id: string) => void; onNewTab: () => void; onHomeClick: () => void; - onSidebarToggle: () => void; } export function TabBar({ @@ -19,22 +18,12 @@ export function TabBar({ onTabClose, onNewTab, onHomeClick, - onSidebarToggle, }: TabBarProps) { return (
- -
+ ); +} diff --git a/ui/goose2/src/shared/ui/icons/GooseIcon.tsx b/ui/goose2/src/shared/ui/icons/GooseIcon.tsx new file mode 100644 index 000000000000..288c956b8b08 --- /dev/null +++ b/ui/goose2/src/shared/ui/icons/GooseIcon.tsx @@ -0,0 +1,370 @@ +export function GooseIcon({ className = "" }: { className?: string }) { + return ( + + ); +} + +export function Rain({ className = "" }: { className?: string }) { + return ( + + ); +} From ea7d712cf0bd0e73ecb636d2036dfa4af784f182 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 29 Mar 2026 20:34:23 -0700 Subject: [PATCH 005/149] feat: add Skills and Agents pages with sidebar navigation (#4) - Add SkillsView and AgentsView stub pages matching goose2.0 layout (title left, action button right, search bar, empty state) - Add MainPanelLayout shared component for page layout - Add SearchBar shared component for page-level search - Add page-transition CSS animation (matching goose2.0) - Add storage path constants (~/.goose/) - Wire up sidebar nav items (Agents, Skills) with view routing - Add AppView type and view state management to AppShell - Sidebar active state uses subtle bg-background-secondary treatment --- ui/goose2/src/app/AppShell.tsx | 39 +++++++++++++-- .../src/features/agents/ui/AgentsView.tsx | 50 +++++++++++++++++++ ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 17 +++++-- .../src/features/skills/ui/SkillsView.tsx | 50 +++++++++++++++++++ ui/goose2/src/shared/constants/storage.ts | 21 ++++++++ ui/goose2/src/shared/styles/globals.css | 39 +++++++++++++++ ui/goose2/src/shared/ui/MainPanelLayout.tsx | 21 ++++++++ ui/goose2/src/shared/ui/SearchBar.tsx | 35 +++++++++++++ 8 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 ui/goose2/src/features/agents/ui/AgentsView.tsx create mode 100644 ui/goose2/src/features/skills/ui/SkillsView.tsx create mode 100644 ui/goose2/src/shared/constants/storage.ts create mode 100644 ui/goose2/src/shared/ui/MainPanelLayout.tsx create mode 100644 ui/goose2/src/shared/ui/SearchBar.tsx diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 132bfd4ab03c..ff8db87aa6e2 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -4,9 +4,13 @@ import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; import { HomeScreen } from "@/features/home/ui/HomeScreen"; import { ChatView } from "@/features/chat/ui/ChatView"; +import { SkillsView } from "@/features/skills/ui/SkillsView"; +import { AgentsView } from "@/features/agents/ui/AgentsView"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; import type { Tab } from "@/features/tabs/types"; +export type AppView = "home" | "chat" | "skills" | "agents"; + const SIDEBAR_WIDTH = 240; const SIDEBAR_COLLAPSED_WIDTH = 48; @@ -15,24 +19,35 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const [settingsOpen, setSettingsOpen] = useState(false); const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); - const isHome = activeTabId === null; + const [activeView, setActiveView] = useState("home"); + const isHome = activeTabId === null && activeView === "home"; const handleNewTab = () => { const id = String(Date.now()); setTabs((prev) => [...prev, { id, title: "New Chat" }]); setActiveTabId(id); + setActiveView("chat"); }; const handleTabClose = (id: string) => { setTabs((prev) => { const next = prev.filter((t) => t.id !== id); if (activeTabId === id) { - setActiveTabId(next[0]?.id ?? null); + const nextId = next[0]?.id ?? null; + setActiveTabId(nextId); + if (!nextId) setActiveView("home"); } return next; }); }; + const handleNavigate = (view: AppView) => { + setActiveView(view); + if (view !== "chat") { + setActiveTabId(null); + } + }; + const toggleSidebar = () => setSidebarCollapsed((prev) => !prev); useEffect(() => { @@ -52,6 +67,19 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return () => window.removeEventListener("keydown", handler); }, []); + const renderContent = () => { + switch (activeView) { + case "skills": + return ; + case "agents": + return ; + case "chat": + return ; + case "home": + return activeTabId ? : ; + } + }; + return (
{/* Tab bar — full width across the top */} @@ -61,7 +89,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onTabSelect={setActiveTabId} onTabClose={handleTabClose} onNewTab={handleNewTab} - onHomeClick={() => setActiveTabId(null)} + onHomeClick={() => handleNavigate("home")} /> {/* Main content area — sidebar + content as flex row */} @@ -81,13 +109,16 @@ export function AppShell({ children }: { children?: React.ReactNode }) { width={SIDEBAR_WIDTH} onCollapse={toggleSidebar} onSettingsClick={() => setSettingsOpen(true)} + onNavigate={handleNavigate} + onNewChat={handleNewTab} + activeView={activeView} className="h-full shadow-xl rounded-xl" />
{/* Content area */}
- {children ?? (isHome ? : )} + {children ?? renderContent()}
diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx new file mode 100644 index 000000000000..e70dd0474678 --- /dev/null +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { Bot, Plus } from "lucide-react"; +import { SearchBar } from "@/shared/ui/SearchBar"; + +export function AgentsView() { + const [search, setSearch] = useState(""); + + return ( +
+
+ {/* Header */} +
+
+

Agents

+

+ Custom agent configurations for specific workflows +

+
+
+ +
+
+ + {/* Search */} + + + {/* Empty state */} +
+ +
+

No agents yet

+

+ Create an agent or add a YAML file to your agents folder. +

+
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 30f34c1a3bae..805451c8aa0b 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { GooseIcon } from "@/shared/ui/icons/GooseIcon"; +import type { AppView } from "@/app/AppShell"; interface SidebarProps { collapsed: boolean; @@ -18,13 +19,15 @@ interface SidebarProps { onSettingsClick?: () => void; onSearchClick?: () => void; onNewChat?: () => void; + onNavigate?: (view: AppView) => void; + activeView?: AppView; className?: string; } -const NAV_ITEMS = [ +const NAV_ITEMS: readonly { id: AppView; label: string; icon: typeof Bot }[] = [ { id: "agents", label: "Agents", icon: Bot }, { id: "skills", label: "Skills", icon: BookOpen }, -] as const; +]; const RECENT_CHATS = [ { id: "1", name: "Debug login flow", time: "2m" }, @@ -40,6 +43,8 @@ export function Sidebar({ onSettingsClick, onSearchClick, onNewChat, + onNavigate, + activeView, className, }: SidebarProps) { const [expanded, setExpanded] = useState(!collapsed); @@ -77,6 +82,7 @@ export function Sidebar({ > +
+
+ + {/* Search */} + + + {/* Empty state */} +
+ +
+

No skills yet

+

+ Skills you add will appear here. +

+
+
+
+ + ); +} diff --git a/ui/goose2/src/shared/constants/storage.ts b/ui/goose2/src/shared/constants/storage.ts new file mode 100644 index 000000000000..29860a886e49 --- /dev/null +++ b/ui/goose2/src/shared/constants/storage.ts @@ -0,0 +1,21 @@ +/** + * Storage path constants. + * + * All persistent data lives under ~/.goose/ to stay consistent with the + * goose CLI and goose2.0 desktop app. + */ + +/** Root directory for all Goose data. */ +export const GOOSE_DIR = ".goose"; + +/** Subdirectory for saved recipes / skills. */ +export const RECIPES_DIR = `${GOOSE_DIR}/recipes`; + +/** Subdirectory for agent configurations. */ +export const AGENTS_DIR = `${GOOSE_DIR}/agents`; + +/** Subdirectory for session history. */ +export const SESSIONS_DIR = `${GOOSE_DIR}/sessions`; + +/** Subdirectory for extension state. */ +export const EXTENSIONS_DIR = `${GOOSE_DIR}/extensions`; diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css index ae3757afe2b3..11f295b64dbe 100644 --- a/ui/goose2/src/shared/styles/globals.css +++ b/ui/goose2/src/shared/styles/globals.css @@ -187,6 +187,38 @@ padding-right: calc(0.5rem * var(--density-spacing)); } +/* ═══════════════════════════════════════════════════════════ + Page-transition animation (matches goose2.0) + ═══════════════════════════════════════════════════════════ */ +@keyframes page-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.page-transition { + opacity: 0; + animation: page-fade-in 0.08s ease-out forwards; + pointer-events: auto; +} + +/* Content fade-in (used for list areas after loading) */ +@keyframes content-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.content-fade-in { + animation: content-fade-in 0.3s ease-out forwards; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -196,4 +228,11 @@ /* biome-ignore lint/complexity/noImportantStyles: required to override all transitions for reduced-motion accessibility */ transition-duration: 0.01ms !important; } + + .page-transition { + /* biome-ignore lint/complexity/noImportantStyles: required to override animation for reduced-motion accessibility */ + animation: none !important; + /* biome-ignore lint/complexity/noImportantStyles: required to override opacity for reduced-motion accessibility */ + opacity: 1 !important; + } } diff --git a/ui/goose2/src/shared/ui/MainPanelLayout.tsx b/ui/goose2/src/shared/ui/MainPanelLayout.tsx new file mode 100644 index 000000000000..d530da52bec5 --- /dev/null +++ b/ui/goose2/src/shared/ui/MainPanelLayout.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +/** + * Layout wrapper for full-page views (Skills, Agents, etc.). + * + * Fills the parent container (typically `
` inside AppShell) + * and provides a flex-column context for header + scrollable content. + */ +export function MainPanelLayout({ + children, + backgroundColor = "bg-background-primary", +}: { + children: ReactNode; + backgroundColor?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/ui/goose2/src/shared/ui/SearchBar.tsx b/ui/goose2/src/shared/ui/SearchBar.tsx new file mode 100644 index 000000000000..fabb30ae1137 --- /dev/null +++ b/ui/goose2/src/shared/ui/SearchBar.tsx @@ -0,0 +1,35 @@ +import { Search } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; + +interface SearchBarProps { + /** Current search value (controlled) */ + value: string; + /** Called when the search term changes */ + onChange: (term: string) => void; + /** Placeholder text */ + placeholder?: string; + /** Optional className for the wrapper */ + className?: string; +} + +export function SearchBar({ + value, + onChange, + placeholder, + className, +}: SearchBarProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="w-full pl-9 pr-3 py-2 text-sm bg-background-secondary border border-border-secondary/50 rounded-lg placeholder:text-foreground-secondary/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-border-primary transition-colors" + /> +
+ ); +} From e6750f0d956a7a173e065c863659aef8c3ea4748 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:25:23 -0700 Subject: [PATCH 006/149] chore(deps): update all non-major dependencies (#5) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- ui/goose2/.github/workflows/ci.yml | 18 +++++++++--------- ui/goose2/package.json | 2 +- ui/goose2/pnpm-lock.yaml | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ui/goose2/.github/workflows/ci.yml b/ui/goose2/.github/workflows/ci.yml index 77a162fc410f..f855547b21bc 100644 --- a/ui/goose2/.github/workflows/ci.yml +++ b/ui/goose2/.github/workflows/ci.yml @@ -16,8 +16,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 @@ -31,8 +31,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 @@ -45,8 +45,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 @@ -66,7 +66,7 @@ jobs: uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Cache Rust - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cargo/registry @@ -109,7 +109,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install system dependencies run: | @@ -127,7 +127,7 @@ jobs: components: rustfmt, clippy - name: Cache Rust - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cargo/registry diff --git a/ui/goose2/package.json b/ui/goose2/package.json index 573140b12503..9219dd9479ad 100644 --- a/ui/goose2/package.json +++ b/ui/goose2/package.json @@ -41,7 +41,7 @@ "postcss": "^8.5.8", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", - "typescript": "~5.8.3", + "typescript": "~5.9.0", "vite": "^7.0.4", "vitest": "^3.2.1", "@testing-library/react": "^16.3.0", diff --git a/ui/goose2/pnpm-lock.yaml b/ui/goose2/pnpm-lock.yaml index 160cf2fba746..fc267b2d945d 100644 --- a/ui/goose2/pnpm-lock.yaml +++ b/ui/goose2/pnpm-lock.yaml @@ -79,8 +79,8 @@ importers: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19) typescript: - specifier: ~5.8.3 - version: 5.8.3 + specifier: ~5.9.0 + version: 5.9.3 vite: specifier: ^7.0.4 version: 7.3.1(jiti@1.21.7) @@ -1425,8 +1425,8 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -2728,7 +2728,7 @@ snapshots: ts-interface-checker@0.1.13: {} - typescript@5.8.3: {} + typescript@5.9.3: {} update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: From cca7907ef3c24d13744b0a4dc316d14e71ba29c3 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 30 Mar 2026 10:31:32 -0700 Subject: [PATCH 007/149] =?UTF-8?q?feat:=20chat=20experience=20preparation?= =?UTF-8?q?=20=E2=80=94=20UI=20components,=20stores,=20and=20goose2.0=20vi?= =?UTF-8?q?sual=20match=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: chat experience preparation — UI components, stores, and goose2.0-style chat input Add chat and agent management UI infrastructure without ACP protocol implementation: - Rich chat UI: ChatInput (matching goose2.0 design with mode/model/folder pickers), MessageBubble, MessageTimeline, StreamingIndicator, ThinkingBlock, ToolCallCard - Agent/Persona management: PersonaCard, PersonaEditor, PersonaGallery, AgentConfig - Zustand stores for chat state, agent state, session management - Rust backend: persona CRUD, session storage, sidecar management commands - Shared UI primitives: DropdownMenu, Tooltip, Popover (Radix UI) - Type system for messages (text, images, tool calls, thinking, reasoning) - Fix Agents page spacing to match Skills page layout Co-Authored-By: Claude Opus 4.6 (1M context) * style: match goose2.0 visual design — theme colors, message styling, and UI polish - Update theme colors to pure monochrome (remove blue-gray tint) matching goose2.0 - Restyle MessageBubble: remove bubble backgrounds, right-align user messages, smaller avatars, dimmed agent text, hover-reveal actions - Restyle ToolCallCard as compact inline pills with status colors and elapsed time - Restyle ThinkingBlock with brain icon circle and left-border content indent - Fix tab close button right padding (px-3 → pl-3 pr-1.5) - Replace hardcoded "WB" initials with generic User icon in sidebar Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve Rust fmt and clippy dead_code warnings for CI Allow dead_code on preparation types/methods that will be wired up when ACP integration lands. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use getByPlaceholderText in E2E test for chat input The placeholder text is an HTML attribute, not visible text content, so getByText doesn't find it. Switch to getByPlaceholderText. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use CSS selector for placeholder text in E2E smoke test getByPlaceholderText is not available in this Playwright version. Use locator with attribute selector instead. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/package.json | 18 +- ui/goose2/pnpm-lock.yaml | 765 ++++++++++++++++++ ui/goose2/src-tauri/Cargo.lock | 545 ++++++++++++- ui/goose2/src-tauri/Cargo.toml | 9 + ui/goose2/src-tauri/src/commands/agents.rs | 30 + ui/goose2/src-tauri/src/commands/chat.rs | 28 + ui/goose2/src-tauri/src/commands/mod.rs | 4 + ui/goose2/src-tauri/src/commands/sessions.rs | 24 + ui/goose2/src-tauri/src/commands/sidecar.rs | 296 +++++++ ui/goose2/src-tauri/src/lib.rs | 26 + ui/goose2/src-tauri/src/services/mod.rs | 2 + ui/goose2/src-tauri/src/services/personas.rs | 141 ++++ ui/goose2/src-tauri/src/services/sessions.rs | 169 ++++ ui/goose2/src-tauri/src/types/agents.rs | 132 +++ ui/goose2/src-tauri/src/types/messages.rs | 115 +++ ui/goose2/src-tauri/src/types/mod.rs | 2 + ui/goose2/src/app/AppShell.tsx | 105 ++- .../src/features/agents/hooks/useAgents.ts | 179 ++++ .../src/features/agents/hooks/usePersonas.ts | 61 ++ .../stores/__tests__/agentStore.test.ts | 200 +++++ .../src/features/agents/stores/agentStore.ts | 151 ++++ .../src/features/agents/ui/AgentConfig.tsx | 190 +++++ .../src/features/agents/ui/AgentsView.tsx | 200 ++++- .../src/features/agents/ui/PersonaCard.tsx | 179 ++++ .../src/features/agents/ui/PersonaEditor.tsx | 308 +++++++ .../src/features/agents/ui/PersonaGallery.tsx | 97 +++ .../agents/ui/__tests__/PersonaCard.test.tsx | 94 +++ ui/goose2/src/features/chat/hooks/useChat.ts | 193 +++++ ui/goose2/src/features/chat/hooks/useSSE.ts | 74 ++ .../chat/stores/__tests__/chatStore.test.ts | 191 +++++ .../src/features/chat/stores/chatStore.ts | 254 ++++++ ui/goose2/src/features/chat/ui/ChatInput.tsx | 441 ++++++++++ ui/goose2/src/features/chat/ui/ChatView.tsx | 81 +- .../src/features/chat/ui/ContextRing.tsx | 61 ++ .../src/features/chat/ui/MessageBubble.tsx | 249 ++++++ .../src/features/chat/ui/MessageTimeline.tsx | 148 ++++ .../features/chat/ui/StreamingIndicator.tsx | 54 ++ .../src/features/chat/ui/ThinkingBlock.tsx | 64 ++ .../src/features/chat/ui/ToolCallCard.tsx | 137 ++++ .../chat/ui/__tests__/ChatInput.test.tsx | 88 ++ .../chat/ui/__tests__/MessageBubble.test.tsx | 102 +++ .../chat/ui/__tests__/ToolCallCard.test.tsx | 136 ++++ .../src/features/home/ui/HomeScreen.test.tsx | 4 +- ui/goose2/src/features/home/ui/HomeScreen.tsx | 103 ++- ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 3 +- ui/goose2/src/features/tabs/types.ts | 2 + ui/goose2/src/features/tabs/ui/TabBar.tsx | 2 +- ui/goose2/src/shared/api/agents.ts | 27 + ui/goose2/src/shared/api/chat.ts | 28 + ui/goose2/src/shared/api/fetch.ts | 44 + ui/goose2/src/shared/api/index.ts | 3 + ui/goose2/src/shared/hooks/useSidecar.ts | 84 ++ ui/goose2/src/shared/styles/globals.css | 28 +- .../shared/types/__tests__/messages.test.ts | 166 ++++ ui/goose2/src/shared/types/agents.ts | 124 +++ ui/goose2/src/shared/types/chat.ts | 98 +++ ui/goose2/src/shared/types/index.ts | 2 + ui/goose2/src/shared/types/messages.ts | 154 ++++ ui/goose2/src/shared/ui/dropdown-menu.tsx | 96 +++ ui/goose2/src/shared/ui/popover.tsx | 33 + ui/goose2/src/shared/ui/tooltip.tsx | 31 + ui/goose2/src/stores/agentConfigStore.ts | 38 + ui/goose2/src/stores/chatStore.ts | 66 ++ ui/goose2/src/stores/index.ts | 4 + ui/goose2/src/stores/sessionStore.ts | 66 ++ ui/goose2/src/stores/skillStore.ts | 38 + ui/goose2/src/types/chat.ts | 137 ++++ ui/goose2/src/types/index.ts | 60 ++ ui/goose2/tests/e2e/smoke.spec.ts | 4 +- 69 files changed, 7668 insertions(+), 120 deletions(-) create mode 100644 ui/goose2/src-tauri/src/commands/agents.rs create mode 100644 ui/goose2/src-tauri/src/commands/chat.rs create mode 100644 ui/goose2/src-tauri/src/commands/mod.rs create mode 100644 ui/goose2/src-tauri/src/commands/sessions.rs create mode 100644 ui/goose2/src-tauri/src/commands/sidecar.rs create mode 100644 ui/goose2/src-tauri/src/services/mod.rs create mode 100644 ui/goose2/src-tauri/src/services/personas.rs create mode 100644 ui/goose2/src-tauri/src/services/sessions.rs create mode 100644 ui/goose2/src-tauri/src/types/agents.rs create mode 100644 ui/goose2/src-tauri/src/types/messages.rs create mode 100644 ui/goose2/src-tauri/src/types/mod.rs create mode 100644 ui/goose2/src/features/agents/hooks/useAgents.ts create mode 100644 ui/goose2/src/features/agents/hooks/usePersonas.ts create mode 100644 ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts create mode 100644 ui/goose2/src/features/agents/stores/agentStore.ts create mode 100644 ui/goose2/src/features/agents/ui/AgentConfig.tsx create mode 100644 ui/goose2/src/features/agents/ui/PersonaCard.tsx create mode 100644 ui/goose2/src/features/agents/ui/PersonaEditor.tsx create mode 100644 ui/goose2/src/features/agents/ui/PersonaGallery.tsx create mode 100644 ui/goose2/src/features/agents/ui/__tests__/PersonaCard.test.tsx create mode 100644 ui/goose2/src/features/chat/hooks/useChat.ts create mode 100644 ui/goose2/src/features/chat/hooks/useSSE.ts create mode 100644 ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts create mode 100644 ui/goose2/src/features/chat/stores/chatStore.ts create mode 100644 ui/goose2/src/features/chat/ui/ChatInput.tsx create mode 100644 ui/goose2/src/features/chat/ui/ContextRing.tsx create mode 100644 ui/goose2/src/features/chat/ui/MessageBubble.tsx create mode 100644 ui/goose2/src/features/chat/ui/MessageTimeline.tsx create mode 100644 ui/goose2/src/features/chat/ui/StreamingIndicator.tsx create mode 100644 ui/goose2/src/features/chat/ui/ThinkingBlock.tsx create mode 100644 ui/goose2/src/features/chat/ui/ToolCallCard.tsx create mode 100644 ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx create mode 100644 ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx create mode 100644 ui/goose2/src/features/chat/ui/__tests__/ToolCallCard.test.tsx create mode 100644 ui/goose2/src/shared/api/agents.ts create mode 100644 ui/goose2/src/shared/api/chat.ts create mode 100644 ui/goose2/src/shared/api/fetch.ts create mode 100644 ui/goose2/src/shared/api/index.ts create mode 100644 ui/goose2/src/shared/hooks/useSidecar.ts create mode 100644 ui/goose2/src/shared/types/__tests__/messages.test.ts create mode 100644 ui/goose2/src/shared/types/agents.ts create mode 100644 ui/goose2/src/shared/types/chat.ts create mode 100644 ui/goose2/src/shared/types/index.ts create mode 100644 ui/goose2/src/shared/types/messages.ts create mode 100644 ui/goose2/src/shared/ui/dropdown-menu.tsx create mode 100644 ui/goose2/src/shared/ui/popover.tsx create mode 100644 ui/goose2/src/shared/ui/tooltip.tsx create mode 100644 ui/goose2/src/stores/agentConfigStore.ts create mode 100644 ui/goose2/src/stores/chatStore.ts create mode 100644 ui/goose2/src/stores/index.ts create mode 100644 ui/goose2/src/stores/sessionStore.ts create mode 100644 ui/goose2/src/stores/skillStore.ts create mode 100644 ui/goose2/src/types/chat.ts create mode 100644 ui/goose2/src/types/index.ts diff --git a/ui/goose2/package.json b/ui/goose2/package.json index 9219dd9479ad..d4dee43db4c0 100644 --- a/ui/goose2/package.json +++ b/ui/goose2/package.json @@ -21,7 +21,10 @@ "test:e2e:smoke": "pnpm build && playwright test --project=smoke" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.21", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", @@ -29,25 +32,26 @@ "lucide-react": "^0.577.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12" }, "devDependencies": { "@biomejs/biome": "^2.4.6", + "@playwright/test": "^1.52.0", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", "autoprefixer": "^10.4.27", + "jsdom": "^26.1.0", "postcss": "^8.5.8", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "typescript": "~5.9.0", "vite": "^7.0.4", - "vitest": "^3.2.1", - "@testing-library/react": "^16.3.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/user-event": "^14.6.1", - "jsdom": "^26.1.0", - "@playwright/test": "^1.52.0" + "vitest": "^3.2.1" } } diff --git a/ui/goose2/pnpm-lock.yaml b/ui/goose2/pnpm-lock.yaml index fc267b2d945d..4b361385fe7c 100644 --- a/ui/goose2/pnpm-lock.yaml +++ b/ui/goose2/pnpm-lock.yaml @@ -8,9 +8,18 @@ importers: .: dependencies: + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.90.21 version: 5.95.2(react@19.2.4) @@ -35,6 +44,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4) devDependencies: '@biomejs/biome': specifier: ^2.4.6 @@ -428,6 +440,21 @@ packages: cpu: [x64] os: [win32] + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -461,6 +488,35 @@ packages: engines: {node: '>=18'} hasBin: true + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -470,6 +526,181 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-slot@1.2.4': resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} peerDependencies: @@ -479,6 +710,98 @@ packages: '@types/react': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -825,6 +1148,10 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -937,6 +1264,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1015,6 +1345,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1280,6 +1614,36 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -1425,6 +1789,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1436,6 +1803,26 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1560,6 +1947,24 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.4': {} @@ -1821,6 +2226,23 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1856,12 +2278,219 @@ snapshots: dependencies: playwright: 1.58.2 + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -1869,6 +2498,85 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.0': @@ -2145,6 +2853,10 @@ snapshots: arg@5.0.2: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -2242,6 +2954,8 @@ snapshots: dequal@2.0.3: {} + detect-node-es@1.1.0: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -2325,6 +3039,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2551,6 +3267,33 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react@19.2.4: {} read-cache@1.0.0: @@ -2728,6 +3471,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + typescript@5.9.3: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -2736,6 +3481,21 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + util-deprecate@1.0.2: {} vite-node@3.2.4(jiti@1.21.7): @@ -2841,3 +3601,8 @@ snapshots: xmlchars@2.2.0: {} yallist@3.1.1: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 diff --git a/ui/goose2/src-tauri/Cargo.lock b/ui/goose2/src-tauri/Cargo.lock index 7e3272329deb..9954002914fd 100644 --- a/ui/goose2/src-tauri/Cargo.lock +++ b/ui/goose2/src-tauri/Cargo.lock @@ -213,6 +213,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.21.7" @@ -408,6 +430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -444,6 +468,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -451,11 +492,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -491,6 +543,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -514,7 +576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -527,7 +589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -540,6 +602,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -850,6 +921,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1020,6 +1100,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1030,6 +1116,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1037,6 +1138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1104,6 +1206,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1250,8 +1353,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1261,9 +1366,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1275,6 +1382,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -1379,12 +1487,21 @@ dependencies = [ name = "goose2" version = "0.1.0" dependencies = [ + "chrono", + "dirs", + "futures", + "log", + "rand 0.10.0", + "reqwest", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", "tauri-plugin-window-state", + "tokio", + "tokio-stream", + "uuid", ] [[package]] @@ -1439,6 +1556,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1555,6 +1691,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1566,6 +1703,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1584,9 +1737,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1877,6 +2032,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.92" @@ -2016,6 +2181,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2347,6 +2518,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2802,6 +2979,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2848,6 +3081,27 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2868,6 +3122,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2886,6 +3150,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_hc" version = "0.2.0" @@ -2987,21 +3266,30 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3013,6 +3301,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3041,6 +3343,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3056,6 +3433,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3113,6 +3499,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3334,7 +3743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3503,6 +3912,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3556,6 +3971,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3577,7 +4013,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -4007,6 +4443,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -4016,11 +4467,46 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4325,6 +4811,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4567,6 +5059,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.3" @@ -4623,6 +5125,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4808,6 +5319,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4853,6 +5375,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5405,6 +5936,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/ui/goose2/src-tauri/Cargo.toml b/ui/goose2/src-tauri/Cargo.toml index 3521c464fea6..990b520f42bc 100644 --- a/ui/goose2/src-tauri/Cargo.toml +++ b/ui/goose2/src-tauri/Cargo.toml @@ -18,3 +18,12 @@ tauri-plugin-opener = "2" tauri-plugin-window-state = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +reqwest = { version = "0.13.2", features = ["default-tls", "json", "stream"] } +dirs = "6.0.0" +rand = "0.10.0" +log = "0.4.29" +tokio = { version = "1.50.0", features = ["full"] } +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +tokio-stream = "0.1" diff --git a/ui/goose2/src-tauri/src/commands/agents.rs b/ui/goose2/src-tauri/src/commands/agents.rs new file mode 100644 index 000000000000..9783c56474fc --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/agents.rs @@ -0,0 +1,30 @@ +use crate::services::personas::PersonaStore; +use crate::types::agents::*; +use tauri::State; + +#[tauri::command] +pub fn list_personas(store: State<'_, PersonaStore>) -> Vec { + store.list() +} + +#[tauri::command] +pub fn create_persona( + store: State<'_, PersonaStore>, + request: CreatePersonaRequest, +) -> Result { + store.create(request) +} + +#[tauri::command] +pub fn update_persona( + store: State<'_, PersonaStore>, + id: String, + request: UpdatePersonaRequest, +) -> Result { + store.update(&id, request) +} + +#[tauri::command] +pub fn delete_persona(store: State<'_, PersonaStore>, id: String) -> Result<(), String> { + store.delete(&id) +} diff --git a/ui/goose2/src-tauri/src/commands/chat.rs b/ui/goose2/src-tauri/src/commands/chat.rs new file mode 100644 index 000000000000..06d226d004a0 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/chat.rs @@ -0,0 +1,28 @@ +use crate::services::sessions::SessionStore; +use crate::types::messages::*; +use tauri::State; + +#[tauri::command] +pub async fn chat_send_message( + session_store: State<'_, SessionStore>, + session_id: String, + message: Message, +) -> Result { + // 1. Store the user message + session_store.add_message(&session_id, message.clone())?; + + // 2. TODO: Connect to goosed via HTTP/SSE for real inference + // For now, return a mock assistant response + let response = Message { + id: uuid::Uuid::new_v4().to_string(), + role: MessageRole::Assistant, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::Text { + text: "I'm Goose, your AI coding assistant. The chat backend is being set up — real inference coming soon! For now, I can confirm the message pipeline is working end-to-end.".to_string(), + }], + metadata: None, + }; + + session_store.add_message(&session_id, response.clone())?; + Ok(response) +} diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs new file mode 100644 index 000000000000..fef59d11e871 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod agents; +pub mod chat; +pub mod sessions; +pub mod sidecar; diff --git a/ui/goose2/src-tauri/src/commands/sessions.rs b/ui/goose2/src-tauri/src/commands/sessions.rs new file mode 100644 index 000000000000..2c64358765eb --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/sessions.rs @@ -0,0 +1,24 @@ +use crate::services::sessions::SessionStore; +use crate::types::agents::Session; +use crate::types::messages::Message; +use tauri::State; + +#[tauri::command] +pub fn create_session(store: State<'_, SessionStore>, agent_id: Option) -> Session { + store.create_session(agent_id) +} + +#[tauri::command] +pub fn list_sessions(store: State<'_, SessionStore>) -> Vec { + store.list_sessions() +} + +#[tauri::command] +pub fn get_session_messages(store: State<'_, SessionStore>, session_id: String) -> Vec { + store.get_messages(&session_id) +} + +#[tauri::command] +pub fn delete_session(store: State<'_, SessionStore>, session_id: String) -> Result<(), String> { + store.delete_session(&session_id) +} diff --git a/ui/goose2/src-tauri/src/commands/sidecar.rs b/ui/goose2/src-tauri/src/commands/sidecar.rs new file mode 100644 index 000000000000..c32e82c751fa --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/sidecar.rs @@ -0,0 +1,296 @@ +use std::env; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; + +use rand::RngExt; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; + +/// Information about the running sidecar process. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidecarInfo { + pub url: String, + pub port: u16, + pub pid: Option, + pub secret_key: String, + pub healthy: bool, +} + +/// Shared state for the sidecar process. +pub struct SidecarState { + pub process: Mutex>, + pub url: Mutex>, + pub port: Mutex>, + pub secret_key: Mutex>, +} + +impl Default for SidecarState { + fn default() -> Self { + Self { + process: Mutex::new(None), + url: Mutex::new(None), + port: Mutex::new(None), + secret_key: Mutex::new(None), + } + } +} + +/// Find an available TCP port by binding to port 0. +fn find_available_port() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Failed to bind port: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("Failed to get local addr: {e}"))? + .port(); + Ok(port) +} + +/// Generate a random 32-character hex secret key. +fn generate_secret_key() -> String { + let mut rng = rand::rng(); + let bytes: Vec = (0..16).map(|_| rng.random::()).collect(); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Resolve the path to the goosed binary. +fn find_goosed_binary(app: &AppHandle) -> Result { + // Check for an explicit override via environment variable. + if let Ok(path) = env::var("GOOSED_PATH") { + let p = std::path::PathBuf::from(&path); + if p.exists() { + return Ok(p); + } + return Err(format!("GOOSED_PATH set but not found: {path}")); + } + + // In debug mode, look relative to the workspace root. + #[cfg(debug_assertions)] + { + let resource_dir = app + .path() + .resource_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + + let candidates = [ + resource_dir.join("../../target/debug/goosed"), + resource_dir.join("../../target/release/goosed"), + std::path::PathBuf::from("../../target/debug/goosed"), + std::path::PathBuf::from("../../target/release/goosed"), + ]; + + for candidate in &candidates { + if candidate.exists() { + return Ok(candidate.clone()); + } + } + } + + // In release mode, look next to the app bundle. + #[cfg(not(debug_assertions))] + { + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("Failed to get resource dir: {e}"))?; + + let candidate = resource_dir.join("goosed"); + if candidate.exists() { + return Ok(candidate); + } + } + + // Fallback: check PATH. + if let Ok(output) = Command::new("which").arg("goosed").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(std::path::PathBuf::from(path)); + } + } + } + + Err("Could not find goosed binary".to_string()) +} + +/// Build a reqwest client that accepts self-signed certificates. +fn build_http_client() -> Result { + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}")) +} + +/// Poll the sidecar health endpoint until it responds or we time out. +async fn wait_for_healthy(url: &str, secret_key: &str) -> Result { + let client = build_http_client()?; + let status_url = format!("{url}/status"); + + for _ in 0..80 { + let result = client + .get(&status_url) + .header("X-Secret-Key", secret_key) + .send() + .await; + + if let Ok(resp) = result { + if resp.status().is_success() { + return Ok(true); + } + } + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + Err("Sidecar did not become healthy within 8 seconds".to_string()) +} + +/// Start the goosed sidecar process. +/// +/// If `GOOSE_EXTERNAL_BACKEND` is set, connects to an already-running goosed +/// instance instead of spawning a new process. +#[tauri::command] +pub async fn start_sidecar( + app: AppHandle, + state: State<'_, SidecarState>, + working_dir: Option, + port: Option, +) -> Result { + // If an external backend is configured, use it directly. + if let Ok(external_url) = env::var("GOOSE_EXTERNAL_BACKEND") { + let secret_key = + env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| generate_secret_key()); + + *state.url.lock().map_err(|e| e.to_string())? = Some(external_url.clone()); + *state.secret_key.lock().map_err(|e| e.to_string())? = Some(secret_key.clone()); + + // Try to parse port from the external URL. + let parsed_port = external_url + .rsplit(':') + .next() + .and_then(|p| p.trim_end_matches('/').parse::().ok()) + .unwrap_or(0); + + *state.port.lock().map_err(|e| e.to_string())? = Some(parsed_port); + + return Ok(SidecarInfo { + url: external_url, + port: parsed_port, + pid: None, + secret_key, + healthy: true, + }); + } + + let port = match port { + Some(p) => p, + None => find_available_port()?, + }; + + let secret_key = generate_secret_key(); + let url = format!("https://127.0.0.1:{port}"); + + let goosed_path = find_goosed_binary(&app)?; + + let home_dir = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/tmp".to_string()); + + let mut cmd = Command::new(&goosed_path); + cmd.env("GOOSE_PORT", port.to_string()) + .env("GOOSE_SERVER__SECRET_KEY", &secret_key) + .env("HOME", &home_dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + if let Some(ref dir) = working_dir { + cmd.current_dir(dir); + } + + let child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn goosed at {}: {e}", goosed_path.display()))?; + + let pid = child.id(); + + *state.process.lock().map_err(|e| e.to_string())? = Some(child); + *state.url.lock().map_err(|e| e.to_string())? = Some(url.clone()); + *state.port.lock().map_err(|e| e.to_string())? = Some(port); + *state.secret_key.lock().map_err(|e| e.to_string())? = Some(secret_key.clone()); + + // Wait for the sidecar to become healthy. + let healthy = wait_for_healthy(&url, &secret_key).await.unwrap_or(false); + + Ok(SidecarInfo { + url, + port, + pid: Some(pid), + secret_key, + healthy, + }) +} + +/// Stop the running sidecar process. +#[tauri::command] +pub async fn stop_sidecar(state: State<'_, SidecarState>) -> Result<(), String> { + let mut process_guard = state.process.lock().map_err(|e| e.to_string())?; + if let Some(ref mut child) = *process_guard { + child + .kill() + .map_err(|e| format!("Failed to kill sidecar: {e}"))?; + child + .wait() + .map_err(|e| format!("Failed to wait on sidecar: {e}"))?; + } + *process_guard = None; + *state.url.lock().map_err(|e| e.to_string())? = None; + *state.port.lock().map_err(|e| e.to_string())? = None; + *state.secret_key.lock().map_err(|e| e.to_string())? = None; + Ok(()) +} + +/// Get the URL of the running sidecar. +#[tauri::command] +pub async fn get_sidecar_url(state: State<'_, SidecarState>) -> Result, String> { + let url = state.url.lock().map_err(|e| e.to_string())?; + Ok(url.clone()) +} + +/// Get the secret key for authenticating with the sidecar. +#[tauri::command] +pub async fn get_sidecar_secret(state: State<'_, SidecarState>) -> Result, String> { + let secret = state.secret_key.lock().map_err(|e| e.to_string())?; + Ok(secret.clone()) +} + +/// Check if the sidecar is healthy by hitting its status endpoint. +#[tauri::command] +pub async fn sidecar_health(state: State<'_, SidecarState>) -> Result { + let url = { + let guard = state.url.lock().map_err(|e| e.to_string())?; + match guard.clone() { + Some(u) => u, + None => return Ok(false), + } + }; + + let secret_key = { + let guard = state.secret_key.lock().map_err(|e| e.to_string())?; + guard.clone().unwrap_or_default() + }; + + let client = build_http_client()?; + let status_url = format!("{url}/status"); + + let result = client + .get(&status_url) + .header("X-Secret-Key", &secret_key) + .send() + .await; + + match result { + Ok(resp) => Ok(resp.status().is_success()), + Err(_) => Ok(false), + } +} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index f55a6a59a57e..d31447b260cc 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -1,3 +1,10 @@ +mod commands; +mod services; +mod types; + +use commands::sidecar::SidecarState; +use services::personas::PersonaStore; +use services::sessions::SessionStore; use tauri_plugin_window_state::StateFlags; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -9,6 +16,25 @@ pub fn run() { .with_state_flags(StateFlags::all() & !StateFlags::VISIBLE) .build(), ) + .manage(SidecarState::default()) + .manage(PersonaStore::new()) + .manage(SessionStore::new()) + .invoke_handler(tauri::generate_handler![ + commands::sidecar::start_sidecar, + commands::sidecar::stop_sidecar, + commands::sidecar::get_sidecar_url, + commands::sidecar::get_sidecar_secret, + commands::sidecar::sidecar_health, + commands::agents::list_personas, + commands::agents::create_persona, + commands::agents::update_persona, + commands::agents::delete_persona, + commands::sessions::create_session, + commands::sessions::list_sessions, + commands::sessions::get_session_messages, + commands::sessions::delete_session, + commands::chat::chat_send_message, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/ui/goose2/src-tauri/src/services/mod.rs b/ui/goose2/src-tauri/src/services/mod.rs new file mode 100644 index 000000000000..1fe87b57c753 --- /dev/null +++ b/ui/goose2/src-tauri/src/services/mod.rs @@ -0,0 +1,2 @@ +pub mod personas; +pub mod sessions; diff --git a/ui/goose2/src-tauri/src/services/personas.rs b/ui/goose2/src-tauri/src/services/personas.rs new file mode 100644 index 000000000000..191490d4d6d5 --- /dev/null +++ b/ui/goose2/src-tauri/src/services/personas.rs @@ -0,0 +1,141 @@ +use crate::types::agents::{builtin_personas, CreatePersonaRequest, Persona, UpdatePersonaRequest}; +use std::path::PathBuf; +use std::sync::Mutex; + +pub struct PersonaStore { + personas: Mutex>, + store_path: PathBuf, +} + +impl PersonaStore { + pub fn new() -> Self { + let store_path = Self::store_path(); + let stored = Self::load_from_disk(&store_path); + let merged = Self::merge_with_builtins(stored); + Self { + personas: Mutex::new(merged), + store_path, + } + } + + fn store_path() -> PathBuf { + let base = dirs::home_dir().expect("home dir"); + base.join(".goose").join("personas.json") + } + + fn load_from_disk(path: &PathBuf) -> Vec { + match std::fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => Vec::new(), + } + } + + fn merge_with_builtins(stored: Vec) -> Vec { + let builtins = builtin_personas(); + let builtin_ids: std::collections::HashSet = + builtins.iter().map(|p| p.id.clone()).collect(); + + let mut result = builtins; + + // Add custom (non-builtin) personas from disk + for persona in stored { + if !builtin_ids.contains(&persona.id) { + result.push(persona); + } + } + + result + } + + fn save_to_disk(&self, personas: &[Persona]) { + if let Some(parent) = self.store_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Only persist custom personas (not builtins) + let custom: Vec<&Persona> = personas.iter().filter(|p| !p.is_builtin).collect(); + if let Ok(json) = serde_json::to_string_pretty(&custom) { + let _ = std::fs::write(&self.store_path, json); + } + } + + pub fn list(&self) -> Vec { + let personas = self.personas.lock().unwrap(); + personas.clone() + } + + #[allow(dead_code)] + pub fn get(&self, id: &str) -> Option { + let personas = self.personas.lock().unwrap(); + personas.iter().find(|p| p.id == id).cloned() + } + + pub fn create(&self, req: CreatePersonaRequest) -> Result { + let now = chrono::Utc::now().to_rfc3339(); + let persona = Persona { + id: uuid::Uuid::new_v4().to_string(), + display_name: req.display_name, + avatar_url: req.avatar_url, + system_prompt: req.system_prompt, + provider: req.provider, + model: req.model, + is_builtin: false, + created_at: now.clone(), + updated_at: now, + }; + + let mut personas = self.personas.lock().unwrap(); + personas.push(persona.clone()); + self.save_to_disk(&personas); + Ok(persona) + } + + pub fn update(&self, id: &str, req: UpdatePersonaRequest) -> Result { + let mut personas = self.personas.lock().unwrap(); + let persona = personas + .iter_mut() + .find(|p| p.id == id) + .ok_or_else(|| format!("Persona '{}' not found", id))?; + + if persona.is_builtin { + return Err("Cannot update a built-in persona".to_string()); + } + + if let Some(name) = req.display_name { + persona.display_name = name; + } + if let Some(avatar) = req.avatar_url { + persona.avatar_url = Some(avatar); + } + if let Some(prompt) = req.system_prompt { + persona.system_prompt = prompt; + } + if let Some(provider) = req.provider { + persona.provider = Some(provider); + } + if let Some(model) = req.model { + persona.model = Some(model); + } + persona.updated_at = chrono::Utc::now().to_rfc3339(); + + let updated = persona.clone(); + self.save_to_disk(&personas); + Ok(updated) + } + + pub fn delete(&self, id: &str) -> Result<(), String> { + let mut personas = self.personas.lock().unwrap(); + + let persona = personas + .iter() + .find(|p| p.id == id) + .ok_or_else(|| format!("Persona '{}' not found", id))?; + + if persona.is_builtin { + return Err("Cannot delete a built-in persona".to_string()); + } + + personas.retain(|p| p.id != id); + self.save_to_disk(&personas); + Ok(()) + } +} diff --git a/ui/goose2/src-tauri/src/services/sessions.rs b/ui/goose2/src-tauri/src/services/sessions.rs new file mode 100644 index 000000000000..60cf85b760da --- /dev/null +++ b/ui/goose2/src-tauri/src/services/sessions.rs @@ -0,0 +1,169 @@ +use crate::types::agents::Session; +use crate::types::messages::Message; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; + +struct SessionData { + session: Session, + messages: Vec, +} + +pub struct SessionStore { + sessions: Mutex>, + sessions_dir: PathBuf, +} + +impl SessionStore { + pub fn new() -> Self { + let sessions_dir = dirs::home_dir() + .expect("home dir") + .join(".goose") + .join("sessions"); + let _ = std::fs::create_dir_all(&sessions_dir); + + let mut sessions = HashMap::new(); + + // Load existing sessions from disk + if let Ok(entries) = std::fs::read_dir(&sessions_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(id) = path.file_name().and_then(|n| n.to_str()) { + let session_file = path.join("session.json"); + let messages_file = path.join("messages.json"); + + if let Ok(session_json) = std::fs::read_to_string(&session_file) { + if let Ok(session) = serde_json::from_str::(&session_json) { + let messages: Vec = + std::fs::read_to_string(&messages_file) + .ok() + .and_then(|m| serde_json::from_str(&m).ok()) + .unwrap_or_default(); + + sessions.insert(id.to_string(), SessionData { session, messages }); + } + } + } + } + } + } + + Self { + sessions: Mutex::new(sessions), + sessions_dir, + } + } + + fn save_session(&self, id: &str, data: &SessionData) { + let dir = self.sessions_dir.join(id); + let _ = std::fs::create_dir_all(&dir); + + if let Ok(json) = serde_json::to_string_pretty(&data.session) { + let _ = std::fs::write(dir.join("session.json"), json); + } + if let Ok(json) = serde_json::to_string_pretty(&data.messages) { + let _ = std::fs::write(dir.join("messages.json"), json); + } + } + + pub fn create_session(&self, agent_id: Option) -> Session { + let now = chrono::Utc::now().to_rfc3339(); + let id = uuid::Uuid::new_v4().to_string(); + let session = Session { + id: id.clone(), + title: "New Chat".to_string(), + agent_id, + created_at: now.clone(), + updated_at: now, + message_count: 0, + last_message_preview: None, + }; + + let data = SessionData { + session: session.clone(), + messages: Vec::new(), + }; + + self.save_session(&id, &data); + + let mut sessions = self.sessions.lock().unwrap(); + sessions.insert(id, data); + session + } + + #[allow(dead_code)] + pub fn get_session(&self, id: &str) -> Option { + let sessions = self.sessions.lock().unwrap(); + sessions.get(id).map(|d| d.session.clone()) + } + + pub fn list_sessions(&self) -> Vec { + let sessions = self.sessions.lock().unwrap(); + let mut list: Vec = sessions.values().map(|d| d.session.clone()).collect(); + // Sort by updated_at descending (most recent first) + list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + list + } + + pub fn add_message(&self, session_id: &str, message: Message) -> Result<(), String> { + let mut sessions = self.sessions.lock().unwrap(); + let data = sessions + .get_mut(session_id) + .ok_or_else(|| format!("Session '{}' not found", session_id))?; + + // Update session metadata + data.session.message_count += 1; + data.session.updated_at = chrono::Utc::now().to_rfc3339(); + + // Extract a preview from text content + for content in &message.content { + if let crate::types::messages::MessageContent::Text { text } = content { + let preview = if text.len() > 100 { + format!("{}...", &text[..100]) + } else { + text.clone() + }; + data.session.last_message_preview = Some(preview); + break; + } + } + + data.messages.push(message); + self.save_session(session_id, data); + Ok(()) + } + + pub fn get_messages(&self, session_id: &str) -> Vec { + let sessions = self.sessions.lock().unwrap(); + sessions + .get(session_id) + .map(|d| d.messages.clone()) + .unwrap_or_default() + } + + #[allow(dead_code)] + pub fn update_session_title(&self, id: &str, title: &str) -> Result<(), String> { + let mut sessions = self.sessions.lock().unwrap(); + let data = sessions + .get_mut(id) + .ok_or_else(|| format!("Session '{}' not found", id))?; + + data.session.title = title.to_string(); + data.session.updated_at = chrono::Utc::now().to_rfc3339(); + self.save_session(id, data); + Ok(()) + } + + pub fn delete_session(&self, id: &str) -> Result<(), String> { + let mut sessions = self.sessions.lock().unwrap(); + sessions + .remove(id) + .ok_or_else(|| format!("Session '{}' not found", id))?; + + // Remove from disk + let dir = self.sessions_dir.join(id); + let _ = std::fs::remove_dir_all(dir); + Ok(()) + } +} diff --git a/ui/goose2/src-tauri/src/types/agents.rs b/ui/goose2/src-tauri/src/types/agents.rs new file mode 100644 index 000000000000..cb8f1ee96ff4 --- /dev/null +++ b/ui/goose2/src-tauri/src/types/agents.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Persona { + pub id: String, + pub display_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub system_prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + pub is_builtin: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePersonaRequest { + pub display_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub system_prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePersonaRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Agent { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub persona_id: Option, + pub provider: String, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + pub connection_type: String, + pub status: String, + pub is_builtin: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub acp_endpoint: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub id: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + pub created_at: String, + pub updated_at: String, + pub message_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message_preview: Option, +} + +/// Built-in persona definitions +pub fn builtin_personas() -> Vec { + let now = chrono::Utc::now().to_rfc3339(); + vec![ + Persona { + id: "builtin-goose".to_string(), + display_name: "Goose".to_string(), + avatar_url: None, + system_prompt: "You are Goose, a helpful AI coding assistant. You help users write, debug, and understand code. You are direct, concise, and prefer showing code over describing it. You have access to tools for reading files, running commands, and searching codebases.".to_string(), + provider: Some("goose".to_string()), + model: Some("claude-sonnet-4-20250514".to_string()), + is_builtin: true, + created_at: now.clone(), + updated_at: now.clone(), + }, + Persona { + id: "builtin-researcher".to_string(), + display_name: "Scout".to_string(), + avatar_url: None, + system_prompt: "You are Scout, a research-focused AI assistant. You follow a Research-Plan-Implement pattern: first thoroughly research the problem space, then create a detailed plan, and finally implement the solution. You excel at exploring codebases, finding patterns, and synthesizing information from multiple sources.".to_string(), + provider: Some("goose".to_string()), + model: Some("claude-sonnet-4-20250514".to_string()), + is_builtin: true, + created_at: now.clone(), + updated_at: now.clone(), + }, + Persona { + id: "builtin-reviewer".to_string(), + display_name: "Reviewer".to_string(), + avatar_url: None, + system_prompt: "You are Reviewer, a code review specialist. You analyze code changes for correctness, security vulnerabilities, performance issues, and adherence to best practices. You provide actionable feedback with specific suggestions for improvement. You check for edge cases, error handling, and test coverage.".to_string(), + provider: Some("goose".to_string()), + model: Some("claude-sonnet-4-20250514".to_string()), + is_builtin: true, + created_at: now.clone(), + updated_at: now.clone(), + }, + Persona { + id: "builtin-architect".to_string(), + display_name: "Architect".to_string(), + avatar_url: None, + system_prompt: "You are Architect, a system design specialist. You help users design software architectures, make technology choices, and plan complex implementations. You think in terms of components, interfaces, data flow, and trade-offs. You consider scalability, maintainability, and simplicity.".to_string(), + provider: Some("goose".to_string()), + model: Some("claude-sonnet-4-20250514".to_string()), + is_builtin: true, + created_at: now.clone(), + updated_at: now, + }, + ] +} diff --git a/ui/goose2/src-tauri/src/types/messages.rs b/ui/goose2/src-tauri/src/types/messages.rs new file mode 100644 index 000000000000..22cb4e3835f5 --- /dev/null +++ b/ui/goose2/src-tauri/src/types/messages.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Message { + pub id: String, + pub role: MessageRole, + pub created: i64, + pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + User, + Assistant, + System, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MessageContent { + Text { + text: String, + }, + Image { + source: ImageSource, + }, + ToolRequest { + id: String, + name: String, + arguments: serde_json::Value, + status: ToolCallStatus, + }, + ToolResponse { + id: String, + name: String, + result: String, + #[serde(rename = "isError")] + is_error: bool, + }, + Thinking { + text: String, + }, + RedactedThinking {}, + Reasoning { + text: String, + }, + ActionRequired { + id: String, + #[serde(rename = "actionType")] + action_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + SystemNotification { + #[serde(rename = "notificationType")] + notification_type: String, + text: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ImageSource { + Base64 { media_type: String, data: String }, + Url { url: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ToolCallStatus { + Pending, + Executing, + Completed, + Error, + Stopped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MessageMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub user_visible: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_visible: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageAttachment { + #[serde(rename = "type")] + pub attachment_type: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenState { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub accumulated_input: u64, + pub accumulated_output: u64, + pub accumulated_total: u64, +} diff --git a/ui/goose2/src-tauri/src/types/mod.rs b/ui/goose2/src-tauri/src/types/mod.rs new file mode 100644 index 000000000000..ffacd88221b1 --- /dev/null +++ b/ui/goose2/src-tauri/src/types/mod.rs @@ -0,0 +1,2 @@ +pub mod agents; +pub mod messages; diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index ff8db87aa6e2..1dc1f861201b 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { TabBar } from "@/features/tabs/ui/TabBar"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; @@ -7,6 +7,8 @@ import { ChatView } from "@/features/chat/ui/ChatView"; import { SkillsView } from "@/features/skills/ui/SkillsView"; import { AgentsView } from "@/features/agents/ui/AgentsView"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; import type { Tab } from "@/features/tabs/types"; export type AppView = "home" | "chat" | "skills" | "agents"; @@ -20,27 +22,90 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); const [activeView, setActiveView] = useState("home"); + + const chatStore = useChatStore(); + const agentStore = useAgentStore(); + const isHome = activeTabId === null && activeView === "home"; + const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; - const handleNewTab = () => { - const id = String(Date.now()); - setTabs((prev) => [...prev, { id, title: "New Chat" }]); - setActiveTabId(id); - setActiveView("chat"); - }; + // Derive status bar values from stores + const activeAgent = agentStore.getActiveAgent(); + const modelName = activeAgent?.model ?? "Claude Sonnet 4"; + const tokenCount = chatStore.tokenState.totalTokens; + const connectionStatus = chatStore.isConnected + ? ("connected" as const) + : ("disconnected" as const); + + const createNewTab = useCallback( + (title = "New Chat") => { + const id = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const agentId = agentStore.activeAgentId ?? undefined; + + const tab: Tab = { id, title, sessionId, agentId }; + setTabs((prev) => [...prev, tab]); + setActiveTabId(id); + setActiveView("chat"); + + // Set the active session in chatStore + chatStore.setActiveSession(sessionId); + + return tab; + }, + [chatStore, agentStore.activeAgentId], + ); + + const handleNewTab = useCallback(() => { + createNewTab(); + }, [createNewTab]); + + const handleHomeStartChat = useCallback( + (initialMessage?: string) => { + const tab = createNewTab(initialMessage?.slice(0, 40) || "New Chat"); + // The ChatView will handle sending the initial message via its own input + // We just need to create the tab and session + void tab; // tab is used for side effects in createNewTab + }, + [createNewTab], + ); const handleTabClose = (id: string) => { setTabs((prev) => { + const closingTab = prev.find((t) => t.id === id); const next = prev.filter((t) => t.id !== id); + + // Cleanup session data when closing tab + if (closingTab) { + chatStore.cleanupSession(closingTab.sessionId); + } + if (activeTabId === id) { - const nextId = next[0]?.id ?? null; - setActiveTabId(nextId); - if (!nextId) setActiveView("home"); + const nextTab = next[0] ?? null; + setActiveTabId(nextTab?.id ?? null); + if (nextTab) { + chatStore.setActiveSession(nextTab.sessionId); + setActiveView("chat"); + } else { + setActiveView("home"); + } } return next; }); }; + const handleTabSelect = useCallback( + (id: string) => { + setActiveTabId(id); + setActiveView("chat"); + const tab = tabs.find((t) => t.id === id); + if (tab) { + chatStore.setActiveSession(tab.sessionId); + } + }, + [tabs, chatStore], + ); + const handleNavigate = (view: AppView) => { setActiveView(view); if (view !== "chat") { @@ -74,9 +139,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) { case "agents": return ; case "chat": - return ; + return activeTab ? ( + + ) : ( + + ); case "home": - return activeTabId ? : ; + return activeTab ? ( + + ) : ( + + ); } }; @@ -86,7 +159,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { handleNavigate("home")} @@ -129,9 +202,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) { }`} > diff --git a/ui/goose2/src/features/agents/hooks/useAgents.ts b/ui/goose2/src/features/agents/hooks/useAgents.ts new file mode 100644 index 000000000000..0dfad5539cc3 --- /dev/null +++ b/ui/goose2/src/features/agents/hooks/useAgents.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect } from "react"; +import { useAgentStore } from "../stores/agentStore"; + +// Built-in persona definitions (shipped with app) +const BUILTIN_PERSONAS = [ + { + id: "builtin-goose", + displayName: "Goose", + systemPrompt: + "You are Goose, a general-purpose AI coding assistant. You help with writing, debugging, and understanding code. You are direct, helpful, and concise.", + provider: "goose" as const, + model: "claude-sonnet-4-20250514", + isBuiltin: true, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }, + { + id: "builtin-scout", + displayName: "Scout", + systemPrompt: + "You are Scout, a research-focused agent. You excel at exploring codebases, finding relevant code, understanding architecture, and providing comprehensive analysis. Use the Research-Plan-Implement pattern.", + provider: "goose" as const, + model: "claude-sonnet-4-20250514", + isBuiltin: true, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }, + { + id: "builtin-reviewer", + displayName: "Reviewer", + systemPrompt: + "You are Reviewer, a code review specialist. You focus on code quality, correctness, security, performance, and maintainability. Provide clear, actionable feedback organized by severity.", + provider: "goose" as const, + model: "claude-sonnet-4-20250514", + isBuiltin: true, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }, + { + id: "builtin-architect", + displayName: "Architect", + systemPrompt: + "You are Architect, a software design specialist. You help plan implementations, design systems, evaluate tradeoffs, and create technical specifications.", + provider: "goose" as const, + model: "claude-sonnet-4-20250514", + isBuiltin: true, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }, +]; + +/** + * Hook for managing personas and agents. + * Loads built-in personas on mount and provides CRUD operations. + */ +export function useAgents() { + const store = useAgentStore(); + + // Load built-in personas on mount + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally run only on mount to seed built-in personas once + useEffect(() => { + const existing = store.personas; + if (existing.length === 0) { + store.setPersonas(BUILTIN_PERSONAS); + } + }, []); + + const createPersona = useCallback( + (data: { + displayName: string; + systemPrompt: string; + avatarUrl?: string; + provider?: "goose" | "claude" | "openai" | "ollama" | "custom"; + model?: string; + }) => { + const persona = { + id: crypto.randomUUID(), + ...data, + isBuiltin: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + store.addPersona(persona); + return persona; + }, + [store], + ); + + const updatePersona = useCallback( + ( + id: string, + updates: Partial<{ + displayName: string; + systemPrompt: string; + avatarUrl: string; + provider: "goose" | "claude" | "openai" | "ollama" | "custom"; + model: string; + }>, + ) => { + const persona = store.getPersonaById(id); + if (!persona || persona.isBuiltin) return; + store.updatePersona(id, updates); + }, + [store], + ); + + const deletePersona = useCallback( + (id: string) => { + const persona = store.getPersonaById(id); + if (!persona || persona.isBuiltin) return; + store.removePersona(id); + }, + [store], + ); + + const createAgent = useCallback( + (data: { + name: string; + personaId?: string; + provider: "goose" | "claude" | "openai" | "ollama" | "custom"; + model: string; + systemPrompt?: string; + avatarUrl?: string; + connectionType: "builtin" | "acp"; + }) => { + // If persona, inherit defaults + let finalData = { ...data }; + if (data.personaId) { + const persona = store.getPersonaById(data.personaId); + if (persona) { + finalData = { + ...finalData, + systemPrompt: finalData.systemPrompt ?? persona.systemPrompt, + avatarUrl: finalData.avatarUrl ?? persona.avatarUrl, + provider: finalData.provider ?? persona.provider ?? "goose", + model: + finalData.model ?? persona.model ?? "claude-sonnet-4-20250514", + }; + } + } + + const agent = { + id: crypto.randomUUID(), + ...finalData, + status: "offline" as const, + isBuiltin: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + store.addAgent(agent); + return agent; + }, + [store], + ); + + const deleteAgent = useCallback( + (id: string) => { + const agent = store.getAgentById(id); + if (!agent || agent.isBuiltin) return; + store.removeAgent(id); + }, + [store], + ); + + return { + personas: store.personas, + agents: store.agents, + activeAgent: store.getActiveAgent(), + isLoading: store.isLoading, + builtinPersonas: store.getBuiltinPersonas(), + customPersonas: store.getCustomPersonas(), + createPersona, + updatePersona, + deletePersona, + createAgent, + deleteAgent, + setActiveAgent: store.setActiveAgent, + }; +} diff --git a/ui/goose2/src/features/agents/hooks/usePersonas.ts b/ui/goose2/src/features/agents/hooks/usePersonas.ts new file mode 100644 index 000000000000..a0b0b2288360 --- /dev/null +++ b/ui/goose2/src/features/agents/hooks/usePersonas.ts @@ -0,0 +1,61 @@ +import { useEffect, useCallback } from "react"; +import { useAgentStore } from "../stores/agentStore"; +import type { + CreatePersonaRequest, + UpdatePersonaRequest, +} from "@/shared/types/agents"; +import * as api from "@/shared/api/agents"; + +export function usePersonas() { + const store = useAgentStore(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation + const loadPersonas = useCallback(async () => { + store.setPersonasLoading(true); + try { + const personas = await api.listPersonas(); + store.setPersonas(personas); + } catch (error) { + console.error("Failed to load personas:", error); + // Fall back to empty list - builtins will come from backend + } finally { + store.setPersonasLoading(false); + } + }, []); + + useEffect(() => { + loadPersonas(); + }, [loadPersonas]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation + const createPersona = useCallback(async (req: CreatePersonaRequest) => { + const persona = await api.createPersona(req); + store.addPersona(persona); + return persona; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation + const updatePersona = useCallback( + async (id: string, req: UpdatePersonaRequest) => { + const persona = await api.updatePersona(id, req); + store.updatePersona(id, persona); + return persona; + }, + [], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation + const deletePersona = useCallback(async (id: string) => { + await api.deletePersona(id); + store.removePersona(id); + }, []); + + return { + personas: store.personas, + isLoading: store.personasLoading, + createPersona, + updatePersona, + deletePersona, + refresh: loadPersonas, + }; +} diff --git a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts new file mode 100644 index 000000000000..53275e93b3dd --- /dev/null +++ b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useAgentStore } from "../agentStore"; +import type { Persona, Agent } from "@/shared/types/agents"; + +// ── fixtures ────────────────────────────────────────────────────────── + +function makePersona(overrides: Partial = {}): Persona { + return { + id: crypto.randomUUID(), + displayName: "Test Persona", + systemPrompt: "You are helpful.", + isBuiltin: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: crypto.randomUUID(), + name: "Test Agent", + provider: "goose", + model: "claude-sonnet-4", + connectionType: "builtin", + status: "online", + isBuiltin: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +// ── tests ───────────────────────────────────────────────────────────── + +describe("agentStore", () => { + beforeEach(() => { + useAgentStore.setState({ + personas: [], + personasLoading: false, + agents: [], + agentsLoading: false, + activeAgentId: null, + isLoading: false, + personaEditorOpen: false, + editingPersona: null, + }); + }); + + // ── initial state ───────────────────────────────────────────────── + + it("has empty personas and agents initially", () => { + const state = useAgentStore.getState(); + expect(state.personas).toEqual([]); + expect(state.agents).toEqual([]); + }); + + // ── persona CRUD ────────────────────────────────────────────────── + + it("setPersonas replaces personas", () => { + const p1 = makePersona({ id: "p1" }); + const p2 = makePersona({ id: "p2" }); + useAgentStore.getState().setPersonas([p1, p2]); + expect(useAgentStore.getState().personas).toEqual([p1, p2]); + }); + + it("addPersona appends a persona", () => { + const p = makePersona(); + useAgentStore.getState().addPersona(p); + expect(useAgentStore.getState().personas).toHaveLength(1); + expect(useAgentStore.getState().personas[0].id).toBe(p.id); + }); + + it("updatePersona updates the correct persona", () => { + const p = makePersona({ id: "up1", displayName: "Old" }); + useAgentStore.getState().setPersonas([p]); + useAgentStore.getState().updatePersona("up1", { displayName: "New" }); + expect(useAgentStore.getState().personas[0].displayName).toBe("New"); + }); + + it("removePersona removes the correct persona", () => { + const p1 = makePersona({ id: "keep" }); + const p2 = makePersona({ id: "remove" }); + useAgentStore.getState().setPersonas([p1, p2]); + useAgentStore.getState().removePersona("remove"); + expect(useAgentStore.getState().personas).toHaveLength(1); + expect(useAgentStore.getState().personas[0].id).toBe("keep"); + }); + + // ── agent CRUD ──────────────────────────────────────────────────── + + it("setAgents replaces agents", () => { + const a = makeAgent(); + useAgentStore.getState().setAgents([a]); + expect(useAgentStore.getState().agents).toEqual([a]); + }); + + it("addAgent appends an agent", () => { + const a = makeAgent(); + useAgentStore.getState().addAgent(a); + expect(useAgentStore.getState().agents).toHaveLength(1); + }); + + it("updateAgent updates the correct agent", () => { + const a = makeAgent({ id: "ua1", name: "Old" }); + useAgentStore.getState().setAgents([a]); + useAgentStore.getState().updateAgent("ua1", { name: "New" }); + expect(useAgentStore.getState().agents[0].name).toBe("New"); + }); + + it("removeAgent removes the correct agent", () => { + const a1 = makeAgent({ id: "keep" }); + const a2 = makeAgent({ id: "remove" }); + useAgentStore.getState().setAgents([a1, a2]); + useAgentStore.getState().removeAgent("remove"); + expect(useAgentStore.getState().agents).toHaveLength(1); + expect(useAgentStore.getState().agents[0].id).toBe("keep"); + }); + + // ── active agent ────────────────────────────────────────────────── + + it("setActiveAgent updates activeAgentId", () => { + useAgentStore.getState().setActiveAgent("a1"); + expect(useAgentStore.getState().activeAgentId).toBe("a1"); + }); + + it("getActiveAgent returns correct agent or null", () => { + expect(useAgentStore.getState().getActiveAgent()).toBeNull(); + + const a = makeAgent({ id: "active-1" }); + useAgentStore.getState().setAgents([a]); + useAgentStore.getState().setActiveAgent("active-1"); + expect(useAgentStore.getState().getActiveAgent()).toEqual(a); + }); + + // ── persona editor ──────────────────────────────────────────────── + + it("openPersonaEditor sets editing state", () => { + const p = makePersona(); + useAgentStore.getState().openPersonaEditor(p); + expect(useAgentStore.getState().personaEditorOpen).toBe(true); + expect(useAgentStore.getState().editingPersona).toEqual(p); + }); + + it("openPersonaEditor without persona sets editingPersona to null", () => { + useAgentStore.getState().openPersonaEditor(); + expect(useAgentStore.getState().personaEditorOpen).toBe(true); + expect(useAgentStore.getState().editingPersona).toBeNull(); + }); + + it("closePersonaEditor clears editing state", () => { + useAgentStore.getState().openPersonaEditor(makePersona()); + useAgentStore.getState().closePersonaEditor(); + expect(useAgentStore.getState().personaEditorOpen).toBe(false); + expect(useAgentStore.getState().editingPersona).toBeNull(); + }); + + // ── helpers ─────────────────────────────────────────────────────── + + it("getPersonaById returns correct persona", () => { + const p = makePersona({ id: "find-me" }); + useAgentStore.getState().setPersonas([p]); + expect(useAgentStore.getState().getPersonaById("find-me")).toEqual(p); + expect(useAgentStore.getState().getPersonaById("nope")).toBeUndefined(); + }); + + it("getAgentsByPersona filters correctly", () => { + const a1 = makeAgent({ id: "a1", personaId: "p1" }); + const a2 = makeAgent({ id: "a2", personaId: "p2" }); + const a3 = makeAgent({ id: "a3", personaId: "p1" }); + useAgentStore.getState().setAgents([a1, a2, a3]); + const result = useAgentStore.getState().getAgentsByPersona("p1"); + expect(result).toHaveLength(2); + expect(result.map((a) => a.id).sort()).toEqual(["a1", "a3"]); + }); + + it("getBuiltinPersonas returns only builtins", () => { + useAgentStore + .getState() + .setPersonas([ + makePersona({ id: "b", isBuiltin: true }), + makePersona({ id: "c", isBuiltin: false }), + ]); + const builtins = useAgentStore.getState().getBuiltinPersonas(); + expect(builtins).toHaveLength(1); + expect(builtins[0].id).toBe("b"); + }); + + it("getCustomPersonas returns only non-builtins", () => { + useAgentStore + .getState() + .setPersonas([ + makePersona({ id: "b", isBuiltin: true }), + makePersona({ id: "c", isBuiltin: false }), + ]); + const custom = useAgentStore.getState().getCustomPersonas(); + expect(custom).toHaveLength(1); + expect(custom[0].id).toBe("c"); + }); +}); diff --git a/ui/goose2/src/features/agents/stores/agentStore.ts b/ui/goose2/src/features/agents/stores/agentStore.ts new file mode 100644 index 000000000000..138893339d81 --- /dev/null +++ b/ui/goose2/src/features/agents/stores/agentStore.ts @@ -0,0 +1,151 @@ +import { create } from "zustand"; +import type { Persona, Agent } from "@/shared/types/agents"; + +interface AgentStoreState { + // Personas + personas: Persona[]; + personasLoading: boolean; + + // Agents + agents: Agent[]; + agentsLoading: boolean; + + // Active agent for current chat + activeAgentId: string | null; + + // General loading (for backwards compat) + isLoading: boolean; + + // UI state + personaEditorOpen: boolean; + editingPersona: Persona | null; +} + +interface AgentStoreActions { + // Persona CRUD + setPersonas: (personas: Persona[]) => void; + addPersona: (persona: Persona) => void; + updatePersona: (id: string, updates: Partial) => void; + removePersona: (id: string) => void; + setPersonasLoading: (loading: boolean) => void; + + // Agent CRUD + setAgents: (agents: Agent[]) => void; + addAgent: (agent: Agent) => void; + updateAgent: (id: string, updates: Partial) => void; + removeAgent: (id: string) => void; + setAgentsLoading: (loading: boolean) => void; + + // Active agent + setActiveAgent: (id: string | null) => void; + getActiveAgent: () => Agent | null; + + // Persona editor + openPersonaEditor: (persona?: Persona) => void; + closePersonaEditor: () => void; + + // Loading + setLoading: (loading: boolean) => void; + + // Helpers + getPersonaById: (id: string) => Persona | undefined; + getAgentById: (id: string) => Agent | undefined; + getAgentsByPersona: (personaId: string) => Agent[]; + getBuiltinPersonas: () => Persona[]; + getCustomPersonas: () => Persona[]; +} + +export type AgentStore = AgentStoreState & AgentStoreActions; + +export const useAgentStore = create((set, get) => ({ + // State + personas: [], + personasLoading: false, + agents: [], + agentsLoading: false, + activeAgentId: null, + isLoading: false, + personaEditorOpen: false, + editingPersona: null, + + // Persona CRUD + setPersonas: (personas) => set({ personas }), + + addPersona: (persona) => + set((state) => ({ personas: [...state.personas, persona] })), + + updatePersona: (id, updates) => + set((state) => ({ + personas: state.personas.map((p) => + p.id === id + ? { ...p, ...updates, updatedAt: new Date().toISOString() } + : p, + ), + })), + + removePersona: (id) => + set((state) => ({ + personas: state.personas.filter((p) => p.id !== id), + })), + + setPersonasLoading: (personasLoading) => set({ personasLoading }), + + // Agent CRUD + setAgents: (agents) => set({ agents }), + + addAgent: (agent) => set((state) => ({ agents: [...state.agents, agent] })), + + updateAgent: (id, updates) => + set((state) => ({ + agents: state.agents.map((a) => + a.id === id + ? { ...a, ...updates, updatedAt: new Date().toISOString() } + : a, + ), + })), + + removeAgent: (id) => + set((state) => ({ + agents: state.agents.filter((a) => a.id !== id), + activeAgentId: state.activeAgentId === id ? null : state.activeAgentId, + })), + + setAgentsLoading: (agentsLoading) => set({ agentsLoading }), + + // Active agent + setActiveAgent: (id) => set({ activeAgentId: id }), + + getActiveAgent: () => { + const { activeAgentId, agents } = get(); + if (!activeAgentId) return null; + return agents.find((a) => a.id === activeAgentId) ?? null; + }, + + // Persona editor + openPersonaEditor: (persona) => + set({ + personaEditorOpen: true, + editingPersona: persona ?? null, + }), + + closePersonaEditor: () => + set({ + personaEditorOpen: false, + editingPersona: null, + }), + + // Loading + setLoading: (isLoading) => set({ isLoading }), + + // Helpers + getPersonaById: (id) => get().personas.find((p) => p.id === id), + + getAgentById: (id) => get().agents.find((a) => a.id === id), + + getAgentsByPersona: (personaId) => + get().agents.filter((a) => a.personaId === personaId), + + getBuiltinPersonas: () => get().personas.filter((p) => p.isBuiltin), + + getCustomPersonas: () => get().personas.filter((p) => !p.isBuiltin), +})); diff --git a/ui/goose2/src/features/agents/ui/AgentConfig.tsx b/ui/goose2/src/features/agents/ui/AgentConfig.tsx new file mode 100644 index 000000000000..addec78fa659 --- /dev/null +++ b/ui/goose2/src/features/agents/ui/AgentConfig.tsx @@ -0,0 +1,190 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/shared/ui/button"; +import type { + Agent, + Persona, + ProviderType, + AgentConnectionType, + CreateAgentRequest, +} from "@/shared/types/agents"; + +interface AgentConfigProps { + agent?: Agent; + personas: Persona[]; + onSave: (config: CreateAgentRequest) => void; + onCancel: () => void; +} + +const PROVIDER_OPTIONS: { value: ProviderType; label: string }[] = [ + { value: "goose", label: "Goose" }, + { value: "claude", label: "Claude" }, + { value: "openai", label: "OpenAI" }, + { value: "ollama", label: "Ollama" }, + { value: "custom", label: "Custom" }, +]; + +export function AgentConfig({ + agent, + personas, + onSave, + onCancel, +}: AgentConfigProps) { + const [name, setName] = useState(agent?.name ?? ""); + const [personaId, setPersonaId] = useState(agent?.personaId ?? ""); + const connectionType: AgentConnectionType = "builtin"; + const [provider, setProvider] = useState( + agent?.provider ?? "goose", + ); + const [model, setModel] = useState(agent?.model ?? ""); + const [systemPrompt, setSystemPrompt] = useState(agent?.systemPrompt ?? ""); + const [promptExpanded, setPromptExpanded] = useState(false); + + const selectedPersona = useMemo( + () => personas.find((p) => p.id === personaId), + [personas, personaId], + ); + + // Sync inherited fields when persona changes + useEffect(() => { + if (selectedPersona) { + setProvider(selectedPersona.provider ?? "goose"); + setModel(selectedPersona.model ?? ""); + setSystemPrompt(selectedPersona.systemPrompt); + } + }, [selectedPersona]); + + const isValid = name.trim().length > 0 && !!provider; + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!isValid) return; + + const config: CreateAgentRequest = { + name: name.trim(), + personaId: personaId || undefined, + provider, + model: model.trim(), + systemPrompt: systemPrompt.trim() || undefined, + connectionType, + }; + onSave(config); + }, + [isValid, name, personaId, provider, model, systemPrompt, onSave], + ); + + return ( +
+ {/* Name */} + + + {/* Persona selector */} + + + {/* Provider */} + + + {/* Model override */} + + + {/* System prompt override */} +
+ + {promptExpanded && ( +