commit decba25a08336af8d487ff9a66e59a42a02f4f31
Author: ssdfasd <2156608475@qq.com>
Date: Thu Mar 12 21:33:50 2026 +0800
Initial commit: restructure to flat layout with ui/ and web/ at root
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa3cead
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,54 @@
+# Logs
+logs
+*.log
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+node_modules
+dist
+dist-ssr
+release
+*.local
+*.tgz
+*.vsix
+/npm
+/tsc
+/openchamber@*
+local-dev*
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.opencode/plans/*
+.hive
+
+# Build outputs
+build/
+dist-output/
+.gradle/
+.*.bun-build
+
+# Built webview assets (generated during build)
+src/main/resources/webview/
+
+# IDE
+*.iml
+*.ipr
+*.iws
+
+# Local env
+.env
+.env.local
+
+# IntelliJ plugin (separate project)
+packages/intellij/
+
+# OS
+Thumbs.db
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..a24992d
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+lts
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9fd5ff7
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,128 @@
+# OpenChamber - AI Agent Reference (verified)
+
+## Core purpose
+OpenChamber is a web-based UI runtime for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via `@opencode-ai/sdk`.
+
+## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
+- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
+- UI: React, TypeScript, Vite, Tailwind v4
+- State: Zustand (`ui/src/stores/`)
+- UI primitives: Radix UI (`package.json` deps), HeroUI (`package.json` deps), Remixicon (`package.json` deps)
+- Server: Express (`web/server/index.js`)
+
+## Monorepo layout
+No longer using workspaces (see `package.json`).
+- Shared UI: `ui`
+- Web app + server + CLI: `web`
+
+## Documentation map
+Before changing any mapped module, read its module documentation first.
+
+### web
+Web runtime and server implementation for OpenChamber.
+
+#### lib
+Server-side integration modules used by API routes and runtime services.
+
+##### quota
+Quota provider registry, dispatch, and provider integrations for usage endpoints.
+- Module docs: `web/server/lib/quota/DOCUMENTATION.md`
+
+##### git
+Git repository operations for the web server runtime.
+- Module docs: `web/server/lib/git/DOCUMENTATION.md`
+
+##### github
+GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing.
+- Module docs: `web/server/lib/github/DOCUMENTATION.md`
+
+##### opencode
+OpenCode server integration utilities including config management, provider authentication, and UI authentication.
+- Module docs: `web/server/lib/opencode/DOCUMENTATION.md`
+
+##### notifications
+Notification message preparation utilities for system notifications, including text truncation and optional summarization.
+- Module docs: `web/server/lib/notifications/DOCUMENTATION.md`
+
+##### terminal
+WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting.
+- Module docs: `web/server/lib/terminal/DOCUMENTATION.md`
+
+##### tts
+Server-side text-to-speech services and summarization helpers for `/api/tts/*` endpoints.
+- Module docs: `web/server/lib/tts/DOCUMENTATION.md`
+
+##### skills-catalog
+Skills catalog management including discovery, installation, and configuration of agent skill packages.
+- Module docs: `web/server/lib/skills-catalog/DOCUMENTATION.md`
+
+## Build / dev commands (verified)
+All scripts are in `package.json`.
+- Validate: `bun run type-check:web`, `bun run lint:web` (or use :ui variants)
+- Build: `bun run build:web` and/or `bun run build:ui`
+- Package for distribution: `bun run package` (outputs to `dist-output/`)
+
+## Distribution
+Run `bun run package` to generate `dist-output/` folder for distribution.
+Users then run:
+```bash
+cd dist-output
+npm install --omit=dev
+npm run start
+```
+
+## Runtime entry points
+- Web bootstrap: `web/src/main.tsx`
+- Web server: `web/server/index.js`
+- Web CLI: `web/bin/cli.js` (package bin: `web/package.json`)
+
+## OpenCode integration
+- UI client wrapper: `ui/src/lib/opencode/client.ts` (imports `@opencode-ai/sdk/v2`)
+- SSE hookup: `ui/src/hooks/useEventStream.ts`
+- Web server embeds/starts OpenCode server: `web/server/index.js` (`createOpencodeServer`)
+- Web runtime filesystem endpoints: search `web/server/index.js` for `/api/fs/`
+- External server support: Set `OPENCODE_HOST` (full base URL, e.g. `http://hostname:4096`) or `OPENCODE_PORT`, plus `OPENCODE_SKIP_START=true`, to connect to existing OpenCode instance
+
+## Key UI patterns (reference files)
+- Settings shell: `ui/src/components/views/SettingsView.tsx`
+- Settings shared primitives: `ui/src/components/sections/shared/`
+- Settings sections: `ui/src/components/sections/` (incl `skills/`)
+- Chat UI: `ui/src/components/chat/` and `ui/src/components/chat/message/`
+- Theme + typography: `ui/src/lib/theme/`, `ui/src/lib/typography.ts`
+- Terminal UI: `ui/src/components/terminal/` (uses `ghostty-web`)
+
+## External / system integrations (active)
+- Git: `ui/src/lib/gitApi.ts`, `web/server/index.js` (`simple-git`)
+- Terminal PTY: `web/server/index.js` (`bun-pty`/`node-pty`)
+- Skills catalog: `web/server/lib/skills-catalog/`, UI: `ui/src/components/sections/skills/`
+
+## Agent constraints
+- Do not modify `../opencode` (separate repo).
+- Do not run git/GitHub commands unless explicitly asked.
+- Keep baseline green (run `bun run type-check:web`, `bun run lint:web`, `bun run build:web` before finalizing changes).
+
+## Development rules
+- Keep diffs tight; avoid drive-by refactors.
+- Follow local precedent; search nearby code first.
+- TypeScript: avoid `any`/blind casts; keep ESLint/TS green.
+- React: prefer function components + hooks; class only when needed (e.g. error boundaries).
+- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`.
+- Styling: Tailwind v4; typography via `ui/src/lib/typography.ts`; theme vars via `ui/src/lib/theme/`.
+- Toasts: use custom toast wrapper from `@/components/ui` (backed by `ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code.
+- No new deps unless asked.
+- Never add secrets (`.env`, keys) or log sensitive data.
+
+## Theme System (MANDATORY for UI work)
+
+When working on any UI components, styling, or visual changes, agents **MUST** study the theme system skill first.
+
+**Before starting any UI work:**
+```
+skill({ name: "theme-system" })
+```
+
+This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes.
+
+## Recent changes
+- Releases + high-level changes: `CHANGELOG.md`
+- Recent commits: `git log --oneline` (latest tags: `v1.8.x`)
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9e647fb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,776 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+## [1.8.5] - 2026-03-04
+
+- Desktop: startup now opens the app shell much earlier while background services continue loading, so the app feels ready faster after launch.
+- Desktop/macOS: fixed early title updates that could shift traffic-light window controls on startup, keeping native controls stable in their expected position.
+- VSCode: edit-style tool results now open directly in a focused diff view, so you can review generated changes at the first modified line with less manual navigation.
+- VSCode: cleaned up extension settings by removing duplicate display controls and hiding sections that do not apply in the editor environment.
+- Chat: fixed focus-mode composer layout so the footer action row stays pinned and accessible while writing longer prompts.
+- UI/Theming: unified loading logos and startup screens across runtimes, with visuals that better match your active theme.
+- Projects/UI: project icons now follow active theme foreground colors more consistently, improving readability and visual consistency in project lists.
+- Reliability: improved early startup recovery so models and agents are less likely to appear missing right after launch.
+- Tunnel/CLI: fixed one-time Cloudflare tunnel connect links in CLI output for `--try-cf-tunnel`, so remote collaborators can use the printed URL/QR flow successfully (thanks to @plfavreau).
+- Mobile/PWA: respected OS rotation lock by removing forced orientation behavior in the web app shell (thanks to @theluckystrike).
+
+
+## [1.8.4] - 2026-03-04
+
+- Chat: added clickable file-path links in assistant messages (including line targeting), so you can jump from answer text straight to the exact file location (thanks to @yulia-ivashko).
+- Chat: added a new `Changes` tool-output mode that expands edits/patches by default while keeping activity readable, making long runs easier to review (thanks to @iamhenry).
+- Chat: in-progress tools now appear immediately and stay live in collapsed activity view, so active work is visible earlier with stable durations (thanks to @nelsonPires5).
+- Chat: improved long user-message behavior in sticky mode with bounded height, internal scrolling, and cleaner action hit targets for better readability and control.
+- Chat/Files: improved `@` file discovery and mention behavior with project-scoped search and more consistent matching, reducing wrong-project results.
+- Chat/GitHub: added Attach menu actions to link GitHub issues and PRs directly in any session, making it faster to pull ticket/PR context into a prompt.
+- Chat/Files: restored user image previews/fullscreen navigation and improved text-selection action placement on narrow layouts.
+- Shortcuts/Models: added favorite-model cycling shortcuts, so you can switch between starred models without leaving the keyboard (thanks to @iamhenry).
+- Sessions: added active-project session search in the sidebar, with clearer match behavior and easier clearing during filtering (thanks to @KJdotIO).
+- Worktrees/GitHub: streamlined worktree creation with a unified flow for branches, issues, and PR-linked sessions, including cleaner validation and faster branch loading.
+- Worktrees/Git: fixed branch/PR source resolution (including slash-named branches and fork PR heads), so linked worktrees track and push to the correct upstream branch.
+- Git: fixed a PR panel refresh loop that could trigger repeated updates and unstable behavior in the PR section (thanks to @yulia-ivashko).
+- Files/Desktop: improved `Open In` actions from file views/editors, including app selection behavior and tighter integration for opening focused files (thanks to @yulia-ivashko).
+- Mobile/Projects: added long-press project editing with a bottom-sheet panel and drag-to-reorder support for faster project management on mobile (thanks to @Jovines).
+- Web/PWA/Android: added improved install UX with pre-install naming and manifest shortcut updates, so installed web apps feel more customized and project-aware (thanks to @shekohex).
+- UI: interactive controls now consistently show pointer cursors, improving click affordance and reducing ambiguous hover states (thanks to @KJdotIO).
+- Security/Reliability: hardened terminal auth, tightened skill-file path protections, and reduced sensitive request logging exposure for safer day-to-day usage (thanks to @yulia-ivashko).
+
+
+## [1.8.3] - 2026-03-02
+
+- Chat: added user-message display controls for plain-text rendering and sticky headers, so you can tune readability to match your preferences.
+- Chat/UI: overhauled the context panel with reusable tabs and embedded session chat (_beta_), making parallel context work easier without losing place.
+- Chat: improved code block presentation with cleaner action alignment, restored horizontal scrolling, and polished themed highlighting across chat messages and tool output (thanks to @nelsonPires5).
+- Diff: added quick open-in-editor actions from diff views that jump to the first changed line, so it is faster to move from review to edits.
+- Git: refined Git sidebar tab behavior and spacing, plus bulk-revert with confirmations for easier cleanup.
+- Git: fixed commit staging edge cases by filtering stale deleted paths before staging, reducing pathspec commit failures.
+- Git/Worktrees: restored branch rename/edit controls in draft sessions when working in a worktree directory, so branch actions stay available earlier.
+- Chat: model picker now supports collapsible provider groups and remembers expanded state between sessions.
+- Settings: reorganized chat display settings into a more compact two-column layout, so more new options are easier to navigate.
+- Mobile/UI: fixed session-title overflow in compact headers so running/unread indicators and actions remain visible (thanks to @iamhenry).
+
+
+## [1.8.2] - 2026-03-01
+
+- Updates: hardened the self-update flow with safer release handling and fallback behavior, reducing failed or stuck updates.
+- Chat: added a new "Share as image" action so you can quickly export and share important messages (thanks to @Jovines).
+- Chat: improved message readability with cleaner tool/reasoning rendering and less noisy activity timing in busy conversations (thanks to @nelsonPires5).
+- Desktop/Chat: permission toasts now include session context and a clearer permission preview, making approvals more accessible outside of a session (thanks to @nelsonPires5).
+- VSCode: fixed live streaming edge cases for event endpoints with query/trailing-slash variants, improving real-time updates in chat, session editor, and agent-manager views.
+- Reliability: improved event-stream/session visibility handling when the app is hidden or restored, reducing stale activity states and missed updates.
+- Windows: fixed CLI/runtime path and spawn edge cases to reduce startup and command failures on Windows (thanks to @plfavreau).
+- Notifications/Voice: consolidated TTS and summarization service wiring for steadier text-to-speech and summary flows (thanks to @nelsonPires5).
+- Deployment: fixed Docker build/runtime issues for more reliable containerized setups (thanks to @nzlov).
+
+
+## [1.8.1] - 2026-02-28
+
+- Web/Auth: fixed an issue where non-tunnel browser sessions could incorrectly show a tunnel-only lock screen; normal auth flow now appears unless a tunnel is actually active.
+
+
+## [1.8.0] - 2026-02-28
+
+- Desktop: added SSH remote instance support with dedicated lifecycle and UX flows, so you can work against remote machines more reliably (thanks to @shekohex).
+- Projects: added project icon customization with upload/remove and automatic favicon discovery from your repository (thanks to @shekohex).
+- Projects: added header project actions on Web and Mobile, so you can run and stop any configured project commands without leaving chat.
+- Projects/Desktop: project actions can also open SSH-forwarded URLs, making remote dev-server workflows quicker from inside the app.
+- Desktop: added dynamic window titles that reflect active project and remote context, so it is easier to track where you are working (thanks to @shekohex).
+- Remote Tunnel: added tunnel settings with quick/named modes, secure one-time connect links (with QR), and saved named-tunnel presets/tokens so enabling remote access is easier and safer (thanks to @yulia-ivashko).
+- UI: expanded sprite-based file and folder icons across Files, Diff, and Git views for faster visual scanning (thanks to @shekohex).
+- UI: added an expandable project rail with project names, a settings toggle, and saved expansion state for easier navigation in multi-project setups (thanks to @nguyenngothuong).
+- UI/Files: added file-type icons across file lists, tabs, and diffs, so you can identify files faster at a glance (thanks to @shekohex).
+- Files: added a read-only highlighted view with a quick toggle back to edit mode, so you can quickly review code with richer syntax rendering if you don't need to edit thing (thanks to @shekohex).
+- Files: markdown preview now handles frontmatter more cleanly, improving readability for docs-heavy repos (thanks to @shekohex).
+- Chat: improved long-session performance with virtualized message rendering, smoother scrolling, and more stable behavior in large histories (thanks to @shekohex).
+- Chat: enabled markdown rendering in user messages for clearer formatted prompts and notes (thanks to @haofeng0705).
+- Chat: enabled bueatiful diffs for edit tools in chat making this aligned with dedicated diffs view style (thanks to @shekohex).
+- Chat: pasted absolute paths are now treated as normal messages, reducing accidental command-like behavior when sharing paths.
+- Chat: fixed queued sends for inactive sessions, reducing stuck queues.
+- Chat: upgraded Mermaid rendering with a cleaner diagram view plus quick copy/download actions, making generated diagrams easier to read and share (thanks to @shekohex).
+- Notifications: improved child-session notification detection to reduce missed or misclassified subtask updates (thanks to @Jovines).
+- Deployment: added Docker deployment support with safer container defaults and terminal shell fallback, making self-hosted setups easier to run (thanks to @nzlov).
+- Reliability: improved Windows compatibility across git status checks, OpenCode startup, path normalization, and session merge behavior (thanks to @mmereu).
+- Usage: added MiniMax coding-plan quota provider support for broader usage tracking coverage (thanks to @nzlov).
+- Usage: added Ollama Cloud quota provider support for broader usage tracking coverage (thanks to @iamhenry).
+
+
+## [1.7.5] - 2026-02-25
+
+- UI: moved projects into a dedicated sidebar rail and tightened the layout so switching projects and sessions feels faster.
+- Chat: fixed an issue where messages could occasionally duplicate or disappear during active conversations.
+- Sessions: reduced session-switching overhead to make chat context changes feel more immediate.
+- Reliability/Auth: migrated session auth storage to signed JWTs with a persistent secret, reducing unexpected auth-state drift after reconnects or reloads (thanks to @Jovines).
+- Mobile: pending permission prompts now recover after reconnect/resume instead of getting lost mid-run (thanks to @nelsonPires5).
+- Mobile/Chat: refined message spacing and removed the top scroll shadow for a cleaner small-screen reading experience (thanks to @Jovines).
+- Web: added `OPENCODE_HOST` support so you can connect directly to an external OpenCode server using a full base URL (thanks to @colinmollenhour).
+- Web/Mobile: fixed in-app update flow in containerized setups so updates apply correctly.
+
+
+## [1.7.4] - 2026-02-24
+
+- Settings: redesigned the settings workspace with flatter, more consistent page layouts so configuration is faster to scan and edit.
+- Settings: improved agents and skills navigation by grouping entries by subfolder for easier management at scale (thanks to @nguyenngothuong).
+- Chat: improved streaming smoothness and stability with buffered updates and runtime fixes, reducing lag, stuck spinners, memory growth, and timeout-related interruptions in long runs (thanks to @nguyenngothuong).
+- Chat: added fullscreen Mermaid preview, persisted default thinking variant selection, and hardened file-preview safety checks for a safer, more predictable message experience (thanks to @yulia-ivashko).
+- Chat: draft text now persists per session, and the input supports an expanded focus mode for longer prompts (thanks to @nguyenngothuong).
+- Sessions: expanded folder management with subfolders, cleaner organization actions, and clearer delete confirmations (thanks to @nguyenngothuong).
+- Settings: added an MCP config manager UI to simplify editing and validating MCP server configuration (thanks to @nguyenngothuong).
+- Git/PR: moved commit-message and PR-description generation to active-session structured output, so generation uses current session context and avoids fragile backend polling.
+- Chat Activity: improved Structured Output tool rendering with dedicated title/icon, clearer result descriptions, and more reliable detailed expansion defaults.
+- Notifications/Voice: moved utility model controls into AI Summarization as a Zen-only Summarization Model setting.
+- Mobile: refreshed drawer and session-status layouts for better small-screen usability (thanks to @Jovines).
+- Desktop: improved remote instance URL handling for more reliable host/query matching (thanks to @shekohex).
+- Files: added C, C++, and Go language support for syntax-aware rendering in code-heavy workflows (thanks to @fomenks).
+
+
+## [1.7.3] - 2026-02-21
+
+- Settings: added customizable keyboard shortcuts for chat actions, panel toggles, and services, so you can better match OpenChamber to your workflow (thanks to @nelsonPires5).
+- Sessions: added custom folders to group chat sessions, with move/rename/delete flows and persisted collapse state per project (thanks to @nguyenngothuong).
+- Notifications: improved agent progress notifications and permission handling to reduce noisy prompts during active runs (thanks to @nguyenngothuong).
+- Diff/Plans/Files: restored inline comments making more like a GitHub style again (thanks to @nelsonPires5).
+- Terminal: restored terminal text copy behavior, so selecting and copying command output works reliably again (thanks to @shekohex).
+- UI: unified clipboard copy behavior across Desktop app, Web app, and VS Code extension for more consistent copy actions and feedback.
+- Reliability: improved startup environment detection by capturing login-shell environment snapshots, reducing missing PATH/tool issues on launch.
+- Reliability: refactored OpenCode config/auth integration into domain modules for steadier provider auth and command loading flows (thanks to @nelsonPires5).
+
+
+## [1.7.2] - 2026-02-20
+
+- Chat: question prompts now guide you to unanswered items before submit, making tool-question flows faster.
+- Chat: fixed auto-send queue to wait for the active session to be idle before sending, reducing misfires during agent messages.
+- Chat: improved streaming activity rendering and session attention indicators, so active progress and unread signals stay more consistent.
+- UI: added Plan view in the context sidebar panel for quicker access to plan content while you work (thanks to @nelsonPires5).
+- Settings: model variant options now refresh correctly in draft/new-session flows, avoiding stale selections.
+- Reliability: provider auth failures now show clearer re-auth guidance when tokens expire, making recovery faster (thanks to @yulia-ivashko).
+
+
+## [1.7.1] - 2026-02-18
+
+- Chat: slash commands now follow server command semantics (including multiline arguments), so command behavior is more consistent with OpenCode CLI.
+- Chat: added a shell mode triggered by leading `!`, with inline output visibility/copy.
+- Chat: improved delegated-task clarity with richer subtask bubbles, better task-detail rendering, and parent-chat surfacing for child permission/question requests.
+- Chat: improved `@` mention autocomplete by prioritizing agents and cleaning up ordering for faster picks.
+- Skills: discovery now uses OpenCode API as the source of truth with safer fallback scanning, improving installed-state accuracy.
+- Skills: upgraded editing/install UX with better code editing, syntax-aware related files, and clearer location targeting across user/project .opencode and .agents scopes.
+- Mobile: fixed accidental abort right after tapping Send on touch devices, reducing interrupted responses (thanks to @shekohex).
+- Maintenance: removed deprecated GitHub Actions cloud runtime assets and docs to reduce setup confusion (thanks to @yulia-ivashko).
+
+
+## [1.7.0] - 2026-02-17
+
+- Chat: improved live streaming with part-delta updates and smarter auto-follow scrolling, so long responses stay readable while they generate.
+- Chat: Mermaid diagrams now render inline in assistant messages, with quick copy/download actions for easier sharing.
+- UI: added a context overview panel with token usage, cost breakdown, and raw message inspection to make session debugging easier.
+- Sessions: project icon and color customizations now persist reliably across restarts.
+**- Reliability: managed local OpenCode runtimes now use rotated secure auth and tighter lifecycle control across runtimes, reducing stale-process and reconnect issues (thanks to @yulia-ivashko).**
+- Git/GitHub: improved backend reliability for repository and auth operations, helping branch and PR flows stay more predictable (thanks to @nelsonPires5).
+
+
+## [1.6.9] - 2026-02-16
+
+- **UI: redesigned the workspace shell with a context panel, tabbed sidebars, and quicker navigation across chat, files, and reviews, so daily workflows feel more focused.**
+- UI: compact model info in selection (price + capabilities), making model selection faster and more cost-aware (thanks to @nelsonPires5).
+- Chat: fixed files attachment issue and added displaying of excided quota information.
+- Diff: improved large diff rendering and interaction performance for smoother reviews on heavy changesets.
+- Worktrees: shipped an upstream-first flow across supported runtimes, making branch tracking and worktree session setup more predictable (thanks to @yulia-ivashko).
+- Git: improved pull request branch normalization and base/remote resolution to reduce PR setup mismatches (thanks to @gsxdsm).
+- Sessions: added a persistent project notes and todos panel, so key context and follow-ups stay attached to each project (thanks to @gsxdsm).
+- Sessions: introduced the ability to pin sessions within your groups for easy access.
+- Settings: added a configurable Zen model for commit messages generation and summarization of notifications (thanks to @gsxdsm).
+- Usage: added NanoGPT quota support and hardened provider handling for more reliable usage tracking (thanks to @nelsonPires5).
+- Reliability: startup now auto-detects and safely connects to an existing OpenCode server, reducing duplicate-server conflicts (thanks to @ruslan-kurchenko).
+- Desktop: improved day-to-day polish with restored desktop window geometry and posiotion (thanks to @yulia-ivashko).
+- Mobile: fixes for small-screen editor, terminal, and layout overlap issues (thanks to @gsxdsm, @nelsonPires5).
+
+
+## [1.6.8] - 2026-02-12
+
+- Chat: added drag-and-drop attachments with inline image previews, so sharing screenshots and files in prompts feels much faster and more reliable.
+- Sessions: fixed a sidebar issue where draft input could carry over when switching projects, so each workspace keeps cleaner chat context.
+- Chat: improved quick navigation from the sessions list by adding double-click to jump into chat and auto-focus the draft input; also fixed mobile session return behavior (thanks to @gsxdsm).
+- Chat: improved agent/model picking with fuzzy search across names and descriptions, making long lists easier to filter.
+- Usage: corrected Gemini and Antigravity quota source mapping and labels for more accurate usage tracking (thanks to @gsxdsm).
+- Usage: when using remaining-quota mode, usage markers now invert direction to better match how remaining capacity is interpreted (thanks to @gsxdsm).
+- Desktop: fixed project selection in opened remote instances.
+- Desktop: fixed opened remote instances that use HTTP (helpful for instances under tunneling).
+
+
+## [1.6.7] - 2026-02-10
+
+- Voice: added built-in voice input and read-aloud responses with multiple providers, so you can drive chats hands-free when typing is slower (thanks to @gsxdsm).
+- Git: added multi-remote push selection and smarter fork-aware pull request creation to reduce manual branch/remote setup (thanks to @gsxdsm).
+- Usage: added usage pace and prediction indicators in the header and settings, so it is easier to see how quickly quota is moving (thanks to @gsxdsm).
+- Diff/Plans: fixed comment draft collisions and improved multi-line comment editing in plan and file workflows, so feedback is less likely to get lost (thanks to @nelsonPires5).
+- Notifications: stopped firing completion notifications for comment draft edits to reduce noisy alerts during review-heavy sessions (thanks to @nelsonPires5).
+- Settings: added confirmation dialogs for destructive delete/reset actions to prevent accidental data loss.
+- UI: refreshed header and settings layout, improved host switching, and upgraded the editor for smoother day-to-day navigation and editing.
+- Desktop: added multi-window support with a dedicated "New Window" action for parallel work across projects (thanks to @yulia-ivashko).
+- Reliability: fixed message loading edge cases, stabilized voice-mode persistence across restarts, and improved update flow behavior across platforms.
+
+## [1.6.6] - 2026-02-9
+
+- Desktop: redesigned the main workspace with a dedicated Git sidebar and bottom terminal dock, so Git and terminal actions stay in reach while chatting.
+- Desktop: added an `Open In` button to open the current workspace in Finder, Terminal, and supported editors with remembered app preference (thanks to @yulia-ivashko).
+- Header: combined Instance, Usage, and MCP into one services menu for faster access to runtime controls and rate limits while decluttering the header space.
+- Git: added push/pull with remote selection, plus in-app rebase/merge flows with improved remote inference and clearer conflict handling (thanks to @gsxdsm).
+- Git: reorganized the Git workspace with improved in-app PR workflows.
+- Files: improved editing with breadcrumbs, better draft handling, smoother editor interactions, and more reliable directory navigation from file context (thanks to @nelsonPires5).
+- Sessions: improved status behavior, faster mobile session switching with running/unread indicators, and clearer worktree labels when branch name differs (thanks to @Jovines, @gsxdsm).
+- Notifications: added smarter templates with concise summaries, so completion alerts are easier to scan (thanks to @gsxdsm).
+- Usage: added per-model quota breakdowns with collapsible groups, and fixed provider dropdown scrolling (thanks to @nelsonPires5, @gsxdsm).
+- Terminal: improved input responsiveness with a persistent low-latency transport for steadier typing (thanks to @shekohex).
+- Mobile: fixed chat input layout issues on small screens (thanks to @nelsonPires5).
+- Reliability: fixed OpenCode auth pass-through and proxy env handling to reduce intermittent connection/auth issues (thanks to @gsxdsm).
+
+
+## [1.6.5] - 2026-02-6
+
+- Settings: added an OpenCode CLI path override so you can point OpenChamber at a custom/local CLI install.
+- Chat: added arrow-key prompt history and an optional setting to persist input drafts between restarts (thanks to @gsxdsm).
+- Chat: thinking/reasoning blocks now render more consistently, and justification visibility settings now apply reliably (thanks to @gsxdsm).
+- Diff/Plans: added inline comment drafts so you can leave line-level notes and feed them back into requests (thanks to @nelsonPires5).
+- Sessions: you can now rename projects directly from the sidebar, and issue/PR pickers are easier to scan when starting from GitHub context (thanks to @shekohex, @gsxdsm).
+- Worktrees: improved worktree flow reliability, including cleaner handling when a worktree was already removed outside the app (thanks to @gsxdsm).
+- Terminal: improved Android keyboard behavior and removed distracting native caret blink in terminal inputs (thanks to @shekohex).
+- UI: added Vitesse Dark and Vitesse Light theme presets.
+- Reliability: improved OpenCode binary resolution and HOME-path handling across runtimes for steadier local startup.
+
+
+## [1.6.4] - 2026-02-5
+
+- Desktop: switch between local and remote OpenChamber instances, plus a thinner runtime for better feature parity and fewer desktop-only quirks.
+- VSCode: improved Windows PATH resolution and cold-start readiness checks to reduce "stuck loading" for sessions/models/agents.
+- Mobile: split Agent/Model controls and a quick commands button with autocomplete (Commands/Agents/Files) for easier input (thanks to @Jovines, @gsxdsm).
+- Chat: select text in messages to quickly add it to your prompt or start a new session (thanks to @gsxdsm).
+- Diff/Plans: add inline comment drafts so you can annotate specific lines and include those notes in requests (thanks to @nelsonPires5).
+- Terminal/Syntax: font size controls and Phoenix file extension support for better highlighting in files and diffs (thanks to @shekohex).
+- Usage: expanded quota tracking with more providers (including GitHub Copilot) and a provider selector dropdown (thanks to @gsxdsm, @nelsonPires5).
+- Git: improved macOS SSH agent support for smoother private-repo auth (thanks to @shekohex).
+- Web: fixed missing icon when installing the Android PWA (thanks to @nelsonPires5).
+- GitHub: PR description generation supports optional extra context for better summaries (thanks to @nelsonPires5).
+
+
+## [1.6.3] - 2026-02-2
+
+- Web: improved server readiness check to use the `/global/health` endpoint for more reliable startup detection.
+- Web: added login rate limit protection to prevent brute-force attempts on the authentication endpoint (thanks to @Jovines).
+- VSCode: improved server health check with the proper health API endpoint and increased timeout for steadier startup (thanks to @wienans).
+- Settings: dialog no longer persists open/closed state across app restarts.
+
+
+## [1.6.2] - 2026-02-1
+
+- Usage: new multi-provider quota dashboard to monitor API usage across OpenAI, Google, and z.ai (thanks to @nelsonPires5).
+- Settings: now opens in a windowed dialog on desktop with backdrop blur for better focus.
+- Terminal: added tabbed interface to manage multiple terminal sessions per directory.
+- Files: added multi-file tabs on desktop and dropdown selector on mobile (thanks to @nelsonPires5).
+- UI: introduced token-based theming system and 18 themes with light/dark variants; with support for custom user themes from `~/.config/openchamber/themes`.
+- Diff: optimized stacked view with worker-pool processing and lazy DOM rendering for smoother scrolling.
+- Worktrees: workspace path now resolves correctly when using git worktrees (thanks to @nelsonPires5).
+- Projects: fixed directory creation outside workspace in the Add Project modal (thanks to @nelsonPires5).
+
+
+## [1.6.1] - 2026-01-30
+
+- Chat: added Stop button to cancel generation mid-response.
+- Mobile: revamped chat controls on small screens with a unified controls drawer (thanks to @nelsonPires5).
+- UI: update dialog now includes the changelog so you can review what's new before updating.
+- Terminal: added optional on-screen key bar (Esc/Ctrl/arrows/Enter) for easier terminal navigation.
+- Notifications: added "Notify for subtasks" toggle to silence child-session notifications during multi-run (thanks to @Jovines).
+- Reliability: improved event-stream reconnection when the app becomes visible again.
+- Worktrees: starting new worktree sessions now defaults to HEAD when no start point is provided.
+- Git: commit message generation now includes untracked files and handles git diff --no-index comparisons more reliably (thanks to @MrLYC).
+- Desktop: improved macOS window chrome and header spacing, including steadier traffic lights on older macOS versions (thanks to @yulia-ivashko).
+
+
+## [1.6.0] - 2026-01-29
+
+- Chat: added message stall detection with automatic soft resync for more reliable message delivery.
+- Chat: fixed "Load older" button behavior in chat with proper pagination implementation.
+- Git: PR picker now validates local branch existence and includes a refresh action.
+- Git: worktree integration now syncs clean target directories before merging.
+- Diff: fixed memory leak when viewing many modified files; large changesets now lazy-load for smoother performance.
+- VSCode: session activity status now updates reliably even when the webview is hidden.
+- Web: session activity tracking now works consistently across browser tabs.
+- Reliability: plans directory no longer errors when missing.
+
+
+## [1.5.9] - 2026-01-28
+
+- Worktrees: migrated to Opencode SDK worktree implementation; sessions in worktrees are now completely isolated.
+- Git: integrate worktree commits back to a target branch with commit previews and guided conflict handling.
+- Files: toggle markdown preview when viewing files (thanks to @Jovines).
+- Files: open the file viewer in fullscreen for focused review and editing (thanks to @TaylorBeeston).
+- Plans: switch between markdown preview and edit mode in the Plan view.
+- UI: Files, Diff, Git, and Terminal now follow the active session/worktree directory, including new-session drafts.
+- Web: plan lists no longer error when the plans directory is missing.
+
+
+## [1.5.8] - 2026-01-26
+
+- Plans: new Plan/Build mode switching support with dedicated Plan content view with per-session context.
+- GitHub: sign in with multiple accounts and smoother auth flow.
+- Chat/UI: linkable mentions, better wrapping, and markdown/scroll polish in messages.
+- Skills: ClawdHub catalog now pages results and retries transient failures.
+- Diff: fixed Chrome scrolling in All Files layout.
+- Mobile: improved layout for attachments, git, and permissions on small screens (thanks to @nelsonPires5).
+- Web: iOS safe-area support for the PWA header.
+- Activity: added a text-justification setting for activity summaries (thanks to @iyangdianfeng).
+- Reliability: file lists and message sends handle missing directories and transient errors more gracefully.
+
+
+## [1.5.7] - 2026-01-24
+
+- GitHub: PR panel supports fork PR detection by branch name.
+- GitHub: Git tab PR panel can send failed checks/comments to chat with hidden context; added check details dialog with Actions step breakdown.
+- Web: GitHub auth flow fixes.
+
+
+## [1.5.6] - 2026-01-24
+
+- GitHub: connect your account in Settings with device-flow auth to enable GitHub tools.
+- Sessions: start new sessions from GitHub issues with seeded context (title, body, labels, comments).
+- Sessions: start new sessions from GitHub pull requests with PR context baked in (including diffs).
+- Git: manage pull requests in the Git view with AI-generated descriptions, status checks, ready-for-review, and merge actions.
+- Mobile: fixed CommandAutocomplete dropdown scrolling (thanks to @nelsonPires5).
+
+
+## [1.5.5] - 2026-01-23
+
+- Navigation: URLs now sync the active session, tab, settings, and diff state for shareable links and reliable back/forward (thanks to @TaylorBeeston).
+- Settings: agent and command overrides now prefer plural directories while still honoring legacy singular folders.
+- Skills: installs now target plural directories while still recognizing legacy singular folders.
+- Web: push notifications no longer fire when a window is visible, avoiding duplicate alerts.
+- Web: improved push subscription handling across multiple windows for more reliable delivery.
+
+
+## [1.5.4] - 2026-01-22
+
+- Chat: new Apply Patch tool UI with diff preview for patch-based edits.
+- Files: refreshed attachment cards and related file views for clearer context.
+- Settings: manage provider configuration files directly from the UI.
+- UI: updated header and sidebar layout for a cleaner, tighter workspace fit (thanks to @TheRealAshik).
+- Diff: large diffs now lazy-load to avoid freezes (thanks to @Jovines).
+- Web: added Background notifications for PWA.
+- Reliability: connect to external OpenCode servers without auto-start and fixed subagent crashes (thanks to @TaylorBeeston).
+
+
+## [1.5.3] - 2026-01-20
+
+- Files: edit files inline with syntax highlighting, draft protection, and save/discard flow.
+- Files: toggles to show hidden/dotfiles and gitignored entries in file browsers and pickers (thanks to @syntext).
+- Settings: new memory limits controls for session message history.
+- Chat: smoother session switching with more stable scroll anchoring.
+- Chat: new Activity view in collapsed state, now shows latest 6 tools by default.
+- Chat: fixed message copy on Firefox for macOS (thanks to @syntext).
+- Appearance: new corner radius control and restored input bar offset setting (thanks to @TheRealAshik).
+- Git: generated commit messages now auto-pick a gitmoji when enabled (thanks to @TheRealAshik).
+- Performance: faster filesystem/search operations and general stability improvements (thanks to @TheRealAshik).
+
+
+## [1.5.2] - 2026-01-17
+
+- Sessions: added branch picker dialog to start new worktree sessions from local branches (thanks to @nilskroe).
+- Sessions: added project header worktree button, active-session loader, and right-click context menu in the sessions sidebar (thanks to @nilskroe).
+- Sessions: improved worktree delete dialog with linked session details, dirty-change warnings, and optional remote branch removal.
+- Git: added gitmoji picker in commit message composer with cached emoji list (thanks to @TaylorBeeston).
+- Chat: optimized message loading for opening sessions.
+- UI: added one-click diagnostics copy in the About dialog.
+- VSCode: tuned layout breakpoint and server readiness timeout for steadier startup.
+- Reliability: improved OpenCode process cleanup to reduce orphaned servers.
+
+
+## [1.5.1] - 2026-01-16
+
+- Desktop: fixed orphaned OpenCode processes not being cleaned up on restart or exit.
+- Opencode: fixed issue with reloading configuration was killing the app
+
+
+## [1.5.0] - 2026-01-16
+
+- UI: added a new Files tab to browse workspace files directly from the interface.
+- Diff: enhanced the diff viewer with mobile support and the ability to ask the agent for comments on changes.
+- Git Identities: added "default identity" setting with one-click set/unset and automatic local identity detection.
+- VSCode: improved server management to ensure it initializes within the workspace directory with context-aware readiness checks.
+- VSCode: added responsive layout with sessions sidebar + chat side-by-side when wide, compact header, and streamlined settings.
+- Web/VSCode: fixed orphaned OpenCode processes not being cleaned up on restart or exit.
+- Web: the server now automatically resolves and uses an available port if the default is occupied.
+- Stability: fixed heartbeat race condition causing session stalls during long tasks (thanks to @tybradle).
+- Desktop: fixed commands for worktree setup access to PATH.
+
+
+## [1.4.9] - 2026-01-14
+
+- VSCode: added session editor panel to view sessions alongside files.
+- VSCode: improved server connection reliability with multiple URL candidate support.
+- Diff: added stacked/inline diff mode toggle in settings with sidebar file navigation (thanks to @nelsonPires5).
+- Mobile: fixed iOS keyboard safe area padding for home indicator bar (thanks to @Jovines).
+- Upload: increased attachment size limit to 50MB with automatic image compression to 2048px for large files.
+
+
+## [1.4.8] - 2026-01-14
+
+- Git Identities: added token-based authentication support with ~/.git-credentials discovery and import.
+- Settings: consolidated Git settings and added opencode zen model selection for commit generation (thanks to @nelsonPires5).
+- Web Notifications: added configurable native web notifications for assistant completion (thanks to @vio1ator).
+- Chat: sidebar sessions are now automatically sorted by last updated date (thanks to @vio1ator).
+- Chat: fixed edit tool output and added turn duration.
+- UI: todo lists and status indicators now hide automatically when all tasks are completed (thanks to @vio1ator).
+- Reliability: improved project state preservation on validation failures (thanks to @vio1ator) and refined server health monitoring.
+- Stability: added graceful shutdown handling for the server process (thanks to @vio1ator).
+
+
+## [1.4.7] - 2026-01-10
+
+- Skills: added ClawdHub integration as built-in market for skills.
+- Web: fixed issues in terminal
+
+
+## [1.4.6] - 2026-01-09
+
+- VSCode/Web: switch opencode cli management to SDK.
+- Input: removed auto-complete and auto-correction.
+- Shortcuts: switched agent cycling shortcut from Shift + TAB to TAB again.
+- Chat: added question tool support with a rich UI for interaction.
+
+
+## [1.4.5] - 2026-01-08
+
+- Chat: added support for model variants (thinking effort).
+- Shortcuts: Switched agent cycling shortcut from TAB to Shift + TAB.
+- Skills: added autocomplete for skills on "/" when it is not the first character in input.
+- Autocomplete: added scope badges for commands/agents/skills.
+- Compact: changed /summarize command to be /compact and use sdk for compaction.
+- MCP: added ability to dynamically enabled/disabled configured MCP.
+- Web: refactored project adding UI with autocomplete.
+
+
+## [1.4.4] - 2026-01-08
+
+- Agent Manager / Multi Run: select agent per worktree session (thanks to @wienans).
+- Agent Manager / Multi Run: worktree actions to delete group or individual worktrees, or keep only selected one (thanks to @wienans).
+- Agent Manager: added "Copy Worktree Path" action in the more menu (thanks to @wienans).
+- Worktrees: added session creation flow with loading screen, auto-create worktree setting, and setup commands management.
+- Session sidebar: refactoring with unified view for sessions in worktrees.
+- Settings: added ability to create new session in worktree by default
+- Git view: added branch rename for worktree.
+- Chat: fixed IME composition for CJK input to prevent accidental send (thanks to @madebyjun).
+- Projects: added multi-project support with per-project settings for agents/commands/skills.
+- Event stream: improved SSE with heartbeat management, permission bootstrap on connect, and reconnection logic.
+- Tunnel: added QR code and password URL for Cloudflare tunnel (thanks to @martindonadieu).
+- Model selector: fixed dropdowns not responding to viewport size.
+
+
+## [1.4.3] - 2026-01-04
+
+- VS Code extension: added Agent Manager panel to run the same prompt across up to 5 models in parallel (thanks to @wienans).
+- Added permission prompt UI for tools configured with "ask" in opencode.json, showing requested patterns and "Always Allow" options (thanks to @aptdnfapt).
+- Added "Open subAgent session" button on task tool outputs to quickly navigate to child sessions (thanks to @aptdnfapt).
+- VS Code extension: improved activation reliability and error handling.
+
+
+## [1.4.2] - 2026-01-02
+
+- Added timeline dialog (`/timeline` command or Cmd/Ctrl+T) for navigating, reverting, and forking from any point in the conversation (thanks to @aptdnfapt).
+- Added `/undo` and `/redo` commands for reverting and restoring messages in a session (thanks to @aptdnfapt).
+- Added fork button on user messages to create a new session from any point (thanks to @aptdnfapt).
+- Desktop app: keyboard shortcuts now use Cmd on macOS and Ctrl on web/other platforms (thanks to @sakhnyuk).
+- Migrated to OpenCode SDK v2 with improved API types and streaming.
+
+
+## [1.4.1] - 2026-01-02
+
+- Added the ability to select the same model multiple times in multi-agent runs for response comparison.
+- Model selector now includes search and keyboard navigation for faster model selection.
+- Added revert button to all user messages (including first one).
+- Added HEIC image support for file attachments with automatic MIME type normalization for text format files.
+- VS Code extension: added git backend integration for UI to access (thanks to @wienans).
+- VS Code extension: Only show the main Worktree in the Chat Sidebar (thanks to @wienans).
+- Web app: terminal backend now supports a faster Bun-based PTY when Bun is available, with automatic fallback for existing Node-only setups.
+- Terminal: improved terminal performance and stability by switching to the Ghostty-based terminal renderer, while keeping the existing terminal UX and per-directory sessions.
+- Terminal: fixed several issues with terminal session restore and rendering under heavy output, including switching directories and long-running TUI apps.
+
+
+## [1.4.0] - 2026-01-01
+
+- Added the ability to run multiple agents from a single prompt, with each agent working in an isolated worktree.
+- Git view: improved branch publishing by detecting unpublished commits and automatically setting the upstream on first push.
+- Worktrees: new branch creation can start from a chosen base; remote branches are only created when you push.
+- VS Code extension: default location is now the right secondary sidebar in VS Code, and the left activity bar in Cursor/Windsurf; navigation moved into the title bar (thanks to @wienans).
+- Web app: added Cloudflare Quick Tunnel support for simpler remote access (thanks to @wojons and @aptdnfapt).
+- Mobile: improved keyboard/input bar behavior (including Android fixes and better keyboard avoidance) and added an offset setting for curved-screen devices (thanks to @auroraflux).
+- Chat: now shows clearer error messages when agent messages fail.
+- Sidebar: improved readability for sticky headers with a dynamic background.
+
+
+## [1.3.9] - 2025-12-30
+
+ - Added skills management to settings with the ability to create, edit, and delete skills (make sure you have the latest OpenCode version for skills support).
+- Added Skills catalog functionality for discovering and installing skills from external sources.
+- VS Code extension: added right-click context menu with "Add to Context," "Explain," and "Improve Code" actions (thanks to @wienans).
+
+
+## [1.3.8] - 2025-12-29
+
+- Added Intel Mac (x86_64) support for the desktop application (thanks to @rothnic).
+- Build workflow now generates separate builds for Apple Silicon (arm64) and Intel (x86_64) Macs (thanks to @rothnic).
+- Improved dev server HMR by reusing a healthy OpenCode process to avoid zombie instances.
+- Added queued message mode with chips, batching, and idle auto‑send (including attachments).
+- Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes.
+- Fixed scroll position persistence for active conversation turns across session switches.
+- Refactored Agents/Commands management with ability to configure project/user scopes.
+
+
+## [1.3.7] - 2025-12-28
+
+- Redesigned Settings as a full-screen view with tabbed navigation.
+- Added mobile-friendly drill-down navigation for settings.
+- ESC key now closes settings; double-ESC abort only works on chat tab without overlays.
+- Added responsive tab labels in settings header (icons only at narrow widths).
+- Improved session activity status handling and message step completion logic.
+- Introduced enchanced VSCode extension settings with dynamic layout based on width.
+
+
+## [1.3.6] - 2025-12-27
+
+- Added the ability to manage (connect/disconnect) providers in settings.
+- Adjusted auto-summarization visuals in chat.
+
+
+## [1.3.5] - 2025-12-26
+
+- Added Nushell support for operations with Opencode CLI.
+- Improved file search with fuzzy matching capabilities.
+- Enhanced mobile responsiveness in chat controls.
+- Fixed workspace switching performance and API health checks.
+- Improved provider loading reliability during workspace switching.
+- Fixed session handling for non-existent worktree directories.
+- Added Discord links in the about section.
+- Added settings for choosing the default model/agent to start with in a new session.
+
+
+## [1.3.4] - 2025-12-25
+
+- Diff view now loads reliably even with large files and slow networks.
+- Fixed getting diffs for worktree files.
+- VS Code extension: improved type checking and editor integration.
+
+
+## [1.3.3] - 2025-12-25
+
+- Updated OpenCode SDK to 1.0.185 across all app versions.
+- VS Code extension: fixed startup, more reliable OpenCode CLI/API management, and stabilized API proxying/streaming.
+- VS Code extension: added an animated loading screen and introduced command for status/debug output.
+- Fixed session activity tracking so it correctly handles transitions through states (including worktree sessions).
+- Fixed directory path handling (including `~` expansion) to prevent invalid paths and related Git/worktree errors.
+- Chat UI: improved turn grouping/activity rendering and fixed message metadata/agent selection propagation.
+- Chat UI: improved agent activity status behavior and reduced image thumbnail sizes for better readability.
+
+
+## [1.3.2] - 2025-12-22
+
+- Fixed new bug session when switching directories
+- Updated Opencode SDK to the latest version
+
+
+## [1.3.1] - 2025-12-22
+
+- New chats no longer create a session until you send your first message.
+- The app opens to a new chat by default.
+- Fixed mobile and VSCode sessions handling
+- Updated app identity with new logo and icons across all platforms.
+
+
+## [1.3.0] - 2025-12-21
+
+- Added revert functionality in chat for user messages.
+- Polished mobile controls in chat view.
+- Updated user message layout/styling.
+- Improved header tab responsiveness.
+- Fixed bugs with new session creation when the VSCode extension initialized for the first time.
+- Adjusted VSCode extension theme mapping and model selection view.
+- Polished file autocomplete experience.
+
+
+## [1.2.9] - 2025-12-20
+
+- Session auto‑cleanup feature with configurable retention for each app version including VSCode extension.
+- Ability to update web package from mobile/PWA view in setting.
+- A lot of different optimization for a long sessions.
+
+
+## [1.2.8] - 2025-12-19
+
+- Introduced update mechanism for web version that doesn't need any cli interaction.
+- Added installation script for web version with package managed detection.
+- Update and restart of web server now support automatic pick-up of previously set parameters like port or password.
+
+
+## [1.2.7] - 2025-12-19
+
+- Comprehensive macOS native menu bar entries.
+- Redesigned directory selection view for web/mobile with improved layout.
+- Improved theme consistency across dropdown menus, selects, and command palette.
+- Introduced keyboard shortcuts help menu and quick actions menu.
+
+
+## [1.2.6] - 2025-12-19
+
+- Added write/create tool preview in permission cards with syntax highlighting.
+- More descriptive assistant status messages with tool-specific and varied idle phrases.
+- Polished Git view layout
+
+
+## [1.2.5] - 2025-12-19
+
+- Polished chat expirience for longer session.
+- Fixed file link from git view to diff.
+- Enhancements to the inactive state management of the desktop app.
+- Redesigned Git tab layout with improved organization.
+- Fixed untracked files in new directories not showing individually.
+- Smoother session rename experience.
+
+
+## [1.2.4] - 2025-12-18
+
+- MacOS app menu entries for Check for update and for creating bug/request in Help section.
+- For Mobile added settings, improved terminal scrolling, fixed app layout positioning.
+
+
+## [1.2.3] - 2025-12-17
+
+- Added image preview support in Diff tab (shows original/modified images instead of base64 code).
+- Improved diff view visuals and alligned style among different widgets.
+- Optimized git polling and background diff+syntax pre-warm for instant Diff tab open.
+- Optomized reloading unaffected diffs.
+
+
+## [1.2.2] - 2025-12-17
+
+- Agent Task tool now renders progressively with live duration and completed sub-tools summary.
+- Unified markdown rendering between assistant messages and tool outputs.
+- Reduced markdown header sizes for better visual balance.
+
+
+## [1.2.1] - 2025-12-16
+
+- Todo task tracking: collapsible status row showing AI's current task and progress.
+- Switched "Detailed" tool output mode to only open the 'task', 'edit', 'multiedit', 'write', 'bash' tools for better performance.
+
+
+## [1.2.0] - 2025-12-15
+
+- Favorite & recent models for quick access in model selection.
+- Tool call expansion settings: collapsed, activity, or detailed modes.
+- Font size & spacing controls (50-200% scaling) in Appearance Settings.
+- Settings page access within VSCode extension.
+Thanks to @theblazehen for contributing these features!
+
+
+## [1.1.6] - 2025-12-15
+
+- Optimized diff view layout with smaller fonts and compact hunk separators.
+- Improved mobile experience: simplified header, better diff file selector.
+- Redesigned password-protected session unlock screen.
+
+
+## [1.1.5] - 2025-12-15
+
+- Enhanced file attachment features performance.
+- Added fuzzy search feature for file mentioning with @ in chat.
+- Optimized input area layout.
+
+
+## [1.1.4] - 2025-12-15
+
+- Flexoki themes for Shiki syntax highlighting for consistency with the app color schema.
+- Enchanced VSCode extension theming with editor themes.
+- Fixed mobile view model/agent selection.
+
+
+## [1.1.3] - 2025-12-14
+
+- Replaced Monaco diff editor with Pierre/diffs for better performance.
+- Added line wrap toggle in diff view with dynamic layout switching (auto-inline when narrow).
+
+
+## [1.1.2] - 2025-12-13
+
+- Moved VS Code extension to activity bar (left sidebar).
+- Added feedback messages for "Restart API Connection" command.
+- Removed redundant VS Code commands.
+- Enhanced UserTextPart styling.
+
+
+## [1.1.1] - 2025-12-13
+
+- Adjusted model/agent selection alignment.
+- Fixed user message rendering issues.
+
+
+## [1.1.0] - 2025-12-13
+
+- Added assistant answer fork flow so users can start a new session from an assistant plan/response with inherited context.
+- Added OpenChamber VS Code extension with editor integration: file picker, click-to-open in tool parts.
+- Improved scroll performance with force flag and RAF placeholder.
+- Added git polling backoff optimization.
+
+
+## [1.0.9] - 2025-12-08
+
+- Added directory picker on first launch to reduce macOS permission prompts.
+- Show changelog in update dialog from current to new version.
+- Improved update dialog UI with inline version display.
+- Added macOS folder access usage descriptions.
+
+
+## [1.0.8] - 2025-12-08
+
+- Added fallback detection for OpenCode CLI in ~/.opencode/bin.
+- Added window focus after app restart/update.
+- Adapted traffic lights position and corner radius for older macOS versions.
+
+
+## [1.0.7] - 2025-12-08
+
+- Optimized Opencode binary detection.
+- Adjusted app update experience.
+
+
+## [1.0.6] - 2025-12-08
+
+- Enhance shell environment detection.
+
+
+## [1.0.5] - 2025-12-07
+
+- Fixed "Load older messages" incorrectly scrolling to bottom.
+- Fixed page refresh getting stuck on splash screen.
+- Disabled devtools and page refresh in production builds.
+
+
+## [1.0.4] - 2025-12-07
+
+- Optimized desktop app start time
+
+
+## [1.0.3] - 2025-12-07
+
+- Updated onboarding UI.
+- Updated sidebar styles.
+
+
+## [1.0.2] - 2025-12-07
+
+- Updated MacOS window design to the latest one.
+
+
+## [1.0.1] - 2025-12-07
+
+- Initial public release of OpenChamber web and desktop packages in a unified monorepo.
+- Added GitHub Actions release pipeline with macOS signing/notarization, npm publish, and release asset uploads.
+- Introduced OpenCode agent chat experience with section-based navigation, theming, and session persistence.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..95b0b7f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,92 @@
+# Contributing to OpenChamber
+
+## Getting Started
+
+```bash
+git clone https://github.com/btriapitsyn/openchamber.git
+cd openchamber
+bun install
+```
+
+## Dev Scripts
+
+### Web
+
+| Script | Description | Ports |
+|--------|-------------|-------|
+| `bun run dev:web:full` | Build watcher + Express server. No HMR — manual refresh after changes. | `3001` (server + static) |
+| `bun run dev:web:hmr` | Vite dev server + Express API. **Open the Vite URL for HMR**, not the backend. | `5180` (Vite HMR), `3902` (API) |
+
+Both are configurable via env vars: `OPENCHAMBER_PORT`, `OPENCHAMBER_HMR_UI_PORT`, `OPENCHAMBER_HMR_API_PORT`.
+
+### Desktop (Tauri)
+
+```bash
+bun run desktop:dev
+```
+
+Launches Tauri in dev mode with WebView devtools enabled and a distinct dev icon.
+
+### VS Code Extension
+
+```bash
+bun run vscode:dev # Watch mode (extension + webview rebuild on save)
+```
+
+To test in VS Code:
+```bash
+bun run vscode:build && code --extensionDevelopmentPath="$(pwd)/packages/vscode"
+```
+
+### Shared UI (`packages/ui`)
+
+No dev server — this is a source-level library consumed by other packages. During development, `bun run dev` runs type-checking in watch mode.
+
+## Before Submitting
+
+```bash
+bun run type-check # Must pass
+bun run lint # Must pass
+bun run build # Must succeed
+```
+
+## Code Style
+
+- Functional React components only
+- TypeScript strict mode — no `any` without justification
+- Use existing theme colors/typography from `packages/ui/src/lib/theme/` — don't add new ones
+- Components must support light and dark themes
+- Prefer early returns and `if/else`/`switch` over nested ternaries
+- Tailwind v4 for styling; typography via `packages/ui/src/lib/typography.ts`
+
+## Pull Requests
+
+1. Fork and create a branch
+2. Make changes
+3. Run the validation commands above
+4. Submit PR with clear description of what and why
+
+## Project Structure
+
+```
+packages/
+ ui/ Shared React components, hooks, stores, and theme system
+ web/ Web server (Express) + frontend (Vite) + CLI
+ desktop/ Tauri macOS app (thin shell around the web UI)
+ vscode/ VS Code extension (extension host + webview)
+```
+
+See [AGENTS.md](./AGENTS.md) for detailed architecture reference.
+
+## Not a developer?
+
+You can still help:
+
+- Report bugs or UX issues — even "this felt confusing" is valuable feedback
+- Test on different devices, browsers, or OS versions
+- Suggest features or improvements via issues
+- Help others in Discord
+
+## Questions?
+
+Open an [issue](https://github.com/btriapitsyn/openchamber/issues) or ask in [Discord](https://discord.gg/ZYRSdnwwKA).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6a96217
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Bohdan Triapitsyn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..3c129b6
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,32 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability in OpenChamber, please report it responsibly.
+
+**Email:** [artmore@protonmail.com](mailto:artmore@protonmail.com)
+
+Please include:
+- Description of the vulnerability
+- Steps to reproduce
+- Affected version(s)
+- Potential impact
+
+I'll acknowledge receipt within 48 hours and aim to provide a fix or mitigation as quickly as possible.
+
+**Please do not open public GitHub issues for security vulnerabilities.**
+
+## Scope
+
+OpenChamber handles sensitive context including:
+- UI authentication (password-protected sessions, JWT tokens)
+- Cloudflare tunnel access (remote connectivity)
+- Terminal access (PTY sessions)
+- Git credentials and SSH keys
+- File system operations
+
+Security reports related to any of these areas are especially appreciated.
+
+## Supported Versions
+
+Security fixes are applied to the latest release. There is no LTS or backport policy at this time.
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..2de16d1
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,2346 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "openchamber-monorepo",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.20.0",
+ "@codemirror/commands": "^6.10.1",
+ "@codemirror/lang-cpp": "^6.0.3",
+ "@codemirror/lang-css": "^6.3.1",
+ "@codemirror/lang-go": "^6.0.1",
+ "@codemirror/lang-html": "^6.4.11",
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@codemirror/lang-json": "^6.0.2",
+ "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/lang-python": "^6.2.1",
+ "@codemirror/lang-rust": "^6.0.2",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/lang-xml": "^6.1.0",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.12.1",
+ "@codemirror/language-data": "^6.5.2",
+ "@codemirror/legacy-modes": "^6.5.2",
+ "@codemirror/lint": "^6.9.2",
+ "@codemirror/search": "^6.6.0",
+ "@codemirror/state": "^6.5.4",
+ "@codemirror/view": "^6.39.13",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@fontsource/ibm-plex-mono": "^5.2.7",
+ "@fontsource/ibm-plex-sans": "^5.1.1",
+ "@heroui/scroll-shadow": "^2.3.18",
+ "@heroui/system": "^2.4.23",
+ "@heroui/theme": "^2.4.23",
+ "@ibm/plex": "^6.4.1",
+ "@lezer/highlight": "^1.2.3",
+ "@octokit/rest": "^22.0.1",
+ "@opencode-ai/sdk": "^1.2.20",
+ "@pierre/diffs": "1.1.0-beta.13",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@remixicon/react": "^4.7.0",
+ "@streamdown/code": "^1.0.2",
+ "@tanstack/react-virtual": "^3.13.18",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "adm-zip": "^0.5.16",
+ "beautiful-mermaid": "^1.1.3",
+ "bun-pty": "^0.4.5",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "codemirror-lang-elixir": "^4.0.0",
+ "electron-context-menu": "^4.1.1",
+ "electron-store": "^11.0.2",
+ "express": "^5.1.0",
+ "fuse.js": "^7.1.0",
+ "ghostty-web": "^0.4.0",
+ "heic2any": "^0.0.4",
+ "html-to-image": "^1.11.13",
+ "http-proxy-middleware": "^3.0.5",
+ "jose": "^6.1.3",
+ "jsonc-parser": "^3.3.1",
+ "motion": "^12.23.24",
+ "next-themes": "^0.4.6",
+ "node-pty": "^1.1.0",
+ "openai": "^4.79.0",
+ "prismjs": "^1.30.0",
+ "qrcode": "^1.5.4",
+ "qrcode-terminal": "^0.12.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-markdown": "^10.1.0",
+ "react-syntax-highlighter": "^15.6.6",
+ "remark-gfm": "^4.0.1",
+ "simple-git": "^3.28.0",
+ "sonner": "^2.0.7",
+ "streamdown": "^2.2.0",
+ "strip-json-comments": "^5.0.3",
+ "tailwind-merge": "^3.3.1",
+ "web-push": "^3.6.7",
+ "ws": "^8.18.3",
+ "yaml": "^2.8.1",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.8",
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@tailwindcss/postcss": "^4.0.0",
+ "@tauri-apps/api": "^2.9.0",
+ "@types/adm-zip": "^0.5.7",
+ "@types/dom-speech-recognition": "^0.0.7",
+ "@types/node": "^24.3.1",
+ "@types/prismjs": "^1.26.6",
+ "@types/qrcode": "^1.5.5",
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react": "^5.0.0",
+ "autoprefixer": "^10.4.21",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "concurrently": "^9.2.1",
+ "cors": "^2.8.5",
+ "cross-env": "^7.0.3",
+ "electron": "^38.2.0",
+ "electron-builder": "^24.13.3",
+ "eslint": "^9.33.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "nodemon": "^3.1.7",
+ "patch-package": "^8.0.0",
+ "playwright": "^1.58.2",
+ "tailwindcss": "^4.0.0",
+ "tsx": "^4.20.6",
+ "tw-animate-css": "^1.3.8",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.39.1",
+ "vite": "^7.1.2",
+ },
+ },
+ },
+ "overrides": {
+ "@codemirror/language": "6.12.2",
+ "@codemirror/view": "6.39.13",
+ },
+ "packages": {
+ "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
+
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
+
+ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
+
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
+
+ "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
+
+ "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
+
+ "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
+
+ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+
+ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
+
+ "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
+
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+
+ "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
+
+ "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
+
+ "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="],
+
+ "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="],
+
+ "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
+
+ "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="],
+
+ "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
+
+ "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="],
+
+ "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
+
+ "@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.0", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw=="],
+
+ "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
+
+ "@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="],
+
+ "@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw=="],
+
+ "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="],
+
+ "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="],
+
+ "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="],
+
+ "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="],
+
+ "@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="],
+
+ "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="],
+
+ "@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="],
+
+ "@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="],
+
+ "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
+
+ "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="],
+
+ "@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="],
+
+ "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
+
+ "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="],
+
+ "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="],
+
+ "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="],
+
+ "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
+
+ "@codemirror/view": ["@codemirror/view@6.39.13", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw=="],
+
+ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
+
+ "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
+
+ "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
+
+ "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
+
+ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
+
+ "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
+
+ "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
+
+ "@electron/notarize": ["@electron/notarize@2.2.1", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg=="],
+
+ "@electron/osx-sign": ["@electron/osx-sign@1.0.5", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww=="],
+
+ "@electron/universal": ["@electron/universal@1.5.1", "", { "dependencies": { "@electron/asar": "^3.2.1", "@malept/cross-spawn-promise": "^1.1.0", "debug": "^4.3.1", "dir-compare": "^3.0.0", "fs-extra": "^9.0.1", "minimatch": "^3.0.4", "plist": "^3.0.4" } }, "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
+
+ "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
+
+ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
+
+ "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
+
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+
+ "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="],
+
+ "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="],
+
+ "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
+
+ "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
+
+ "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
+
+ "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
+
+ "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
+
+ "@heroui/react-rsc-utils": ["@heroui/react-rsc-utils@2.1.9", "", { "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } }, "sha512-e77OEjNCmQxE9/pnLDDb93qWkX58/CcgIqdNAczT/zUP+a48NxGq2A2WRimvc1uviwaNL2StriE2DmyZPyYW7Q=="],
+
+ "@heroui/react-utils": ["@heroui/react-utils@2.1.14", "", { "dependencies": { "@heroui/react-rsc-utils": "2.1.9", "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } }, "sha512-hhKklYKy9sRH52C9A8P0jWQ79W4MkIvOnKBIuxEMHhigjfracy0o0lMnAUdEsJni4oZKVJYqNGdQl+UVgcmeDA=="],
+
+ "@heroui/scroll-shadow": ["@heroui/scroll-shadow@2.3.19", "", { "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-data-scroll-overflow": "2.2.13" }, "peerDependencies": { "@heroui/system": ">=2.4.18", "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "sha512-y5mdBlhiITVrFnQTDqEphYj7p5pHqoFSFtVuRRvl9wUec2lMxEpD85uMGsfL8OgQTKIAqGh2s6M360+VJm7ajQ=="],
+
+ "@heroui/shared-utils": ["@heroui/shared-utils@2.1.12", "", {}, "sha512-0iCnxVAkIPtrHQo26Qa5g0UTqMTpugTbClNOrEPsrQuyRAq7Syux998cPwGlneTfB5E5xcU3LiEdA9GUyeK2cQ=="],
+
+ "@heroui/system": ["@heroui/system@2.4.28", "", { "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/system-rsc": "2.3.24", "@react-aria/i18n": "3.12.16", "@react-aria/overlays": "3.31.2", "@react-aria/utils": "3.33.1" }, "peerDependencies": { "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "sha512-agtiiMFsCLaBrGWWrGBlFrnwi06ym4uouh61c3/m16CHuYfGm0Hx5oJ1mZVF98SJ91mDyU3e6Rv+8mqV9XVgoQ=="],
+
+ "@heroui/system-rsc": ["@heroui/system-rsc@2.3.24", "", { "dependencies": { "@react-types/shared": "3.33.1" }, "peerDependencies": { "@heroui/theme": ">=2.4.24", "react": ">=18 || >=19.0.0-rc.0" } }, "sha512-GEP3hh7j8wE56WdSAIdCcPQOMDdAYk9bSuQpGzXnkYSiSCJKroOyXbRmp9KF2VgpiMXGI0OlO51aj9JWoa+5Tg=="],
+
+ "@heroui/theme": ["@heroui/theme@2.4.26", "", { "dependencies": { "@heroui/shared-utils": "2.1.12", "color": "^4.2.3", "color2k": "^2.0.3", "deepmerge": "4.3.1", "tailwind-merge": "3.4.0", "tailwind-variants": "3.2.2" }, "peerDependencies": { "tailwindcss": ">=4.0.0" } }, "sha512-TYatChq7YyGDcPJytgOMqQwK72qWYb+vIa7mLmX3Cu9+JzFs2VSHu2QqzdhnOHoK0uJr8giDMy0gvJEDuu31vw=="],
+
+ "@heroui/use-data-scroll-overflow": ["@heroui/use-data-scroll-overflow@2.2.13", "", { "dependencies": { "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } }, "sha512-zboLXO1pgYdzMUahDcVt5jf+l1jAQ/D9dFqr7AxWLfn6tn7/EgY0f6xIrgWDgJnM0U3hKxVeY13pAeB4AFTqTw=="],
+
+ "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
+
+ "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="],
+
+ "@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
+
+ "@internationalized/message": ["@internationalized/message@3.1.8", "", { "dependencies": { "@swc/helpers": "^0.5.0", "intl-messageformat": "^10.1.0" } }, "sha512-Rwk3j/TlYZhn3HQ6PyXUV0XP9Uv42jqZGNegt0BXlxjE6G3+LwHjbQZAGHhCnCPdaA6Tvd3ma/7QzLlLkJxAWA=="],
+
+ "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
+
+ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="],
+
+ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
+
+ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
+
+ "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
+
+ "@lezer/cpp": ["@lezer/cpp@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw=="],
+
+ "@lezer/css": ["@lezer/css@1.3.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg=="],
+
+ "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="],
+
+ "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
+
+ "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
+
+ "@lezer/java": ["@lezer/java@1.1.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw=="],
+
+ "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
+
+ "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
+
+ "@lezer/lr": ["@lezer/lr@1.4.8", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA=="],
+
+ "@lezer/markdown": ["@lezer/markdown@1.6.3", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw=="],
+
+ "@lezer/php": ["@lezer/php@1.0.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA=="],
+
+ "@lezer/python": ["@lezer/python@1.1.18", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="],
+
+ "@lezer/rust": ["@lezer/rust@1.0.2", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg=="],
+
+ "@lezer/sass": ["@lezer/sass@1.1.0", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="],
+
+ "@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="],
+
+ "@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
+
+ "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
+
+ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
+
+ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
+
+ "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
+
+ "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
+
+ "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
+
+ "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
+
+ "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
+
+ "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
+
+ "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
+
+ "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
+
+ "@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="],
+
+ "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
+
+ "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
+
+ "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
+
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.24", "", {}, "sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg=="],
+
+ "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
+
+ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+
+ "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
+
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
+
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
+
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
+
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
+
+ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
+
+ "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+
+ "@react-aria/focus": ["@react-aria/focus@3.21.5", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q=="],
+
+ "@react-aria/i18n": ["@react-aria/i18n@3.12.16", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@internationalized/message": "^3.1.8", "@internationalized/number": "^3.6.5", "@internationalized/string": "^3.2.7", "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Km2CAz6MFQOUEaattaW+2jBdWOHUF8WX7VQoNbjlqElCP58nSaqi9yxTWUDRhAcn8/xFUnkFh4MFweNgtrHuEA=="],
+
+ "@react-aria/interactions": ["@react-aria/interactions@3.27.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-stately/flags": "^3.1.2", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw=="],
+
+ "@react-aria/overlays": ["@react-aria/overlays@3.31.2", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-stately/flags": "^3.1.2", "@react-stately/overlays": "^3.6.23", "@react-types/button": "^3.15.1", "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-78HYI08r6LvcfD34gyv19ArRIjy1qxOKuXl/jYnjLDyQzD4pVb634IQWcm0zt10RdKgyuH6HTqvuDOgZTLet7Q=="],
+
+ "@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="],
+
+ "@react-aria/utils": ["@react-aria/utils@3.33.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w=="],
+
+ "@react-aria/visually-hidden": ["@react-aria/visually-hidden@3.8.31", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-RTOHHa4n56a9A3criThqFHBifvZoV71+MCkSuNP2cKO662SUWjqKkd0tJt/mBRMEJPkys8K7Eirp6T8Wt5FFRA=="],
+
+ "@react-stately/flags": ["@react-stately/flags@3.1.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg=="],
+
+ "@react-stately/overlays": ["@react-stately/overlays@3.6.23", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@react-types/overlays": "^3.9.4", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-RzWxots9A6gAzQMP4s8hOAHV7SbJRTFSlQbb6ly1nkWQXacOSZSFNGsKOaS0eIatfNPlNnW4NIkgtGws5UYzfw=="],
+
+ "@react-stately/utils": ["@react-stately/utils@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw=="],
+
+ "@react-types/button": ["@react-types/button@3.15.1", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-M1HtsKreJkigCnqceuIT22hDJBSStbPimnpmQmsl7SNyqCFY3+DHS7y/Sl3GvqCkzxF7j9UTL0dG38lGQ3K4xQ=="],
+
+ "@react-types/overlays": ["@react-types/overlays@3.9.4", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-7Z9HaebMFyYBqtv3XVNHEmVkm7AiYviV7gv0c98elEN2Co+eQcKFGvwBM9Gy/lV57zlTqFX1EX/SAqkMEbCLOA=="],
+
+ "@react-types/shared": ["@react-types/shared@3.33.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag=="],
+
+ "@remixicon/react": ["@remixicon/react@4.9.0", "", { "peerDependencies": { "react": ">=18.2.0" } }, "sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+
+ "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
+
+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
+
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
+
+ "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
+
+ "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
+
+ "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],
+
+ "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
+
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
+ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
+
+ "@streamdown/code": ["@streamdown/code@1.1.0", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-swypCjtE6vv01bnEtPeaw2ew9cbL2nbsLc06HAIK3K6nYXj5WDA8VLR6GEiwdh7HLIPt5dGze+PJ0eJVkqesug=="],
+
+ "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
+
+ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
+
+ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
+
+ "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.21", "", { "dependencies": { "@tanstack/virtual-core": "3.13.21" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw=="],
+
+ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.21", "", {}, "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw=="],
+
+ "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
+
+ "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
+
+ "@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="],
+
+ "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+
+ "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+
+ "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
+
+ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+
+ "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
+
+ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+
+ "@types/dom-speech-recognition": ["@types/dom-speech-recognition@0.0.7", "", {}, "sha512-NjiUoJbBlKhyufNsMZLSp+pbPNtPAFnR738RCJvtZy/HVQ2TZjmqpMyaeOSMXgxdfZM60nt8QGbtfmQrJAH2sw=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+ "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
+
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+ "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
+
+ "@types/http-proxy": ["@types/http-proxy@1.17.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw=="],
+
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
+ "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
+ "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
+
+ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
+
+ "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
+
+ "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
+
+ "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
+
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
+ "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="],
+
+ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
+
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
+
+ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
+
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
+
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
+
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
+
+ "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
+
+ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
+
+ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
+
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
+
+ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
+
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+
+ "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
+
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+
+ "app-builder-bin": ["app-builder-bin@4.0.0", "", {}, "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA=="],
+
+ "app-builder-lib": ["app-builder-lib@24.13.3", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/notarize": "2.2.1", "@electron/osx-sign": "1.0.5", "@electron/universal": "1.5.1", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "bluebird-lst": "^1.0.9", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chromium-pickle-js": "^0.2.0", "debug": "^4.3.4", "ejs": "^3.1.8", "electron-publish": "24.13.1", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "minimatch": "^5.1.1", "read-config-file": "6.3.2", "sanitize-filename": "^1.6.3", "semver": "^7.3.8", "tar": "^6.1.12", "temp-file": "^3.4.0" }, "peerDependencies": { "dmg-builder": "24.13.3", "electron-builder-squirrel-windows": "24.13.3" } }, "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig=="],
+
+ "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="],
+
+ "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="],
+
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
+ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
+
+ "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
+
+ "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
+
+ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
+
+ "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
+
+ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
+
+ "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="],
+
+ "autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="],
+
+ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
+
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
+
+ "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="],
+
+ "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
+
+ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+
+ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
+
+ "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
+
+ "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="],
+
+ "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
+
+ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
+
+ "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
+
+ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+
+ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
+ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
+
+ "buffer-equal": ["buffer-equal@1.0.1", "", {}, "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg=="],
+
+ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+
+ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
+
+ "builder-util": ["builder-util@24.13.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "4.0.0", "bluebird-lst": "^1.0.9", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0" } }, "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA=="],
+
+ "builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
+
+ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
+
+ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+
+ "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
+
+ "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
+
+ "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
+ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
+
+ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
+
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
+ "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
+
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+
+ "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
+
+ "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
+
+ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
+
+ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+
+ "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
+
+ "codemirror-lang-elixir": ["codemirror-lang-elixir@4.0.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "lezer-elixir": "^1.0.0" } }, "sha512-z6W/XB4b7TZrp9EZYBGVq93vQfvKbff+1iM8YZaVErL0dguBAeLmVRlEv1NuDZHOP1qjJ3NwyibkUkNWn7q9VQ=="],
+
+ "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
+
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
+
+ "color2k": ["color2k@2.0.3", "", {}, "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog=="],
+
+ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+ "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
+
+ "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="],
+
+ "compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="],
+
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
+
+ "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
+
+ "config-file-ts": ["config-file-ts@0.2.6", "", { "dependencies": { "glob": "^10.3.10", "typescript": "^5.3.3" } }, "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w=="],
+
+ "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
+
+ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
+ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
+
+ "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
+
+ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
+
+ "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
+
+ "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
+
+ "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="],
+
+ "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
+
+ "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
+
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
+ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
+
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
+
+ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
+
+ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
+
+ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
+ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
+
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+ "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
+
+ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
+
+ "dir-compare": ["dir-compare@3.3.0", "", { "dependencies": { "buffer-equal": "^1.0.0", "minimatch": "^3.0.4" } }, "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg=="],
+
+ "dmg-builder": ["dmg-builder@24.13.3", "", { "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ=="],
+
+ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
+
+ "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
+
+ "dotenv": ["dotenv@9.0.2", "", {}, "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg=="],
+
+ "dotenv-expand": ["dotenv-expand@5.1.0", "", {}, "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
+
+ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
+
+ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+
+ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
+
+ "electron": ["electron@38.8.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-lyBhcVi9QYAZL6FO6r5twAWAjWnYomo3iVDvrb5SJZlq928BGemHOKG0tPIq41NOLaCu9f3XdEEjMkjQPjprRg=="],
+
+ "electron-builder": ["electron-builder@24.13.3", "", { "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "dmg-builder": "24.13.3", "fs-extra": "^10.1.0", "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "read-config-file": "6.3.2", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg=="],
+
+ "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@24.13.3", "", { "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", "builder-util": "24.13.1", "fs-extra": "^10.1.0" } }, "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg=="],
+
+ "electron-context-menu": ["electron-context-menu@4.1.1", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-kebXha4K2DmR7zrKpO8muzXlUAdhgqQIT7DpPeT4cAKZugwCB/NbuEuT+1SDjOH9vXJYjWK2UQuFvnVw0u0lHg=="],
+
+ "electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
+
+ "electron-is-dev": ["electron-is-dev@3.0.1", "", {}, "sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q=="],
+
+ "electron-publish": ["electron-publish@24.13.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A=="],
+
+ "electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
+
+ "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
+
+ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
+
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
+
+ "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
+
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="],
+
+ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
+
+ "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
+
+ "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+
+ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
+
+ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
+ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
+
+ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
+
+ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
+
+ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
+
+ "ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="],
+
+ "ext-name": ["ext-name@5.0.0", "", { "dependencies": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" } }, "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ=="],
+
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+ "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
+
+ "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
+
+ "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+
+ "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
+
+ "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="],
+
+ "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
+
+ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
+
+ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
+ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
+
+ "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
+
+ "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
+
+ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
+
+ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+
+ "framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="],
+
+ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
+
+ "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
+
+ "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
+
+ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
+ "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
+
+ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
+
+ "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
+
+ "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
+
+ "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
+
+ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
+ "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
+
+ "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
+
+ "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
+
+ "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
+
+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
+
+ "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+ "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+ "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
+
+ "heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="],
+
+ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
+
+ "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
+
+ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
+
+ "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
+
+ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
+ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
+
+ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+
+ "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="],
+
+ "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
+
+ "http-proxy-middleware": ["http-proxy-middleware@3.0.5", "", { "dependencies": { "@types/http-proxy": "^1.17.15", "debug": "^4.3.6", "http-proxy": "^1.18.1", "is-glob": "^4.0.3", "is-plain-object": "^5.0.0", "micromatch": "^4.0.8" } }, "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg=="],
+
+ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
+
+ "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
+
+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
+ "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
+
+ "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
+
+ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="],
+
+ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+
+ "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
+
+ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+
+ "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
+
+ "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
+
+ "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
+
+ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
+
+ "is-ci": ["is-ci@3.0.1", "", { "dependencies": { "ci-info": "^3.2.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ=="],
+
+ "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
+
+ "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
+
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+ "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
+
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+
+ "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
+
+ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
+
+ "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+
+ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
+
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
+ "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+
+ "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
+ "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
+
+ "json-with-bigint": ["json-with-bigint@3.5.7", "", {}, "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw=="],
+
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
+
+ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
+
+ "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
+
+ "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
+
+ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
+
+ "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
+
+ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
+
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
+ "lezer-elixir": ["lezer-elixir@1.1.3", "", { "dependencies": { "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.3.0" } }, "sha512-Ymc58/WhxdZS9yEOlnKbF3rdeBdFcPm4OEm26KMqA1Za9vztXi7I5qwGw1KxYmm3Nv0iDHq//EQyBwSEzKG9Mg=="],
+
+ "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
+
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
+
+ "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
+
+ "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
+
+ "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="],
+
+ "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
+
+ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+
+ "lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="],
+
+ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
+ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
+
+ "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
+ "marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
+
+ "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+ "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+ "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+ "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
+
+ "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+ "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+ "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+ "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
+ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
+
+ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+
+ "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+ "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+ "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+ "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+ "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
+
+ "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
+
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
+ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
+ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
+
+ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
+
+ "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
+
+ "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+
+ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+
+ "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
+
+ "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
+
+ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
+
+ "motion": ["motion@12.35.2", "", { "dependencies": { "framer-motion": "^12.35.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A=="],
+
+ "motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="],
+
+ "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+
+ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+
+ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
+
+ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
+
+ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
+ "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
+
+ "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
+
+ "nodemon": ["nodemon@3.1.14", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
+
+ "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
+
+ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
+
+ "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
+
+ "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
+
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
+
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
+
+ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+
+ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+
+ "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
+
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
+
+ "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+
+ "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
+
+ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
+
+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
+
+ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
+
+ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
+
+ "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+
+ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
+
+ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
+
+ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
+
+ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
+
+ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
+ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
+
+ "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="],
+
+ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
+
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
+
+ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
+
+ "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
+
+ "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
+
+ "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
+
+ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
+
+ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
+
+ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
+
+ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+
+ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
+ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
+
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
+ "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="],
+
+ "read-config-file": ["read-config-file@6.3.2", "", { "dependencies": { "config-file-ts": "^0.2.4", "dotenv": "^9.0.2", "dotenv-expand": "^5.1.0", "js-yaml": "^4.1.0", "json5": "^2.2.0", "lazy-val": "^1.0.4" } }, "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q=="],
+
+ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
+ "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
+
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+
+ "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
+
+ "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
+
+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
+ "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="],
+
+ "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
+
+ "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
+
+ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
+ "remend": ["remend@1.2.2", "", {}, "sha512-4ZJgIB9EG9fQE41mOJCRHMmnxDTKHWawQoJWZyUbZuj680wVyogu2ihnj8Edqm7vh2mo/TWHyEZpn2kqeDvS7w=="],
+
+ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
+ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
+
+ "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
+
+ "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
+
+ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+ "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
+
+ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
+
+ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
+
+ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+
+ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+
+ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "sanitize-filename": ["sanitize-filename@1.6.3", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg=="],
+
+ "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
+
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
+
+ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
+
+ "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
+
+ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+
+ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
+
+ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
+
+ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
+
+ "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
+
+ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+
+ "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+ "simple-git": ["simple-git@3.33.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng=="],
+
+ "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
+
+ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
+
+ "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
+
+ "slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
+
+ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
+
+ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
+
+ "sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
+
+ "sort-keys-length": ["sort-keys-length@1.0.1", "", { "dependencies": { "sort-keys": "^1.0.0" } }, "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw=="],
+
+ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
+
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+ "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
+
+ "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
+
+ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+
+ "streamdown": ["streamdown@2.4.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.2.2", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-fRk4HEYNznRLmxoVeT8wsGBwHF6/Yrdey6k+ZrE1Qtp4NyKwm7G/6e2Iw8penY4yLx31TlAHWT5Bsg1weZ9FZg=="],
+
+ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
+
+ "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
+
+ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
+
+ "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
+
+ "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+
+ "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
+
+ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
+
+ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+
+ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
+
+ "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
+
+ "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
+
+ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+
+ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
+
+ "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
+
+ "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
+
+ "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+
+ "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="],
+
+ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
+ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
+
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
+ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
+
+ "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
+
+ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
+
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
+
+ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
+
+ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+
+ "typescript-eslint": ["typescript-eslint@8.57.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/parser": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA=="],
+
+ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
+
+ "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+ "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
+
+ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
+
+ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+
+ "unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
+ "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
+ "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
+
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+
+ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
+
+ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+
+ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
+
+ "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
+
+ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
+ "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
+ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+
+ "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
+
+ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
+
+ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
+ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+
+ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
+
+ "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+
+ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
+ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
+
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
+ "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="],
+
+ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
+ "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
+
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
+ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
+ "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
+
+ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
+
+ "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
+
+ "@electron/universal/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
+
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
+
+ "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+
+ "@heroui/theme/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
+
+ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+
+ "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
+ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
+
+ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
+
+ "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
+ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
+
+ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+
+ "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
+
+ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "app-builder-lib/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
+
+ "archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
+ "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+
+ "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
+ "builder-util/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
+
+ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
+ "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
+
+ "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
+ "conf/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
+
+ "conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
+
+ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+ "electron/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+
+ "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
+
+ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
+
+ "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
+
+ "glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+
+ "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
+
+ "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
+
+ "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
+
+ "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="],
+
+ "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
+
+ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
+
+ "http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
+
+ "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
+
+ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
+
+ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+
+ "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+ "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
+ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
+
+ "nodemon/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
+
+ "nodemon/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
+
+ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
+ "parse-entities/character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
+
+ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
+ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+
+ "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
+
+ "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
+ "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
+
+ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
+
+ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
+
+ "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
+
+ "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
+
+ "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+ "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
+
+ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="],
+
+ "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
+
+ "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+
+ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
+
+ "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
+
+ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "archiver-utils/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
+
+ "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "builder-util/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
+
+ "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
+ "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
+ "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
+
+ "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
+
+ "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
+
+ "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
+ "nodemon/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
+
+ "nodemon/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
+
+ "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
+ "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
+
+ "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+
+ "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
+
+ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
+
+ "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+
+ "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+ "nodemon/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+
+ "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
+
+ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+
+ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+
+ "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+ }
+}
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..73afbdb
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
\ No newline at end of file
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..552d212
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { globalIgnores } from 'eslint/config'
+
+export default tseslint.config([
+ globalIgnores(['dist', '.openchamber']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs['recommended-latest'],
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/openchamber.sed b/openchamber.sed
new file mode 100644
index 0000000..a00731e
--- /dev/null
+++ b/openchamber.sed
@@ -0,0 +1,19 @@
+[Version]
+Class=IEXPRESS
+SEDVersion=3
+
+[Options]
+PackageName=OpenChamber.exe
+TargetName=OpenChamber.exe
+Compression=1
+AdvancedInstallOnly=1
+SourceDigest=sha256
+CryptographicAlgorithm=sha256
+RunPostExtractCommand=start.bat
+
+[Source]
+dist-output
+start.bat
+
+[Strings]
+File1=start.bat
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c88eff7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,167 @@
+{
+ "name": "openchamber-monorepo",
+ "version": "1.8.5",
+ "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
+ "private": true,
+ "type": "module",
+ "packageManager": "bun@1.3.5",
+ "workspaces": [],
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "keywords": [
+ "opencode",
+ "ai",
+ "coding",
+ "openchamber",
+ "cli"
+ ],
+ "author": "Bohdan Triapitsyn",
+ "license": "MIT",
+ "scripts": {
+ "dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd web dev:server:watch\" \"bun run --cwd web build:watch\" \"bun run --cwd ui dev\"",
+ "build:web": "bun run --cwd web build",
+ "build:ui": "bun run --cwd ui build",
+ "package": "bun run build:web && bunx shx rm -rf dist-output && bunx shx mkdir dist-output && bunx shx cp -r web/dist dist-output/ && bunx shx cp -r web/server dist-output/ && bunx shx cp -r web/bin dist-output/ && bunx shx cp web/package.json dist-output/",
+ "type-check:web": "bun run --cwd web type-check",
+ "type-check:ui": "bun run --cwd ui type-check",
+ "lint:web": "bun run --cwd web lint",
+ "lint:ui": "bun run --cwd ui lint",
+ "dev:web": "bun run --cwd web build:watch",
+ "dev:web:server": "bun run --cwd web dev:server:watch",
+ "start:web": "bun run --cwd web start",
+ "pack:web": "bun pm pack --cwd web",
+ "icons:sprite": "node scripts/generate-file-type-sprite.mjs",
+ "version:bump": "node scripts/bump-version.mjs",
+ "release:prepare": "bun run build:web && bun run type-check:web && bun run lint:web",
+ "release:test": "./scripts/test-release-build.sh",
+ "release:test:intel": "./scripts/test-release-build.sh x86_64",
+ "release:test:arm": "./scripts/test-release-build.sh aarch64"
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.20.0",
+ "@codemirror/commands": "^6.10.1",
+ "@codemirror/lang-css": "^6.3.1",
+ "@codemirror/lang-html": "^6.4.11",
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@codemirror/lang-json": "^6.0.2",
+ "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/lang-python": "^6.2.1",
+ "@codemirror/lang-rust": "^6.0.2",
+ "@codemirror/lang-cpp": "^6.0.3",
+ "@codemirror/lang-go": "^6.0.1",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/lang-xml": "^6.1.0",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.12.1",
+ "@codemirror/language-data": "^6.5.2",
+ "@codemirror/legacy-modes": "^6.5.2",
+ "@codemirror/lint": "^6.9.2",
+ "@codemirror/search": "^6.6.0",
+ "@codemirror/state": "^6.5.4",
+ "@codemirror/view": "^6.39.13",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@fontsource/ibm-plex-mono": "^5.2.7",
+ "@fontsource/ibm-plex-sans": "^5.1.1",
+ "@heroui/scroll-shadow": "^2.3.18",
+ "@heroui/system": "^2.4.23",
+ "@heroui/theme": "^2.4.23",
+ "@ibm/plex": "^6.4.1",
+ "@lezer/highlight": "^1.2.3",
+ "@octokit/rest": "^22.0.1",
+ "@opencode-ai/sdk": "^1.2.20",
+ "@pierre/diffs": "1.1.0-beta.13",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@remixicon/react": "^4.7.0",
+ "@streamdown/code": "^1.0.2",
+ "@tanstack/react-virtual": "^3.13.18",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "beautiful-mermaid": "^1.1.3",
+ "bun-pty": "^0.4.5",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "codemirror-lang-elixir": "^4.0.0",
+ "electron-context-menu": "^4.1.1",
+ "electron-store": "^11.0.2",
+ "express": "^5.1.0",
+ "fuse.js": "^7.1.0",
+ "ghostty-web": "^0.4.0",
+ "heic2any": "^0.0.4",
+ "html-to-image": "^1.11.13",
+ "http-proxy-middleware": "^3.0.5",
+ "motion": "^12.23.24",
+ "next-themes": "^0.4.6",
+ "node-pty": "^1.1.0",
+ "prismjs": "^1.30.0",
+ "qrcode": "^1.5.4",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-markdown": "^10.1.0",
+ "react-syntax-highlighter": "^15.6.6",
+ "remark-gfm": "^4.0.1",
+ "simple-git": "^3.28.0",
+ "sonner": "^2.0.7",
+ "streamdown": "^2.2.0",
+ "strip-json-comments": "^5.0.3",
+ "tailwind-merge": "^3.3.1",
+ "yaml": "^2.8.1",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.8",
+ "adm-zip": "^0.5.16",
+ "jose": "^6.1.3",
+ "jsonc-parser": "^3.3.1",
+ "openai": "^4.79.0",
+ "qrcode-terminal": "^0.12.0",
+ "web-push": "^3.6.7",
+ "ws": "^8.18.3"
+ },
+ "overrides": {
+ "@codemirror/language": "6.12.2",
+ "@codemirror/view": "6.39.13"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@tailwindcss/postcss": "^4.0.0",
+ "@tauri-apps/api": "^2.9.0",
+ "@types/dom-speech-recognition": "^0.0.7",
+ "@types/node": "^24.3.1",
+ "@types/prismjs": "^1.26.6",
+ "@types/qrcode": "^1.5.5",
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react": "^5.0.0",
+ "autoprefixer": "^10.4.21",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "concurrently": "^9.2.1",
+ "cors": "^2.8.5",
+ "cross-env": "^7.0.3",
+ "electron": "^38.2.0",
+ "electron-builder": "^24.13.3",
+ "eslint": "^9.33.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "nodemon": "^3.1.7",
+ "patch-package": "^8.0.0",
+ "playwright": "^1.58.2",
+ "tailwindcss": "^4.0.0",
+ "tsx": "^4.20.6",
+ "tw-animate-css": "^1.3.8",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.39.1",
+ "vite": "^7.1.2",
+ "@types/adm-zip": "^0.5.7"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..db3c3d1
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+}
\ No newline at end of file
diff --git a/run.bat b/run.bat
new file mode 100644
index 0000000..50eec2f
--- /dev/null
+++ b/run.bat
@@ -0,0 +1,23 @@
+@echo off
+echo Starting OpenCode...
+start "OpenCode" cmd /c "opencode serve"
+
+echo Waiting for OpenCode to start...
+:wait_loop
+curl -s http://localhost:4096/health >nul 2>&1
+if %errorlevel% neq 0 (
+ timeout /t 1 >nul
+ goto wait_loop
+)
+echo OpenCode started on port 4096
+
+echo.
+echo Starting OpenChamber...
+start "OpenChamber" cmd /c "bun run start:web"
+
+echo.
+echo ========================================
+echo OpenChamber should be ready at:
+echo http://localhost:3000
+echo ========================================
+pause
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..552bb0a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./ui/tsconfig.json" },
+ { "path": "./web/tsconfig.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "moduleResolution": "bundler",
+ "moduleDetection": "force",
+ "verbatimModuleSyntax": true,
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "paths": {
+ "@openchamber/ui/*": ["./ui/src/*"],
+ "@openchamber/web/*": ["./web/src/*"]
+ }
+ }
+}
diff --git a/ui/package.json b/ui/package.json
new file mode 100644
index 0000000..de43905
--- /dev/null
+++ b/ui/package.json
@@ -0,0 +1,113 @@
+{
+ "name": "@openchamber/ui",
+ "version": "1.8.5",
+ "private": true,
+ "type": "module",
+ "main": "src/main.tsx",
+ "scripts": {
+ "dev": "tsc --noEmit --watch",
+ "build": "tsc --noEmit",
+ "type-check": "tsc --noEmit",
+ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../eslint.config.js"
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.20.0",
+ "@codemirror/commands": "^6.10.1",
+ "@codemirror/lang-cpp": "^6.0.3",
+ "@codemirror/lang-css": "^6.3.1",
+ "@codemirror/lang-go": "^6.0.1",
+ "@codemirror/lang-html": "^6.4.11",
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@codemirror/lang-json": "^6.0.2",
+ "@codemirror/lang-markdown": "^6.5.0",
+ "@codemirror/lang-python": "^6.2.1",
+ "@codemirror/lang-rust": "^6.0.2",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/lang-xml": "^6.1.0",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.12.1",
+ "@codemirror/language-data": "^6.5.2",
+ "@codemirror/legacy-modes": "^6.5.2",
+ "@codemirror/lint": "^6.9.2",
+ "@codemirror/search": "^6.6.0",
+ "@codemirror/state": "^6.5.4",
+ "@codemirror/view": "^6.39.13",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@fontsource/ibm-plex-mono": "^5.2.7",
+ "@fontsource/ibm-plex-sans": "^5.1.1",
+ "@ibm/plex": "^6.4.1",
+ "@lezer/highlight": "^1.2.3",
+ "@opencode-ai/sdk": "^1.2.20",
+ "@pierre/diffs": "1.1.0-beta.13",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@remixicon/react": "^4.7.0",
+ "@streamdown/code": "^1.0.2",
+ "@tanstack/react-virtual": "^3.13.18",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "beautiful-mermaid": "^1.1.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "codemirror-lang-elixir": "^4.0.0",
+ "express": "^5.1.0",
+ "fuse.js": "^7.1.0",
+ "ghostty-web": "^0.4.0",
+ "heic2any": "^0.0.4",
+ "html-to-image": "^1.11.13",
+ "http-proxy-middleware": "^3.0.5",
+ "motion": "^12.23.24",
+ "next-themes": "^0.4.6",
+ "prismjs": "^1.30.0",
+ "qrcode": "^1.5.4",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-syntax-highlighter": "^15.6.6",
+ "simple-git": "^3.28.0",
+ "sonner": "^2.0.7",
+ "streamdown": "^2.2.0",
+ "strip-json-comments": "^5.0.3",
+ "tailwind-merge": "^3.3.1",
+ "yaml": "^2.8.1",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.8"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.33.0",
+ "@tailwindcss/postcss": "^4.0.0",
+ "@tauri-apps/api": "^2.9.0",
+ "@types/node": "^24.3.1",
+ "@types/prismjs": "^1.26.6",
+ "@types/qrcode": "^1.5.5",
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react": "^5.0.0",
+ "autoprefixer": "^10.4.21",
+ "concurrently": "^9.2.1",
+ "cors": "^2.8.5",
+ "cross-env": "^7.0.3",
+ "electron": "^38.2.0",
+ "electron-builder": "^24.13.3",
+ "eslint": "^9.33.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "nodemon": "^3.1.7",
+ "tailwindcss": "^4.0.0",
+ "tsx": "^4.20.6",
+ "tw-animate-css": "^1.3.8",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.39.1",
+ "vite": "^7.1.2"
+ }
+}
diff --git a/ui/src/App.css b/ui/src/App.css
new file mode 100644
index 0000000..6ee5bff
--- /dev/null
+++ b/ui/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: none;
+}
+.logo.react:hover {
+ filter: none;
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
new file mode 100644
index 0000000..1e0c440
--- /dev/null
+++ b/ui/src/App.tsx
@@ -0,0 +1,548 @@
+import React from 'react';
+import { MainLayout } from '@/components/layout/MainLayout';
+import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
+import { AgentManagerView } from '@/components/views/agent-manager';
+import { ChatView } from '@/components/views';
+import { FireworksProvider } from '@/contexts/FireworksContext';
+import { Toaster } from '@/components/ui/sonner';
+import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
+import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
+import { useEventStream } from '@/hooks/useEventStream';
+import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
+import { useMenuActions } from '@/hooks/useMenuActions';
+import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
+import { useServerSessionStatus } from '@/hooks/useServerSessionStatus';
+import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
+import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
+import { useRouter } from '@/hooks/useRouter';
+import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
+import { useWindowTitle } from '@/hooks/useWindowTitle';
+import { useGitHubPrBackgroundTracking } from '@/hooks/useGitHubPrBackgroundTracking';
+import { GitPollingProvider } from '@/hooks/useGitPolling';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { hasModifier } from '@/lib/utils';
+import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
+import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { opencodeClient } from '@/lib/opencode/client';
+import { useFontPreferences } from '@/hooks/useFontPreferences';
+import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
+import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
+import { AboutDialog } from '@/components/ui/AboutDialog';
+import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
+import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
+import { useUIStore } from '@/stores/useUIStore';
+import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+import type { RuntimeAPIs } from '@/lib/api/types';
+import { TooltipProvider } from '@/components/ui/tooltip';
+
+const CLI_MISSING_ERROR_REGEX =
+ /ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i;
+const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
+
+const AboutDialogWrapper: React.FC = () => {
+ const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
+ return (
+
+ );
+};
+
+type AppProps = {
+ apis: RuntimeAPIs;
+};
+
+type EmbeddedSessionChatConfig = {
+ sessionId: string;
+ directory: string | null;
+};
+
+type EmbeddedVisibilityPayload = {
+ visible?: unknown;
+};
+
+const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const params = new URLSearchParams(window.location.search);
+ if (params.get('ocPanel') !== 'session-chat') {
+ return null;
+ }
+
+ const sessionIdRaw = params.get('sessionId');
+ const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : '';
+ if (!sessionId) {
+ return null;
+ }
+
+ const directoryRaw = params.get('directory');
+ const directory = typeof directoryRaw === 'string' && directoryRaw.trim().length > 0
+ ? directoryRaw.trim()
+ : null;
+
+ return {
+ sessionId,
+ directory,
+ };
+};
+
+function App({ apis }: AppProps) {
+ const { initializeApp, isInitialized, isConnected } = useConfigStore();
+ const providersCount = useConfigStore((state) => state.providers.length);
+ const agentsCount = useConfigStore((state) => state.agents.length);
+ const loadProviders = useConfigStore((state) => state.loadProviders);
+ const loadAgents = useConfigStore((state) => state.loadAgents);
+ const { error, clearError, loadSessions } = useSessionStore();
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
+ const sessions = useSessionStore((state) => state.sessions);
+ const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
+ const setDirectory = useDirectoryStore((state) => state.setDirectory);
+ const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
+ const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
+ const { uiFont, monoFont } = useFontPreferences();
+ const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
+ const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode);
+ const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
+ const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
+ const appReadyDispatchedRef = React.useRef(false);
+ const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []);
+ const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
+
+ React.useEffect(() => {
+ setIsVSCodeRuntime(apis.runtime.isVSCode);
+ }, [apis.runtime.isVSCode]);
+
+ React.useEffect(() => {
+ registerRuntimeAPIs(apis);
+ return () => registerRuntimeAPIs(null);
+ }, [apis]);
+
+ React.useEffect(() => {
+ if (embeddedSessionChat) {
+ return;
+ }
+
+ void refreshGitHubAuthStatus(apis.github, { force: true });
+ }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
+
+ useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git);
+
+ React.useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ const root = document.documentElement;
+ const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack;
+ const monoStack = CODE_FONT_OPTION_MAP[monoFont]?.stack ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack;
+
+ root.style.setProperty('--font-sans', uiStack);
+ root.style.setProperty('--font-heading', uiStack);
+ root.style.setProperty('--font-family-sans', uiStack);
+ root.style.setProperty('--font-mono', monoStack);
+ root.style.setProperty('--font-family-mono', monoStack);
+ root.style.setProperty('--ui-regular-font-weight', '400');
+
+ if (document.body) {
+ document.body.style.fontFamily = uiStack;
+ }
+ }, [uiFont, monoFont]);
+
+ React.useEffect(() => {
+ if (isInitialized) {
+ const hideInitialLoading = () => {
+ const loadingElement = document.getElementById('initial-loading');
+ if (loadingElement) {
+ loadingElement.classList.add('fade-out');
+
+ setTimeout(() => {
+ loadingElement.remove();
+ }, 300);
+ }
+ };
+
+ const timer = setTimeout(hideInitialLoading, 150);
+ return () => clearTimeout(timer);
+ }
+ }, [isInitialized]);
+
+ React.useEffect(() => {
+ const fallbackTimer = setTimeout(() => {
+ const loadingElement = document.getElementById('initial-loading');
+ if (loadingElement && !isInitialized) {
+ loadingElement.classList.add('fade-out');
+ setTimeout(() => {
+ loadingElement.remove();
+ }, 300);
+ }
+ }, 5000);
+
+ return () => clearTimeout(fallbackTimer);
+ }, [isInitialized]);
+
+ React.useEffect(() => {
+ const init = async () => {
+ // VS Code runtime bootstraps config + sessions after the managed OpenCode instance reports "connected".
+ // Doing the default initialization here can race with startup and lead to one-shot failures.
+ if (isVSCodeRuntime) {
+ return;
+ }
+ await initializeApp();
+ };
+
+ init();
+ }, [initializeApp, isVSCodeRuntime]);
+
+ const startupRecoveryInProgressRef = React.useRef(false);
+ const startupRecoveryLastAttemptRef = React.useRef(0);
+
+ React.useEffect(() => {
+ if (isVSCodeRuntime) {
+ return;
+ }
+ if (!isConnected) {
+ return;
+ }
+ if (providersCount > 0 && agentsCount > 0) {
+ return;
+ }
+ if (startupRecoveryInProgressRef.current) {
+ return;
+ }
+
+ const now = Date.now();
+ if (now - startupRecoveryLastAttemptRef.current < 750) {
+ return;
+ }
+
+ startupRecoveryLastAttemptRef.current = now;
+ startupRecoveryInProgressRef.current = true;
+
+ const repair = async () => {
+ try {
+ if (providersCount === 0) {
+ await loadProviders();
+ }
+ if (agentsCount === 0) {
+ await loadAgents();
+ }
+ } catch {
+ // Keep UI responsive; we'll retry on next cycle.
+ } finally {
+ startupRecoveryInProgressRef.current = false;
+ }
+ };
+
+ void repair();
+ }, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]);
+
+ React.useEffect(() => {
+ if (isSwitchingDirectory) {
+ return;
+ }
+
+ const syncDirectoryAndSessions = async () => {
+ // VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
+ if (isVSCodeRuntime) {
+ return;
+ }
+
+ if (!isConnected) {
+ return;
+ }
+ opencodeClient.setDirectory(currentDirectory);
+
+ await loadSessions();
+ };
+
+ syncDirectoryAndSessions();
+ }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
+
+ React.useEffect(() => {
+ if (!embeddedSessionChat || typeof window === 'undefined') {
+ return;
+ }
+
+ const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
+ const nextVisible = payload?.visible === true;
+ setIsEmbeddedVisible(nextVisible);
+ };
+
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) {
+ return;
+ }
+
+ const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
+ if (data?.type !== 'openchamber:embedded-visibility') {
+ return;
+ }
+
+ applyVisibility(data.payload);
+ };
+
+ const scopedWindow = window as unknown as {
+ __openchamberSetEmbeddedVisibility?: (payload?: EmbeddedVisibilityPayload) => void;
+ };
+
+ scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
+ window.addEventListener('message', handleMessage);
+
+ return () => {
+ window.removeEventListener('message', handleMessage);
+ if (scopedWindow.__openchamberSetEmbeddedVisibility === applyVisibility) {
+ delete scopedWindow.__openchamberSetEmbeddedVisibility;
+ }
+ };
+ }, [embeddedSessionChat]);
+
+ React.useEffect(() => {
+ if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
+ return;
+ }
+
+ if (currentDirectory === embeddedSessionChat.directory) {
+ return;
+ }
+
+ setDirectory(embeddedSessionChat.directory, { showOverlay: false });
+ }, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
+
+ React.useEffect(() => {
+ if (!embeddedSessionChat || isVSCodeRuntime) {
+ return;
+ }
+
+ if (currentSessionId === embeddedSessionChat.sessionId) {
+ return;
+ }
+
+ if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
+ return;
+ }
+
+ void setCurrentSession(embeddedSessionChat.sessionId);
+ }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
+
+ React.useEffect(() => {
+ if (!embeddedSessionChat || typeof window === 'undefined') {
+ return;
+ }
+
+ const handleStorage = (event: StorageEvent) => {
+ if (event.storageArea !== window.localStorage) {
+ return;
+ }
+
+ if (event.key !== 'ui-store') {
+ return;
+ }
+
+ void useUIStore.persist.rehydrate();
+ };
+
+ window.addEventListener('storage', handleStorage);
+ return () => {
+ window.removeEventListener('storage', handleStorage);
+ };
+ }, [embeddedSessionChat]);
+
+ React.useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (!isInitialized || isSwitchingDirectory) return;
+ if (appReadyDispatchedRef.current) return;
+ appReadyDispatchedRef.current = true;
+ (window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady = true;
+ window.dispatchEvent(new Event('openchamber:app-ready'));
+ }, [isInitialized, isSwitchingDirectory]);
+
+ useEventStream({ enabled: embeddedBackgroundWorkEnabled });
+
+ // Server-authoritative session status polling
+ // Replaces SSE-dependent status updates with reliable HTTP polling
+ useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
+
+ usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
+
+ useWindowTitle();
+
+ useRouter();
+
+ useKeyboardShortcuts();
+
+ const handleToggleMemoryDebug = React.useCallback(() => {
+ setShowMemoryDebug(prev => !prev);
+ }, []);
+
+ useMenuActions(handleToggleMemoryDebug);
+
+ useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
+ useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled });
+ useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled });
+
+ React.useEffect(() => {
+ if (embeddedSessionChat) {
+ return;
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (hasModifier(e) && e.shiftKey && e.key === 'D') {
+ e.preventDefault();
+ setShowMemoryDebug(prev => !prev);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [embeddedSessionChat]);
+
+ React.useEffect(() => {
+ if (embeddedSessionChat) {
+ return;
+ }
+
+ if (error) {
+
+ setTimeout(() => clearError(), 5000);
+ }
+ }, [clearError, embeddedSessionChat, error]);
+
+ React.useEffect(() => {
+ if (embeddedSessionChat) {
+ return;
+ }
+
+ if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
+ return;
+ }
+
+ let cancelled = false;
+ const run = async () => {
+ const res = await fetch('/health', { method: 'GET' }).catch(() => null);
+ if (!res || !res.ok || cancelled) return;
+ const data = (await res.json().catch(() => null)) as null | {
+ openCodeRunning?: unknown;
+ isOpenCodeReady?: unknown;
+ opencodeBinaryResolved?: unknown;
+ lastOpenCodeError?: unknown;
+ };
+ if (!data || cancelled) return;
+ const openCodeRunning = data.openCodeRunning === true;
+ const isOpenCodeReady = data.isOpenCodeReady === true;
+ const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : '';
+ const hasResolvedBinary = resolvedBinary.length > 0;
+ const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
+ const cliMissing =
+ !openCodeRunning &&
+ (CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady));
+ setShowCliOnboarding(cliMissing);
+ };
+
+ void run();
+ const interval = window.setInterval(() => {
+ void run();
+ }, CLI_ONBOARDING_HEALTH_POLL_MS);
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [embeddedSessionChat]);
+
+ const handleCliAvailable = React.useCallback(() => {
+ setShowCliOnboarding(false);
+ window.location.reload();
+ }, []);
+
+ if (showCliOnboarding) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (embeddedSessionChat) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // VS Code runtime - simplified layout without git/terminal views
+ if (isVSCodeRuntime) {
+ // Check if this is the Agent Manager panel
+ const panelType = typeof window !== 'undefined'
+ ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
+ : 'chat';
+
+ if (panelType === 'agentManager') {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {showMemoryDebug && (
+
setShowMemoryDebug(false)} />
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/ui/src/assets/icons/file-types/3d.svg b/ui/src/assets/icons/file-types/3d.svg
new file mode 100644
index 0000000..0fdb934
--- /dev/null
+++ b/ui/src/assets/icons/file-types/3d.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/README.md b/ui/src/assets/icons/file-types/README.md
new file mode 100644
index 0000000..9d252f9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/README.md
@@ -0,0 +1,26 @@
+# File Type Icons Sprite
+
+This directory keeps the source file-type SVG icons and the generated sprite used by the UI.
+
+## Runtime behavior
+
+- The UI resolves an icon id in `packages/ui/src/lib/fileTypeIcons.ts`.
+- Valid icon ids are loaded from `packages/ui/src/lib/fileTypeIconIds.ts`.
+- `packages/ui/src/components/icons/FileTypeIcon.tsx` renders the icon with ` ` from `sprite.svg`.
+- Vite handles `sprite.svg` as a normal asset URL automatically.
+
+The sprite generator rewrites internal SVG ids per icon (gradients, clip paths, filters) so ids do not collide after packing all icons into one file.
+
+## Build step
+
+- No special step is required for normal `dev`/`build`.
+- Regenerate the sprite only when icon source files in this folder change:
+
+```bash
+bun run icons:sprite
+```
+
+The command regenerates both `sprite.svg` and `packages/ui/src/lib/fileTypeIconIds.ts`.
+Both generated files are committed and consumed automatically by app builds.
+
+If you only run `bun run dev`, `bun run build`, `bun run lint`, or `bun run type-check`, no extra sprite step is needed unless the source icon files changed.
diff --git a/ui/src/assets/icons/file-types/abap.svg b/ui/src/assets/icons/file-types/abap.svg
new file mode 100644
index 0000000..0a9b083
--- /dev/null
+++ b/ui/src/assets/icons/file-types/abap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/abc.svg b/ui/src/assets/icons/file-types/abc.svg
new file mode 100644
index 0000000..7c7cb53
--- /dev/null
+++ b/ui/src/assets/icons/file-types/abc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/actionscript.svg b/ui/src/assets/icons/file-types/actionscript.svg
new file mode 100644
index 0000000..31d91f2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/actionscript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ada.svg b/ui/src/assets/icons/file-types/ada.svg
new file mode 100644
index 0000000..613646f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ada.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/adobe-illustrator.svg b/ui/src/assets/icons/file-types/adobe-illustrator.svg
new file mode 100644
index 0000000..e0a334b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/adobe-illustrator.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/adobe-illustrator_light.svg b/ui/src/assets/icons/file-types/adobe-illustrator_light.svg
new file mode 100644
index 0000000..326d231
--- /dev/null
+++ b/ui/src/assets/icons/file-types/adobe-illustrator_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/adobe-photoshop.svg b/ui/src/assets/icons/file-types/adobe-photoshop.svg
new file mode 100644
index 0000000..27033d9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/adobe-photoshop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/adobe-photoshop_light.svg b/ui/src/assets/icons/file-types/adobe-photoshop_light.svg
new file mode 100644
index 0000000..d2bfb4d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/adobe-photoshop_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/adobe-swc.svg b/ui/src/assets/icons/file-types/adobe-swc.svg
new file mode 100644
index 0000000..fda5c18
--- /dev/null
+++ b/ui/src/assets/icons/file-types/adobe-swc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/adonis.svg b/ui/src/assets/icons/file-types/adonis.svg
new file mode 100644
index 0000000..f854f01
--- /dev/null
+++ b/ui/src/assets/icons/file-types/adonis.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/advpl.svg b/ui/src/assets/icons/file-types/advpl.svg
new file mode 100644
index 0000000..54e493b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/advpl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/amplify.svg b/ui/src/assets/icons/file-types/amplify.svg
new file mode 100644
index 0000000..89f4212
--- /dev/null
+++ b/ui/src/assets/icons/file-types/amplify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/android.svg b/ui/src/assets/icons/file-types/android.svg
new file mode 100644
index 0000000..c44608d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/android.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/angular.svg b/ui/src/assets/icons/file-types/angular.svg
new file mode 100644
index 0000000..a28075e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/angular.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/antlr.svg b/ui/src/assets/icons/file-types/antlr.svg
new file mode 100644
index 0000000..42f43bb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/antlr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/apiblueprint.svg b/ui/src/assets/icons/file-types/apiblueprint.svg
new file mode 100644
index 0000000..0846267
--- /dev/null
+++ b/ui/src/assets/icons/file-types/apiblueprint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/apollo.svg b/ui/src/assets/icons/file-types/apollo.svg
new file mode 100644
index 0000000..6de6aa2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/apollo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/applescript.svg b/ui/src/assets/icons/file-types/applescript.svg
new file mode 100644
index 0000000..d883e90
--- /dev/null
+++ b/ui/src/assets/icons/file-types/applescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/apps-script.svg b/ui/src/assets/icons/file-types/apps-script.svg
new file mode 100644
index 0000000..ed20f1f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/apps-script.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/appveyor.svg b/ui/src/assets/icons/file-types/appveyor.svg
new file mode 100644
index 0000000..0dd0a5c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/appveyor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/architecture.svg b/ui/src/assets/icons/file-types/architecture.svg
new file mode 100644
index 0000000..ee7de18
--- /dev/null
+++ b/ui/src/assets/icons/file-types/architecture.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/arduino.svg b/ui/src/assets/icons/file-types/arduino.svg
new file mode 100644
index 0000000..053dc12
--- /dev/null
+++ b/ui/src/assets/icons/file-types/arduino.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/asciidoc.svg b/ui/src/assets/icons/file-types/asciidoc.svg
new file mode 100644
index 0000000..82215c7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/asciidoc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/assembly.svg b/ui/src/assets/icons/file-types/assembly.svg
new file mode 100644
index 0000000..33a0566
--- /dev/null
+++ b/ui/src/assets/icons/file-types/assembly.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/astro-config.svg b/ui/src/assets/icons/file-types/astro-config.svg
new file mode 100644
index 0000000..1c12c5e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/astro-config.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/astro.svg b/ui/src/assets/icons/file-types/astro.svg
new file mode 100644
index 0000000..fa67fee
--- /dev/null
+++ b/ui/src/assets/icons/file-types/astro.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/astyle.svg b/ui/src/assets/icons/file-types/astyle.svg
new file mode 100644
index 0000000..6643432
--- /dev/null
+++ b/ui/src/assets/icons/file-types/astyle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/audio.svg b/ui/src/assets/icons/file-types/audio.svg
new file mode 100644
index 0000000..74f43c4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/audio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/aurelia.svg b/ui/src/assets/icons/file-types/aurelia.svg
new file mode 100644
index 0000000..f7b67f0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/aurelia.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/authors.svg b/ui/src/assets/icons/file-types/authors.svg
new file mode 100644
index 0000000..88618a7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/authors.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/auto.svg b/ui/src/assets/icons/file-types/auto.svg
new file mode 100644
index 0000000..41bd15d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/auto.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/auto_light.svg b/ui/src/assets/icons/file-types/auto_light.svg
new file mode 100644
index 0000000..5f2451b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/auto_light.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/autohotkey.svg b/ui/src/assets/icons/file-types/autohotkey.svg
new file mode 100644
index 0000000..4ecd7a3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/autohotkey.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/autoit.svg b/ui/src/assets/icons/file-types/autoit.svg
new file mode 100644
index 0000000..350519f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/autoit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/azure-pipelines.svg b/ui/src/assets/icons/file-types/azure-pipelines.svg
new file mode 100644
index 0000000..f460d20
--- /dev/null
+++ b/ui/src/assets/icons/file-types/azure-pipelines.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/azure.svg b/ui/src/assets/icons/file-types/azure.svg
new file mode 100644
index 0000000..2330f87
--- /dev/null
+++ b/ui/src/assets/icons/file-types/azure.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/babel.svg b/ui/src/assets/icons/file-types/babel.svg
new file mode 100644
index 0000000..244ae36
--- /dev/null
+++ b/ui/src/assets/icons/file-types/babel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ballerina.svg b/ui/src/assets/icons/file-types/ballerina.svg
new file mode 100644
index 0000000..3c1341d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ballerina.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bazel.svg b/ui/src/assets/icons/file-types/bazel.svg
new file mode 100644
index 0000000..b38a90c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bazel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bbx.svg b/ui/src/assets/icons/file-types/bbx.svg
new file mode 100644
index 0000000..002d260
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bbx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/beancount.svg b/ui/src/assets/icons/file-types/beancount.svg
new file mode 100644
index 0000000..905ff22
--- /dev/null
+++ b/ui/src/assets/icons/file-types/beancount.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bench-js.svg b/ui/src/assets/icons/file-types/bench-js.svg
new file mode 100644
index 0000000..c2ba0ca
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bench-js.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bench-jsx.svg b/ui/src/assets/icons/file-types/bench-jsx.svg
new file mode 100644
index 0000000..ed2b9d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bench-jsx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bench-ts.svg b/ui/src/assets/icons/file-types/bench-ts.svg
new file mode 100644
index 0000000..f9c2af9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bench-ts.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bibliography.svg b/ui/src/assets/icons/file-types/bibliography.svg
new file mode 100644
index 0000000..ad6baa6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bibliography.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bibtex-style.svg b/ui/src/assets/icons/file-types/bibtex-style.svg
new file mode 100644
index 0000000..24d121d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bibtex-style.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bicep.svg b/ui/src/assets/icons/file-types/bicep.svg
new file mode 100644
index 0000000..dc959e7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bicep.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/biome.svg b/ui/src/assets/icons/file-types/biome.svg
new file mode 100644
index 0000000..2f255fc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/biome.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bitbucket.svg b/ui/src/assets/icons/file-types/bitbucket.svg
new file mode 100644
index 0000000..ba572f0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bitbucket.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bithound.svg b/ui/src/assets/icons/file-types/bithound.svg
new file mode 100644
index 0000000..1eea4de
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bithound.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/blender.svg b/ui/src/assets/icons/file-types/blender.svg
new file mode 100644
index 0000000..f55d6bc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/blender.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/blink.svg b/ui/src/assets/icons/file-types/blink.svg
new file mode 100644
index 0000000..4412288
--- /dev/null
+++ b/ui/src/assets/icons/file-types/blink.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/blink_light.svg b/ui/src/assets/icons/file-types/blink_light.svg
new file mode 100644
index 0000000..380d8c7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/blink_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/blitz.svg b/ui/src/assets/icons/file-types/blitz.svg
new file mode 100644
index 0000000..147ccc1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/blitz.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bower.svg b/ui/src/assets/icons/file-types/bower.svg
new file mode 100644
index 0000000..9ffb06a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bower.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/brainfuck.svg b/ui/src/assets/icons/file-types/brainfuck.svg
new file mode 100644
index 0000000..6a2422c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/brainfuck.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/browserlist.svg b/ui/src/assets/icons/file-types/browserlist.svg
new file mode 100644
index 0000000..d2e0d0a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/browserlist.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/browserlist_light.svg b/ui/src/assets/icons/file-types/browserlist_light.svg
new file mode 100644
index 0000000..fa34de6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/browserlist_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bruno.svg b/ui/src/assets/icons/file-types/bruno.svg
new file mode 100644
index 0000000..88bebea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bruno.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/buck.svg b/ui/src/assets/icons/file-types/buck.svg
new file mode 100644
index 0000000..a5a31bc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/buck.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bucklescript.svg b/ui/src/assets/icons/file-types/bucklescript.svg
new file mode 100644
index 0000000..d67a784
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bucklescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/buildkite.svg b/ui/src/assets/icons/file-types/buildkite.svg
new file mode 100644
index 0000000..32a4995
--- /dev/null
+++ b/ui/src/assets/icons/file-types/buildkite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bun.svg b/ui/src/assets/icons/file-types/bun.svg
new file mode 100644
index 0000000..cc36204
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bun.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/bun_light.svg b/ui/src/assets/icons/file-types/bun_light.svg
new file mode 100644
index 0000000..d49bac7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/bun_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/c.svg b/ui/src/assets/icons/file-types/c.svg
new file mode 100644
index 0000000..5bb84b6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/c.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/c3.svg b/ui/src/assets/icons/file-types/c3.svg
new file mode 100644
index 0000000..ff30caa
--- /dev/null
+++ b/ui/src/assets/icons/file-types/c3.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cabal.svg b/ui/src/assets/icons/file-types/cabal.svg
new file mode 100644
index 0000000..014335b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cabal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/caddy.svg b/ui/src/assets/icons/file-types/caddy.svg
new file mode 100644
index 0000000..997c119
--- /dev/null
+++ b/ui/src/assets/icons/file-types/caddy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cadence.svg b/ui/src/assets/icons/file-types/cadence.svg
new file mode 100644
index 0000000..25338ba
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cadence.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cairo.svg b/ui/src/assets/icons/file-types/cairo.svg
new file mode 100644
index 0000000..591b232
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cairo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cake.svg b/ui/src/assets/icons/file-types/cake.svg
new file mode 100644
index 0000000..ed6b09f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cake.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/capacitor.svg b/ui/src/assets/icons/file-types/capacitor.svg
new file mode 100644
index 0000000..2a48c58
--- /dev/null
+++ b/ui/src/assets/icons/file-types/capacitor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/capnp.svg b/ui/src/assets/icons/file-types/capnp.svg
new file mode 100644
index 0000000..c74aa9f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/capnp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cbx.svg b/ui/src/assets/icons/file-types/cbx.svg
new file mode 100644
index 0000000..716426a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cbx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cds.svg b/ui/src/assets/icons/file-types/cds.svg
new file mode 100644
index 0000000..3c7fed8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cds.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/certificate.svg b/ui/src/assets/icons/file-types/certificate.svg
new file mode 100644
index 0000000..64ddcf3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/certificate.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/changelog.svg b/ui/src/assets/icons/file-types/changelog.svg
new file mode 100644
index 0000000..b4b1a07
--- /dev/null
+++ b/ui/src/assets/icons/file-types/changelog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/chess.svg b/ui/src/assets/icons/file-types/chess.svg
new file mode 100644
index 0000000..85bede3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/chess.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/chess_light.svg b/ui/src/assets/icons/file-types/chess_light.svg
new file mode 100644
index 0000000..250fb8c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/chess_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/chrome.svg b/ui/src/assets/icons/file-types/chrome.svg
new file mode 100644
index 0000000..0208e27
--- /dev/null
+++ b/ui/src/assets/icons/file-types/chrome.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/circleci.svg b/ui/src/assets/icons/file-types/circleci.svg
new file mode 100644
index 0000000..464dace
--- /dev/null
+++ b/ui/src/assets/icons/file-types/circleci.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/circleci_light.svg b/ui/src/assets/icons/file-types/circleci_light.svg
new file mode 100644
index 0000000..cd45d35
--- /dev/null
+++ b/ui/src/assets/icons/file-types/circleci_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/citation.svg b/ui/src/assets/icons/file-types/citation.svg
new file mode 100644
index 0000000..eb7fcaa
--- /dev/null
+++ b/ui/src/assets/icons/file-types/citation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/clangd.svg b/ui/src/assets/icons/file-types/clangd.svg
new file mode 100644
index 0000000..f6742e9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/clangd.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/claude.svg b/ui/src/assets/icons/file-types/claude.svg
new file mode 100644
index 0000000..fa01860
--- /dev/null
+++ b/ui/src/assets/icons/file-types/claude.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cline.svg b/ui/src/assets/icons/file-types/cline.svg
new file mode 100644
index 0000000..c41f59d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/clojure.svg b/ui/src/assets/icons/file-types/clojure.svg
new file mode 100644
index 0000000..1b22aed
--- /dev/null
+++ b/ui/src/assets/icons/file-types/clojure.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cloudfoundry.svg b/ui/src/assets/icons/file-types/cloudfoundry.svg
new file mode 100644
index 0000000..3251ca4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cloudfoundry.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cmake.svg b/ui/src/assets/icons/file-types/cmake.svg
new file mode 100644
index 0000000..aa21796
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cmake.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/coala.svg b/ui/src/assets/icons/file-types/coala.svg
new file mode 100644
index 0000000..1e84b8f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/coala.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cobol.svg b/ui/src/assets/icons/file-types/cobol.svg
new file mode 100644
index 0000000..220b0ab
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cobol.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/coconut.svg b/ui/src/assets/icons/file-types/coconut.svg
new file mode 100644
index 0000000..98355a6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/coconut.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/code-climate.svg b/ui/src/assets/icons/file-types/code-climate.svg
new file mode 100644
index 0000000..97cbb4e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/code-climate.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/code-climate_light.svg b/ui/src/assets/icons/file-types/code-climate_light.svg
new file mode 100644
index 0000000..dd18ba5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/code-climate_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/codecov.svg b/ui/src/assets/icons/file-types/codecov.svg
new file mode 100644
index 0000000..9a8d4eb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/codecov.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/codeowners.svg b/ui/src/assets/icons/file-types/codeowners.svg
new file mode 100644
index 0000000..553c60f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/codeowners.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/coderabbit-ai.svg b/ui/src/assets/icons/file-types/coderabbit-ai.svg
new file mode 100644
index 0000000..5d1b6c9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/coderabbit-ai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/coffee.svg b/ui/src/assets/icons/file-types/coffee.svg
new file mode 100644
index 0000000..f81b65c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/coffee.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/coldfusion.svg b/ui/src/assets/icons/file-types/coldfusion.svg
new file mode 100644
index 0000000..d018b66
--- /dev/null
+++ b/ui/src/assets/icons/file-types/coldfusion.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/coloredpetrinets.svg b/ui/src/assets/icons/file-types/coloredpetrinets.svg
new file mode 100644
index 0000000..bd61261
--- /dev/null
+++ b/ui/src/assets/icons/file-types/coloredpetrinets.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/command.svg b/ui/src/assets/icons/file-types/command.svg
new file mode 100644
index 0000000..b5a7913
--- /dev/null
+++ b/ui/src/assets/icons/file-types/command.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/commitizen.svg b/ui/src/assets/icons/file-types/commitizen.svg
new file mode 100644
index 0000000..2467d2c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/commitizen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/commitlint.svg b/ui/src/assets/icons/file-types/commitlint.svg
new file mode 100644
index 0000000..c42144a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/commitlint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/concourse.svg b/ui/src/assets/icons/file-types/concourse.svg
new file mode 100644
index 0000000..c34f23e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/concourse.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/conduct.svg b/ui/src/assets/icons/file-types/conduct.svg
new file mode 100644
index 0000000..97eb6fc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/conduct.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/console.svg b/ui/src/assets/icons/file-types/console.svg
new file mode 100644
index 0000000..75f90b7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/console.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/contentlayer.svg b/ui/src/assets/icons/file-types/contentlayer.svg
new file mode 100644
index 0000000..441f690
--- /dev/null
+++ b/ui/src/assets/icons/file-types/contentlayer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/context.svg b/ui/src/assets/icons/file-types/context.svg
new file mode 100644
index 0000000..1b8200e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/context.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/contributing.svg b/ui/src/assets/icons/file-types/contributing.svg
new file mode 100644
index 0000000..13666a0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/contributing.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/controller.svg b/ui/src/assets/icons/file-types/controller.svg
new file mode 100644
index 0000000..9f99264
--- /dev/null
+++ b/ui/src/assets/icons/file-types/controller.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/copilot.svg b/ui/src/assets/icons/file-types/copilot.svg
new file mode 100644
index 0000000..24e89af
--- /dev/null
+++ b/ui/src/assets/icons/file-types/copilot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/copilot_light.svg b/ui/src/assets/icons/file-types/copilot_light.svg
new file mode 100644
index 0000000..9bc56ea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/copilot_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cpp.svg b/ui/src/assets/icons/file-types/cpp.svg
new file mode 100644
index 0000000..16534ac
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cpp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/craco.svg b/ui/src/assets/icons/file-types/craco.svg
new file mode 100644
index 0000000..96ba458
--- /dev/null
+++ b/ui/src/assets/icons/file-types/craco.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/credits.svg b/ui/src/assets/icons/file-types/credits.svg
new file mode 100644
index 0000000..b67c55a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/credits.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/crystal.svg b/ui/src/assets/icons/file-types/crystal.svg
new file mode 100644
index 0000000..e3796bf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/crystal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/crystal_light.svg b/ui/src/assets/icons/file-types/crystal_light.svg
new file mode 100644
index 0000000..ca387f4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/crystal_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/csharp.svg b/ui/src/assets/icons/file-types/csharp.svg
new file mode 100644
index 0000000..02b1be3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/csharp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/css-map.svg b/ui/src/assets/icons/file-types/css-map.svg
new file mode 100644
index 0000000..55b74c0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/css-map.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/css.svg b/ui/src/assets/icons/file-types/css.svg
new file mode 100644
index 0000000..1acad1b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/css.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cucumber.svg b/ui/src/assets/icons/file-types/cucumber.svg
new file mode 100644
index 0000000..052fd29
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cucumber.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cuda.svg b/ui/src/assets/icons/file-types/cuda.svg
new file mode 100644
index 0000000..cc57a60
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cuda.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/cursor.svg b/ui/src/assets/icons/file-types/cursor.svg
new file mode 100644
index 0000000..b754147
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cursor.svg
@@ -0,0 +1 @@
+
diff --git a/ui/src/assets/icons/file-types/cursor_light.svg b/ui/src/assets/icons/file-types/cursor_light.svg
new file mode 100644
index 0000000..f65b646
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cursor_light.svg
@@ -0,0 +1 @@
+
diff --git a/ui/src/assets/icons/file-types/cypress.svg b/ui/src/assets/icons/file-types/cypress.svg
new file mode 100644
index 0000000..35274d3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/cypress.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/d.svg b/ui/src/assets/icons/file-types/d.svg
new file mode 100644
index 0000000..3207725
--- /dev/null
+++ b/ui/src/assets/icons/file-types/d.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dart.svg b/ui/src/assets/icons/file-types/dart.svg
new file mode 100644
index 0000000..04b22d0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dart.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dart_generated.svg b/ui/src/assets/icons/file-types/dart_generated.svg
new file mode 100644
index 0000000..8f64f5f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dart_generated.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/database.svg b/ui/src/assets/icons/file-types/database.svg
new file mode 100644
index 0000000..b107234
--- /dev/null
+++ b/ui/src/assets/icons/file-types/database.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/deepsource.svg b/ui/src/assets/icons/file-types/deepsource.svg
new file mode 100644
index 0000000..d70fd46
--- /dev/null
+++ b/ui/src/assets/icons/file-types/deepsource.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/denizenscript.svg b/ui/src/assets/icons/file-types/denizenscript.svg
new file mode 100644
index 0000000..2debb9d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/denizenscript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/deno.svg b/ui/src/assets/icons/file-types/deno.svg
new file mode 100644
index 0000000..344a12e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/deno.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/deno_light.svg b/ui/src/assets/icons/file-types/deno_light.svg
new file mode 100644
index 0000000..ee82ea7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/deno_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dependabot.svg b/ui/src/assets/icons/file-types/dependabot.svg
new file mode 100644
index 0000000..3b101a1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dependabot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dependencies-update.svg b/ui/src/assets/icons/file-types/dependencies-update.svg
new file mode 100644
index 0000000..b85ad9e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dependencies-update.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dhall.svg b/ui/src/assets/icons/file-types/dhall.svg
new file mode 100644
index 0000000..0be9411
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dhall.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/diff.svg b/ui/src/assets/icons/file-types/diff.svg
new file mode 100644
index 0000000..ea3068c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/diff.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dinophp.svg b/ui/src/assets/icons/file-types/dinophp.svg
new file mode 100644
index 0000000..8e6ef29
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dinophp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/disc.svg b/ui/src/assets/icons/file-types/disc.svg
new file mode 100644
index 0000000..b0d74dc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/disc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/django.svg b/ui/src/assets/icons/file-types/django.svg
new file mode 100644
index 0000000..64c9ee3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/django.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dll.svg b/ui/src/assets/icons/file-types/dll.svg
new file mode 100644
index 0000000..0646cbb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dll.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/docker.svg b/ui/src/assets/icons/file-types/docker.svg
new file mode 100644
index 0000000..7d6a1a5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/docker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/doctex-installer.svg b/ui/src/assets/icons/file-types/doctex-installer.svg
new file mode 100644
index 0000000..5bdb443
--- /dev/null
+++ b/ui/src/assets/icons/file-types/doctex-installer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/document.svg b/ui/src/assets/icons/file-types/document.svg
new file mode 100644
index 0000000..a717956
--- /dev/null
+++ b/ui/src/assets/icons/file-types/document.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dotjs.svg b/ui/src/assets/icons/file-types/dotjs.svg
new file mode 100644
index 0000000..5ac893c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dotjs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/drawio.svg b/ui/src/assets/icons/file-types/drawio.svg
new file mode 100644
index 0000000..8ef1bcb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/drawio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/drizzle.svg b/ui/src/assets/icons/file-types/drizzle.svg
new file mode 100644
index 0000000..72f1b21
--- /dev/null
+++ b/ui/src/assets/icons/file-types/drizzle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/drone.svg b/ui/src/assets/icons/file-types/drone.svg
new file mode 100644
index 0000000..5e3082d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/drone.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/drone_light.svg b/ui/src/assets/icons/file-types/drone_light.svg
new file mode 100644
index 0000000..ce3ad25
--- /dev/null
+++ b/ui/src/assets/icons/file-types/drone_light.svg
@@ -0,0 +1,4 @@
+
+
+
diff --git a/ui/src/assets/icons/file-types/duc.svg b/ui/src/assets/icons/file-types/duc.svg
new file mode 100644
index 0000000..1d85b34
--- /dev/null
+++ b/ui/src/assets/icons/file-types/duc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/dune.svg b/ui/src/assets/icons/file-types/dune.svg
new file mode 100644
index 0000000..1a35e70
--- /dev/null
+++ b/ui/src/assets/icons/file-types/dune.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/edge.svg b/ui/src/assets/icons/file-types/edge.svg
new file mode 100644
index 0000000..298b558
--- /dev/null
+++ b/ui/src/assets/icons/file-types/edge.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/editorconfig.svg b/ui/src/assets/icons/file-types/editorconfig.svg
new file mode 100644
index 0000000..ba52899
--- /dev/null
+++ b/ui/src/assets/icons/file-types/editorconfig.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ejs.svg b/ui/src/assets/icons/file-types/ejs.svg
new file mode 100644
index 0000000..6ead40e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ejs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/elixir.svg b/ui/src/assets/icons/file-types/elixir.svg
new file mode 100644
index 0000000..d40f90b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/elixir.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/elm.svg b/ui/src/assets/icons/file-types/elm.svg
new file mode 100644
index 0000000..c17b74d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/elm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/email.svg b/ui/src/assets/icons/file-types/email.svg
new file mode 100644
index 0000000..a603e14
--- /dev/null
+++ b/ui/src/assets/icons/file-types/email.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ember.svg b/ui/src/assets/icons/file-types/ember.svg
new file mode 100644
index 0000000..c16cef1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ember.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/epub.svg b/ui/src/assets/icons/file-types/epub.svg
new file mode 100644
index 0000000..98f11d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/epub.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/erlang.svg b/ui/src/assets/icons/file-types/erlang.svg
new file mode 100644
index 0000000..41025d6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/erlang.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/esbuild.svg b/ui/src/assets/icons/file-types/esbuild.svg
new file mode 100644
index 0000000..e682d6b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/esbuild.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/eslint.svg b/ui/src/assets/icons/file-types/eslint.svg
new file mode 100644
index 0000000..54fe8cc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/eslint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/excalidraw.svg b/ui/src/assets/icons/file-types/excalidraw.svg
new file mode 100644
index 0000000..c1e1bca
--- /dev/null
+++ b/ui/src/assets/icons/file-types/excalidraw.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/exe.svg b/ui/src/assets/icons/file-types/exe.svg
new file mode 100644
index 0000000..dde947d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/exe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/fastlane.svg b/ui/src/assets/icons/file-types/fastlane.svg
new file mode 100644
index 0000000..44d042f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/fastlane.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/favicon.svg b/ui/src/assets/icons/file-types/favicon.svg
new file mode 100644
index 0000000..21abf66
--- /dev/null
+++ b/ui/src/assets/icons/file-types/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/figma.svg b/ui/src/assets/icons/file-types/figma.svg
new file mode 100644
index 0000000..db4522b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/figma.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/firebase.svg b/ui/src/assets/icons/file-types/firebase.svg
new file mode 100644
index 0000000..bb3b63c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/firebase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/flash.svg b/ui/src/assets/icons/file-types/flash.svg
new file mode 100644
index 0000000..abd6e01
--- /dev/null
+++ b/ui/src/assets/icons/file-types/flash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/flow.svg b/ui/src/assets/icons/file-types/flow.svg
new file mode 100644
index 0000000..0591981
--- /dev/null
+++ b/ui/src/assets/icons/file-types/flow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-admin-open.svg b/ui/src/assets/icons/file-types/folder-admin-open.svg
new file mode 100644
index 0000000..5e77464
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-admin-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-admin.svg b/ui/src/assets/icons/file-types/folder-admin.svg
new file mode 100644
index 0000000..f8d1ea1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-admin.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-android-open.svg b/ui/src/assets/icons/file-types/folder-android-open.svg
new file mode 100644
index 0000000..cdd8376
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-android-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-android.svg b/ui/src/assets/icons/file-types/folder-android.svg
new file mode 100644
index 0000000..7ee8a46
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-android.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-angular-open.svg b/ui/src/assets/icons/file-types/folder-angular-open.svg
new file mode 100644
index 0000000..60c604e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-angular-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-angular.svg b/ui/src/assets/icons/file-types/folder-angular.svg
new file mode 100644
index 0000000..3d8c87d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-angular.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-animation-open.svg b/ui/src/assets/icons/file-types/folder-animation-open.svg
new file mode 100644
index 0000000..637a3af
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-animation-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-animation.svg b/ui/src/assets/icons/file-types/folder-animation.svg
new file mode 100644
index 0000000..6b5bb69
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-animation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ansible-open.svg b/ui/src/assets/icons/file-types/folder-ansible-open.svg
new file mode 100644
index 0000000..96df458
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ansible-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ansible.svg b/ui/src/assets/icons/file-types/folder-ansible.svg
new file mode 100644
index 0000000..f430315
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ansible.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-api-open.svg b/ui/src/assets/icons/file-types/folder-api-open.svg
new file mode 100644
index 0000000..ac3edb9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-api-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-api.svg b/ui/src/assets/icons/file-types/folder-api.svg
new file mode 100644
index 0000000..bf1d64c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-api.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-apollo-open.svg b/ui/src/assets/icons/file-types/folder-apollo-open.svg
new file mode 100644
index 0000000..f0febaf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-apollo-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-apollo.svg b/ui/src/assets/icons/file-types/folder-apollo.svg
new file mode 100644
index 0000000..7eb6107
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-apollo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-app-open.svg b/ui/src/assets/icons/file-types/folder-app-open.svg
new file mode 100644
index 0000000..c9da6a7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-app-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-app.svg b/ui/src/assets/icons/file-types/folder-app.svg
new file mode 100644
index 0000000..d0e37f1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-app.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-archive-open.svg b/ui/src/assets/icons/file-types/folder-archive-open.svg
new file mode 100644
index 0000000..6af2a9f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-archive-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-archive.svg b/ui/src/assets/icons/file-types/folder-archive.svg
new file mode 100644
index 0000000..b018654
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-archive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-astro-open.svg b/ui/src/assets/icons/file-types/folder-astro-open.svg
new file mode 100644
index 0000000..282a3ce
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-astro-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-astro.svg b/ui/src/assets/icons/file-types/folder-astro.svg
new file mode 100644
index 0000000..b324019
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-astro.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-atom-open.svg b/ui/src/assets/icons/file-types/folder-atom-open.svg
new file mode 100644
index 0000000..5558d18
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-atom-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-atom.svg b/ui/src/assets/icons/file-types/folder-atom.svg
new file mode 100644
index 0000000..c272f6e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-atom.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-attachment-open.svg b/ui/src/assets/icons/file-types/folder-attachment-open.svg
new file mode 100644
index 0000000..7a9af66
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-attachment-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-attachment.svg b/ui/src/assets/icons/file-types/folder-attachment.svg
new file mode 100644
index 0000000..3b9992e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-attachment.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-audio-open.svg b/ui/src/assets/icons/file-types/folder-audio-open.svg
new file mode 100644
index 0000000..6d9b238
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-audio-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-audio.svg b/ui/src/assets/icons/file-types/folder-audio.svg
new file mode 100644
index 0000000..e3d0db3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-audio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-aurelia-open.svg b/ui/src/assets/icons/file-types/folder-aurelia-open.svg
new file mode 100644
index 0000000..bacae24
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-aurelia-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-aurelia.svg b/ui/src/assets/icons/file-types/folder-aurelia.svg
new file mode 100644
index 0000000..61ee59e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-aurelia.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-aws-open.svg b/ui/src/assets/icons/file-types/folder-aws-open.svg
new file mode 100644
index 0000000..9e530d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-aws-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-aws.svg b/ui/src/assets/icons/file-types/folder-aws.svg
new file mode 100644
index 0000000..769755d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-aws.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-azure-pipelines-open.svg b/ui/src/assets/icons/file-types/folder-azure-pipelines-open.svg
new file mode 100644
index 0000000..9253cd5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-azure-pipelines-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-azure-pipelines.svg b/ui/src/assets/icons/file-types/folder-azure-pipelines.svg
new file mode 100644
index 0000000..a0fef25
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-azure-pipelines.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-backup-open.svg b/ui/src/assets/icons/file-types/folder-backup-open.svg
new file mode 100644
index 0000000..c2914ee
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-backup-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-backup.svg b/ui/src/assets/icons/file-types/folder-backup.svg
new file mode 100644
index 0000000..aa9a6c9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-backup.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-base-open.svg b/ui/src/assets/icons/file-types/folder-base-open.svg
new file mode 100644
index 0000000..e84bc36
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-base-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-base.svg b/ui/src/assets/icons/file-types/folder-base.svg
new file mode 100644
index 0000000..1944100
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-base.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-batch-open.svg b/ui/src/assets/icons/file-types/folder-batch-open.svg
new file mode 100644
index 0000000..1db45e1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-batch-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-batch.svg b/ui/src/assets/icons/file-types/folder-batch.svg
new file mode 100644
index 0000000..c44a66b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-batch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-benchmark-open.svg b/ui/src/assets/icons/file-types/folder-benchmark-open.svg
new file mode 100644
index 0000000..fa7b3ea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-benchmark-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-benchmark.svg b/ui/src/assets/icons/file-types/folder-benchmark.svg
new file mode 100644
index 0000000..8291d68
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-benchmark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bibliography-open.svg b/ui/src/assets/icons/file-types/folder-bibliography-open.svg
new file mode 100644
index 0000000..81b6cde
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bibliography-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bibliography.svg b/ui/src/assets/icons/file-types/folder-bibliography.svg
new file mode 100644
index 0000000..aa1e92a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bibliography.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bicep-open.svg b/ui/src/assets/icons/file-types/folder-bicep-open.svg
new file mode 100644
index 0000000..72519ce
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bicep-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bicep.svg b/ui/src/assets/icons/file-types/folder-bicep.svg
new file mode 100644
index 0000000..b336ff5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bicep.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-blender-open.svg b/ui/src/assets/icons/file-types/folder-blender-open.svg
new file mode 100644
index 0000000..1c80d73
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-blender-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-blender.svg b/ui/src/assets/icons/file-types/folder-blender.svg
new file mode 100644
index 0000000..6f56dce
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-blender.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bloc-open.svg b/ui/src/assets/icons/file-types/folder-bloc-open.svg
new file mode 100644
index 0000000..8833e5f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bloc-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bloc.svg b/ui/src/assets/icons/file-types/folder-bloc.svg
new file mode 100644
index 0000000..cf08363
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bloc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bower-open.svg b/ui/src/assets/icons/file-types/folder-bower-open.svg
new file mode 100644
index 0000000..659f87c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bower-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-bower.svg b/ui/src/assets/icons/file-types/folder-bower.svg
new file mode 100644
index 0000000..6bfd654
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-bower.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-buildkite-open.svg b/ui/src/assets/icons/file-types/folder-buildkite-open.svg
new file mode 100644
index 0000000..872db64
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-buildkite-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-buildkite.svg b/ui/src/assets/icons/file-types/folder-buildkite.svg
new file mode 100644
index 0000000..9512b40
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-buildkite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cart-open.svg b/ui/src/assets/icons/file-types/folder-cart-open.svg
new file mode 100644
index 0000000..4471a77
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cart-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cart.svg b/ui/src/assets/icons/file-types/folder-cart.svg
new file mode 100644
index 0000000..d19a627
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cart.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-changesets-open.svg b/ui/src/assets/icons/file-types/folder-changesets-open.svg
new file mode 100644
index 0000000..c389233
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-changesets-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-changesets.svg b/ui/src/assets/icons/file-types/folder-changesets.svg
new file mode 100644
index 0000000..fc071f4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-changesets.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ci-open.svg b/ui/src/assets/icons/file-types/folder-ci-open.svg
new file mode 100644
index 0000000..57ac1ba
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ci-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ci.svg b/ui/src/assets/icons/file-types/folder-ci.svg
new file mode 100644
index 0000000..4fdc2ed
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ci.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-circleci-open.svg b/ui/src/assets/icons/file-types/folder-circleci-open.svg
new file mode 100644
index 0000000..9e323ff
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-circleci-open.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-circleci.svg b/ui/src/assets/icons/file-types/folder-circleci.svg
new file mode 100644
index 0000000..ef32518
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-circleci.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-class-open.svg b/ui/src/assets/icons/file-types/folder-class-open.svg
new file mode 100644
index 0000000..9c5b101
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-class-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-class.svg b/ui/src/assets/icons/file-types/folder-class.svg
new file mode 100644
index 0000000..8225cf1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-class.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-claude-open.svg b/ui/src/assets/icons/file-types/folder-claude-open.svg
new file mode 100644
index 0000000..1a52afd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-claude-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-claude.svg b/ui/src/assets/icons/file-types/folder-claude.svg
new file mode 100644
index 0000000..0c85a13
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-claude.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-client-open.svg b/ui/src/assets/icons/file-types/folder-client-open.svg
new file mode 100644
index 0000000..ceec8f1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-client-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-client.svg b/ui/src/assets/icons/file-types/folder-client.svg
new file mode 100644
index 0000000..fbfaee7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-client.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cline-open.svg b/ui/src/assets/icons/file-types/folder-cline-open.svg
new file mode 100644
index 0000000..67ef7a2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cline-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cline.svg b/ui/src/assets/icons/file-types/folder-cline.svg
new file mode 100644
index 0000000..8fec96d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cloud-functions-open.svg b/ui/src/assets/icons/file-types/folder-cloud-functions-open.svg
new file mode 100644
index 0000000..b3ce0e4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cloud-functions-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cloud-functions.svg b/ui/src/assets/icons/file-types/folder-cloud-functions.svg
new file mode 100644
index 0000000..8dac84a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cloud-functions.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cloudflare-open.svg b/ui/src/assets/icons/file-types/folder-cloudflare-open.svg
new file mode 100644
index 0000000..d7022ab
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cloudflare-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cloudflare.svg b/ui/src/assets/icons/file-types/folder-cloudflare.svg
new file mode 100644
index 0000000..0cc444e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cloudflare.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cluster-open.svg b/ui/src/assets/icons/file-types/folder-cluster-open.svg
new file mode 100644
index 0000000..3688433
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cluster-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cluster.svg b/ui/src/assets/icons/file-types/folder-cluster.svg
new file mode 100644
index 0000000..77f5b8a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cluster.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cobol-open.svg b/ui/src/assets/icons/file-types/folder-cobol-open.svg
new file mode 100644
index 0000000..0f5e315
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cobol-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cobol.svg b/ui/src/assets/icons/file-types/folder-cobol.svg
new file mode 100644
index 0000000..ea0f54d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cobol.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-command-open.svg b/ui/src/assets/icons/file-types/folder-command-open.svg
new file mode 100644
index 0000000..ca9d4df
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-command-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-command.svg b/ui/src/assets/icons/file-types/folder-command.svg
new file mode 100644
index 0000000..4015207
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-command.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-components-open.svg b/ui/src/assets/icons/file-types/folder-components-open.svg
new file mode 100644
index 0000000..2f55b72
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-components-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-components.svg b/ui/src/assets/icons/file-types/folder-components.svg
new file mode 100644
index 0000000..983833e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-components.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-config-open.svg b/ui/src/assets/icons/file-types/folder-config-open.svg
new file mode 100644
index 0000000..3b4ec5a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-config-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-config.svg b/ui/src/assets/icons/file-types/folder-config.svg
new file mode 100644
index 0000000..8519910
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-config.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-connection-open.svg b/ui/src/assets/icons/file-types/folder-connection-open.svg
new file mode 100644
index 0000000..4d14f09
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-connection-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-connection.svg b/ui/src/assets/icons/file-types/folder-connection.svg
new file mode 100644
index 0000000..f46d526
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-connection.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-console-open.svg b/ui/src/assets/icons/file-types/folder-console-open.svg
new file mode 100644
index 0000000..99384a8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-console-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-console.svg b/ui/src/assets/icons/file-types/folder-console.svg
new file mode 100644
index 0000000..301b10d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-console.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-constant-open.svg b/ui/src/assets/icons/file-types/folder-constant-open.svg
new file mode 100644
index 0000000..9e8791d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-constant-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-constant.svg b/ui/src/assets/icons/file-types/folder-constant.svg
new file mode 100644
index 0000000..99a2291
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-constant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-container-open.svg b/ui/src/assets/icons/file-types/folder-container-open.svg
new file mode 100644
index 0000000..9db8334
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-container-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-container.svg b/ui/src/assets/icons/file-types/folder-container.svg
new file mode 100644
index 0000000..3ea03c1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-container.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-content-open.svg b/ui/src/assets/icons/file-types/folder-content-open.svg
new file mode 100644
index 0000000..a924b27
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-content-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-content.svg b/ui/src/assets/icons/file-types/folder-content.svg
new file mode 100644
index 0000000..23f57d2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-content.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-context-open.svg b/ui/src/assets/icons/file-types/folder-context-open.svg
new file mode 100644
index 0000000..a631e02
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-context-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-context.svg b/ui/src/assets/icons/file-types/folder-context.svg
new file mode 100644
index 0000000..bee74c1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-context.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-contract-open.svg b/ui/src/assets/icons/file-types/folder-contract-open.svg
new file mode 100644
index 0000000..6878c76
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-contract-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-contract.svg b/ui/src/assets/icons/file-types/folder-contract.svg
new file mode 100644
index 0000000..2ea0abb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-contract.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-controller-open.svg b/ui/src/assets/icons/file-types/folder-controller-open.svg
new file mode 100644
index 0000000..a732ed1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-controller-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-controller.svg b/ui/src/assets/icons/file-types/folder-controller.svg
new file mode 100644
index 0000000..f98cd6f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-controller.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-core-open.svg b/ui/src/assets/icons/file-types/folder-core-open.svg
new file mode 100644
index 0000000..34e7a82
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-core-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-core.svg b/ui/src/assets/icons/file-types/folder-core.svg
new file mode 100644
index 0000000..f7cfae6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-core.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-coverage-open.svg b/ui/src/assets/icons/file-types/folder-coverage-open.svg
new file mode 100644
index 0000000..5d47b2f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-coverage-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-coverage.svg b/ui/src/assets/icons/file-types/folder-coverage.svg
new file mode 100644
index 0000000..7a75f71
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-coverage.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-css-open.svg b/ui/src/assets/icons/file-types/folder-css-open.svg
new file mode 100644
index 0000000..ef79791
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-css-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-css.svg b/ui/src/assets/icons/file-types/folder-css.svg
new file mode 100644
index 0000000..4ff433e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-css.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cursor-open.svg b/ui/src/assets/icons/file-types/folder-cursor-open.svg
new file mode 100644
index 0000000..b6e1068
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cursor-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cursor-open_light.svg b/ui/src/assets/icons/file-types/folder-cursor-open_light.svg
new file mode 100644
index 0000000..c960112
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cursor-open_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cursor.svg b/ui/src/assets/icons/file-types/folder-cursor.svg
new file mode 100644
index 0000000..4672608
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cursor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cursor_light.svg b/ui/src/assets/icons/file-types/folder-cursor_light.svg
new file mode 100644
index 0000000..391be56
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cursor_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-custom-open.svg b/ui/src/assets/icons/file-types/folder-custom-open.svg
new file mode 100644
index 0000000..fe747d2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-custom-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-custom.svg b/ui/src/assets/icons/file-types/folder-custom.svg
new file mode 100644
index 0000000..02ac611
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-custom.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cypress-open.svg b/ui/src/assets/icons/file-types/folder-cypress-open.svg
new file mode 100644
index 0000000..2a18521
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cypress-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-cypress.svg b/ui/src/assets/icons/file-types/folder-cypress.svg
new file mode 100644
index 0000000..39460e2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-cypress.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-dart-open.svg b/ui/src/assets/icons/file-types/folder-dart-open.svg
new file mode 100644
index 0000000..8eadca0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-dart-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-dart.svg b/ui/src/assets/icons/file-types/folder-dart.svg
new file mode 100644
index 0000000..0de1518
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-dart.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-database-open.svg b/ui/src/assets/icons/file-types/folder-database-open.svg
new file mode 100644
index 0000000..5bde146
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-database-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-database.svg b/ui/src/assets/icons/file-types/folder-database.svg
new file mode 100644
index 0000000..b256e64
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-database.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-debug-open.svg b/ui/src/assets/icons/file-types/folder-debug-open.svg
new file mode 100644
index 0000000..a0c16a7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-debug-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-debug.svg b/ui/src/assets/icons/file-types/folder-debug.svg
new file mode 100644
index 0000000..1099873
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-debug.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-decorators-open.svg b/ui/src/assets/icons/file-types/folder-decorators-open.svg
new file mode 100644
index 0000000..ff42dde
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-decorators-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-decorators.svg b/ui/src/assets/icons/file-types/folder-decorators.svg
new file mode 100644
index 0000000..fcc746d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-decorators.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-delta-open.svg b/ui/src/assets/icons/file-types/folder-delta-open.svg
new file mode 100644
index 0000000..c2b5663
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-delta-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-delta.svg b/ui/src/assets/icons/file-types/folder-delta.svg
new file mode 100644
index 0000000..cdda479
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-delta.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-desktop-open.svg b/ui/src/assets/icons/file-types/folder-desktop-open.svg
new file mode 100644
index 0000000..880ca76
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-desktop-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-desktop.svg b/ui/src/assets/icons/file-types/folder-desktop.svg
new file mode 100644
index 0000000..5a20b49
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-desktop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-directive-open.svg b/ui/src/assets/icons/file-types/folder-directive-open.svg
new file mode 100644
index 0000000..71946e5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-directive-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-directive.svg b/ui/src/assets/icons/file-types/folder-directive.svg
new file mode 100644
index 0000000..4197c68
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-directive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-dist-open.svg b/ui/src/assets/icons/file-types/folder-dist-open.svg
new file mode 100644
index 0000000..553cef1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-dist-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-dist.svg b/ui/src/assets/icons/file-types/folder-dist.svg
new file mode 100644
index 0000000..995580f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-dist.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-docker-open.svg b/ui/src/assets/icons/file-types/folder-docker-open.svg
new file mode 100644
index 0000000..a76e97b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-docker-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-docker.svg b/ui/src/assets/icons/file-types/folder-docker.svg
new file mode 100644
index 0000000..c5b0949
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-docker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-docs-open.svg b/ui/src/assets/icons/file-types/folder-docs-open.svg
new file mode 100644
index 0000000..3577767
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-docs-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-docs.svg b/ui/src/assets/icons/file-types/folder-docs.svg
new file mode 100644
index 0000000..246a05d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-docs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-download-open.svg b/ui/src/assets/icons/file-types/folder-download-open.svg
new file mode 100644
index 0000000..ddb9c24
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-download-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-download.svg b/ui/src/assets/icons/file-types/folder-download.svg
new file mode 100644
index 0000000..34105b9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-download.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-drizzle-open.svg b/ui/src/assets/icons/file-types/folder-drizzle-open.svg
new file mode 100644
index 0000000..5f0cd59
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-drizzle-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-drizzle.svg b/ui/src/assets/icons/file-types/folder-drizzle.svg
new file mode 100644
index 0000000..d01a186
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-drizzle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-dump-open.svg b/ui/src/assets/icons/file-types/folder-dump-open.svg
new file mode 100644
index 0000000..b4de7f8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-dump-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-dump.svg b/ui/src/assets/icons/file-types/folder-dump.svg
new file mode 100644
index 0000000..8178fcc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-dump.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-element-open.svg b/ui/src/assets/icons/file-types/folder-element-open.svg
new file mode 100644
index 0000000..32dc7cd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-element-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-element.svg b/ui/src/assets/icons/file-types/folder-element.svg
new file mode 100644
index 0000000..d67a85a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-element.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-enum-open.svg b/ui/src/assets/icons/file-types/folder-enum-open.svg
new file mode 100644
index 0000000..92782b1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-enum-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-enum.svg b/ui/src/assets/icons/file-types/folder-enum.svg
new file mode 100644
index 0000000..fa852ef
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-enum.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-environment-open.svg b/ui/src/assets/icons/file-types/folder-environment-open.svg
new file mode 100644
index 0000000..3b56abb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-environment-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-environment.svg b/ui/src/assets/icons/file-types/folder-environment.svg
new file mode 100644
index 0000000..9cc1f2e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-environment.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-error-open.svg b/ui/src/assets/icons/file-types/folder-error-open.svg
new file mode 100644
index 0000000..81f0ffc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-error-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-error.svg b/ui/src/assets/icons/file-types/folder-error.svg
new file mode 100644
index 0000000..3bd1d85
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-error.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-event-open.svg b/ui/src/assets/icons/file-types/folder-event-open.svg
new file mode 100644
index 0000000..28c018d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-event-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-event.svg b/ui/src/assets/icons/file-types/folder-event.svg
new file mode 100644
index 0000000..f54dea6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-event.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-examples-open.svg b/ui/src/assets/icons/file-types/folder-examples-open.svg
new file mode 100644
index 0000000..78c77a9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-examples-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-examples.svg b/ui/src/assets/icons/file-types/folder-examples.svg
new file mode 100644
index 0000000..fba8885
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-examples.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-expo-open.svg b/ui/src/assets/icons/file-types/folder-expo-open.svg
new file mode 100644
index 0000000..614435a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-expo-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-expo.svg b/ui/src/assets/icons/file-types/folder-expo.svg
new file mode 100644
index 0000000..820a998
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-expo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-export-open.svg b/ui/src/assets/icons/file-types/folder-export-open.svg
new file mode 100644
index 0000000..f03eb1b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-export-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-export.svg b/ui/src/assets/icons/file-types/folder-export.svg
new file mode 100644
index 0000000..1b3e3ab
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-export.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-fastlane-open.svg b/ui/src/assets/icons/file-types/folder-fastlane-open.svg
new file mode 100644
index 0000000..5efb2ac
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-fastlane-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-fastlane.svg b/ui/src/assets/icons/file-types/folder-fastlane.svg
new file mode 100644
index 0000000..eb90566
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-fastlane.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-favicon-open.svg b/ui/src/assets/icons/file-types/folder-favicon-open.svg
new file mode 100644
index 0000000..b716525
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-favicon-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-favicon.svg b/ui/src/assets/icons/file-types/folder-favicon.svg
new file mode 100644
index 0000000..6ef90d9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-firebase-open.svg b/ui/src/assets/icons/file-types/folder-firebase-open.svg
new file mode 100644
index 0000000..7149b48
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-firebase-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-firebase.svg b/ui/src/assets/icons/file-types/folder-firebase.svg
new file mode 100644
index 0000000..9eeac86
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-firebase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-firestore-open.svg b/ui/src/assets/icons/file-types/folder-firestore-open.svg
new file mode 100644
index 0000000..a3e6eda
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-firestore-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-firestore.svg b/ui/src/assets/icons/file-types/folder-firestore.svg
new file mode 100644
index 0000000..cb1249a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-firestore.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-flow-open.svg b/ui/src/assets/icons/file-types/folder-flow-open.svg
new file mode 100644
index 0000000..a72dd76
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-flow-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-flow.svg b/ui/src/assets/icons/file-types/folder-flow.svg
new file mode 100644
index 0000000..0155189
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-flow.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-flutter-open.svg b/ui/src/assets/icons/file-types/folder-flutter-open.svg
new file mode 100644
index 0000000..b95a8ce
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-flutter-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-flutter.svg b/ui/src/assets/icons/file-types/folder-flutter.svg
new file mode 100644
index 0000000..e5ffced
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-flutter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-font-open.svg b/ui/src/assets/icons/file-types/folder-font-open.svg
new file mode 100644
index 0000000..1a91f0b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-font-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-font.svg b/ui/src/assets/icons/file-types/folder-font.svg
new file mode 100644
index 0000000..0115b73
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-font.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-forgejo-open.svg b/ui/src/assets/icons/file-types/folder-forgejo-open.svg
new file mode 100644
index 0000000..a976222
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-forgejo-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-forgejo.svg b/ui/src/assets/icons/file-types/folder-forgejo.svg
new file mode 100644
index 0000000..0eaccff
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-forgejo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-functions-open.svg b/ui/src/assets/icons/file-types/folder-functions-open.svg
new file mode 100644
index 0000000..00d6dc4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-functions-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-functions.svg b/ui/src/assets/icons/file-types/folder-functions.svg
new file mode 100644
index 0000000..01a9385
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-functions.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gamemaker-open.svg b/ui/src/assets/icons/file-types/folder-gamemaker-open.svg
new file mode 100644
index 0000000..caf9a82
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gamemaker-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gamemaker.svg b/ui/src/assets/icons/file-types/folder-gamemaker.svg
new file mode 100644
index 0000000..625feb3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gamemaker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-generator-open.svg b/ui/src/assets/icons/file-types/folder-generator-open.svg
new file mode 100644
index 0000000..43b5047
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-generator-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-generator.svg b/ui/src/assets/icons/file-types/folder-generator.svg
new file mode 100644
index 0000000..5446582
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-generator.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg b/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg
new file mode 100644
index 0000000..3ae400e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-gh-workflows.svg b/ui/src/assets/icons/file-types/folder-gh-workflows.svg
new file mode 100644
index 0000000..3a868cc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gh-workflows.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-git-open.svg b/ui/src/assets/icons/file-types/folder-git-open.svg
new file mode 100644
index 0000000..90be1c1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-git-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-git.svg b/ui/src/assets/icons/file-types/folder-git.svg
new file mode 100644
index 0000000..2ca4db5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-git.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gitea-open.svg b/ui/src/assets/icons/file-types/folder-gitea-open.svg
new file mode 100644
index 0000000..239800c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gitea-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gitea.svg b/ui/src/assets/icons/file-types/folder-gitea.svg
new file mode 100644
index 0000000..ac041b3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gitea.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-github-open.svg b/ui/src/assets/icons/file-types/folder-github-open.svg
new file mode 100644
index 0000000..84e5bee
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-github-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-github.svg b/ui/src/assets/icons/file-types/folder-github.svg
new file mode 100644
index 0000000..374bcae
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-github.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-gitlab-open.svg b/ui/src/assets/icons/file-types/folder-gitlab-open.svg
new file mode 100644
index 0000000..fc4deb2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gitlab-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gitlab.svg b/ui/src/assets/icons/file-types/folder-gitlab.svg
new file mode 100644
index 0000000..55db99e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gitlab.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-global-open.svg b/ui/src/assets/icons/file-types/folder-global-open.svg
new file mode 100644
index 0000000..13e72e0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-global-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-global.svg b/ui/src/assets/icons/file-types/folder-global.svg
new file mode 100644
index 0000000..8ada6a6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-global.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-godot-open.svg b/ui/src/assets/icons/file-types/folder-godot-open.svg
new file mode 100644
index 0000000..fd78550
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-godot-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-godot.svg b/ui/src/assets/icons/file-types/folder-godot.svg
new file mode 100644
index 0000000..dc4b5d1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-godot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gradle-open.svg b/ui/src/assets/icons/file-types/folder-gradle-open.svg
new file mode 100644
index 0000000..51725e7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gradle-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gradle.svg b/ui/src/assets/icons/file-types/folder-gradle.svg
new file mode 100644
index 0000000..93e843d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gradle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-graphql-open.svg b/ui/src/assets/icons/file-types/folder-graphql-open.svg
new file mode 100644
index 0000000..ac23650
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-graphql-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-graphql.svg b/ui/src/assets/icons/file-types/folder-graphql.svg
new file mode 100644
index 0000000..1d7b1cc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-graphql.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-guard-open.svg b/ui/src/assets/icons/file-types/folder-guard-open.svg
new file mode 100644
index 0000000..f7031e2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-guard-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-guard.svg b/ui/src/assets/icons/file-types/folder-guard.svg
new file mode 100644
index 0000000..b4269ed
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-guard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gulp-open.svg b/ui/src/assets/icons/file-types/folder-gulp-open.svg
new file mode 100644
index 0000000..556e739
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gulp-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-gulp.svg b/ui/src/assets/icons/file-types/folder-gulp.svg
new file mode 100644
index 0000000..3395231
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-gulp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-helm-open.svg b/ui/src/assets/icons/file-types/folder-helm-open.svg
new file mode 100644
index 0000000..6bbf0cc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-helm-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-helm.svg b/ui/src/assets/icons/file-types/folder-helm.svg
new file mode 100644
index 0000000..7b7d7a7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-helm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-helper-open.svg b/ui/src/assets/icons/file-types/folder-helper-open.svg
new file mode 100644
index 0000000..6fca391
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-helper-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-helper.svg b/ui/src/assets/icons/file-types/folder-helper.svg
new file mode 100644
index 0000000..27a20d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-helper.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-home-open.svg b/ui/src/assets/icons/file-types/folder-home-open.svg
new file mode 100644
index 0000000..8b0f0ca
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-home-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-home.svg b/ui/src/assets/icons/file-types/folder-home.svg
new file mode 100644
index 0000000..a4deeef
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-home.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-hook-open.svg b/ui/src/assets/icons/file-types/folder-hook-open.svg
new file mode 100644
index 0000000..17d6231
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-hook-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-hook.svg b/ui/src/assets/icons/file-types/folder-hook.svg
new file mode 100644
index 0000000..2105709
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-hook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-husky-open.svg b/ui/src/assets/icons/file-types/folder-husky-open.svg
new file mode 100644
index 0000000..88c19e8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-husky-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-husky.svg b/ui/src/assets/icons/file-types/folder-husky.svg
new file mode 100644
index 0000000..1bbdc4c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-husky.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-i18n-open.svg b/ui/src/assets/icons/file-types/folder-i18n-open.svg
new file mode 100644
index 0000000..bc1a53c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-i18n-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-i18n.svg b/ui/src/assets/icons/file-types/folder-i18n.svg
new file mode 100644
index 0000000..6ef0283
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-i18n.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-images-open.svg b/ui/src/assets/icons/file-types/folder-images-open.svg
new file mode 100644
index 0000000..44a673b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-images-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-images.svg b/ui/src/assets/icons/file-types/folder-images.svg
new file mode 100644
index 0000000..5b63a6c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-images.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-import-open.svg b/ui/src/assets/icons/file-types/folder-import-open.svg
new file mode 100644
index 0000000..a58a7e6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-import-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-import.svg b/ui/src/assets/icons/file-types/folder-import.svg
new file mode 100644
index 0000000..0c0f42e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-include-open.svg b/ui/src/assets/icons/file-types/folder-include-open.svg
new file mode 100644
index 0000000..fc2c011
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-include-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-include.svg b/ui/src/assets/icons/file-types/folder-include.svg
new file mode 100644
index 0000000..117b91a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-include.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-intellij-open.svg b/ui/src/assets/icons/file-types/folder-intellij-open.svg
new file mode 100644
index 0000000..5839a2b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-intellij-open.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-intellij-open_light.svg b/ui/src/assets/icons/file-types/folder-intellij-open_light.svg
new file mode 100644
index 0000000..ccb6046
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-intellij-open_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-intellij.svg b/ui/src/assets/icons/file-types/folder-intellij.svg
new file mode 100644
index 0000000..c655f37
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-intellij.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-intellij_light.svg b/ui/src/assets/icons/file-types/folder-intellij_light.svg
new file mode 100644
index 0000000..97bc8c7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-intellij_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-interceptor-open.svg b/ui/src/assets/icons/file-types/folder-interceptor-open.svg
new file mode 100644
index 0000000..c91c42a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-interceptor-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-interceptor.svg b/ui/src/assets/icons/file-types/folder-interceptor.svg
new file mode 100644
index 0000000..e6cbf9f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-interceptor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-interface-open.svg b/ui/src/assets/icons/file-types/folder-interface-open.svg
new file mode 100644
index 0000000..ba54b0e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-interface-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-interface.svg b/ui/src/assets/icons/file-types/folder-interface.svg
new file mode 100644
index 0000000..993ce72
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-interface.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ios-open.svg b/ui/src/assets/icons/file-types/folder-ios-open.svg
new file mode 100644
index 0000000..112fee6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ios-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ios.svg b/ui/src/assets/icons/file-types/folder-ios.svg
new file mode 100644
index 0000000..7af3b85
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ios.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-java-open.svg b/ui/src/assets/icons/file-types/folder-java-open.svg
new file mode 100644
index 0000000..eb59229
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-java-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-java.svg b/ui/src/assets/icons/file-types/folder-java.svg
new file mode 100644
index 0000000..58fdd3d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-java.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-javascript-open.svg b/ui/src/assets/icons/file-types/folder-javascript-open.svg
new file mode 100644
index 0000000..581f3a2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-javascript-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-javascript.svg b/ui/src/assets/icons/file-types/folder-javascript.svg
new file mode 100644
index 0000000..97cf04c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-javascript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-jinja-open.svg b/ui/src/assets/icons/file-types/folder-jinja-open.svg
new file mode 100644
index 0000000..9c0b2b6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-jinja-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-jinja-open_light.svg b/ui/src/assets/icons/file-types/folder-jinja-open_light.svg
new file mode 100644
index 0000000..ffc940f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-jinja-open_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-jinja.svg b/ui/src/assets/icons/file-types/folder-jinja.svg
new file mode 100644
index 0000000..687efe3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-jinja.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-jinja_light.svg b/ui/src/assets/icons/file-types/folder-jinja_light.svg
new file mode 100644
index 0000000..c2b08bb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-jinja_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-job-open.svg b/ui/src/assets/icons/file-types/folder-job-open.svg
new file mode 100644
index 0000000..efd7cdf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-job-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-job.svg b/ui/src/assets/icons/file-types/folder-job.svg
new file mode 100644
index 0000000..9135aff
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-job.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-json-open.svg b/ui/src/assets/icons/file-types/folder-json-open.svg
new file mode 100644
index 0000000..29cdf2f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-json-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-json.svg b/ui/src/assets/icons/file-types/folder-json.svg
new file mode 100644
index 0000000..34085f6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-json.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-jupyter-open.svg b/ui/src/assets/icons/file-types/folder-jupyter-open.svg
new file mode 100644
index 0000000..d431953
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-jupyter-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-jupyter.svg b/ui/src/assets/icons/file-types/folder-jupyter.svg
new file mode 100644
index 0000000..d4d3eb3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-jupyter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-keys-open.svg b/ui/src/assets/icons/file-types/folder-keys-open.svg
new file mode 100644
index 0000000..783b16e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-keys-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-keys.svg b/ui/src/assets/icons/file-types/folder-keys.svg
new file mode 100644
index 0000000..3527f62
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-keys.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-kubernetes-open.svg b/ui/src/assets/icons/file-types/folder-kubernetes-open.svg
new file mode 100644
index 0000000..022be4d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-kubernetes-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-kubernetes.svg b/ui/src/assets/icons/file-types/folder-kubernetes.svg
new file mode 100644
index 0000000..b60d83d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-kubernetes.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-kusto-open.svg b/ui/src/assets/icons/file-types/folder-kusto-open.svg
new file mode 100644
index 0000000..4ea80ca
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-kusto-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-kusto.svg b/ui/src/assets/icons/file-types/folder-kusto.svg
new file mode 100644
index 0000000..fa71096
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-kusto.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-layout-open.svg b/ui/src/assets/icons/file-types/folder-layout-open.svg
new file mode 100644
index 0000000..f8f1def
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-layout-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-layout.svg b/ui/src/assets/icons/file-types/folder-layout.svg
new file mode 100644
index 0000000..3d773bc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-layout.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lefthook-open.svg b/ui/src/assets/icons/file-types/folder-lefthook-open.svg
new file mode 100644
index 0000000..a2694ba
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lefthook-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lefthook.svg b/ui/src/assets/icons/file-types/folder-lefthook.svg
new file mode 100644
index 0000000..0c7eb27
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lefthook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-less-open.svg b/ui/src/assets/icons/file-types/folder-less-open.svg
new file mode 100644
index 0000000..3419b0a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-less-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-less.svg b/ui/src/assets/icons/file-types/folder-less.svg
new file mode 100644
index 0000000..b6abc5e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-less.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lib-open.svg b/ui/src/assets/icons/file-types/folder-lib-open.svg
new file mode 100644
index 0000000..8c44431
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lib-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lib.svg b/ui/src/assets/icons/file-types/folder-lib.svg
new file mode 100644
index 0000000..4e75285
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lib.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-link-open.svg b/ui/src/assets/icons/file-types/folder-link-open.svg
new file mode 100644
index 0000000..817d0d5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-link-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-link.svg b/ui/src/assets/icons/file-types/folder-link.svg
new file mode 100644
index 0000000..48a8bbe
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-link.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-linux-open.svg b/ui/src/assets/icons/file-types/folder-linux-open.svg
new file mode 100644
index 0000000..8517b35
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-linux-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-linux.svg b/ui/src/assets/icons/file-types/folder-linux.svg
new file mode 100644
index 0000000..df4d229
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-linux.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-liquibase-open.svg b/ui/src/assets/icons/file-types/folder-liquibase-open.svg
new file mode 100644
index 0000000..2fe7ba6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-liquibase-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-liquibase.svg b/ui/src/assets/icons/file-types/folder-liquibase.svg
new file mode 100644
index 0000000..aea076a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-liquibase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-log-open.svg b/ui/src/assets/icons/file-types/folder-log-open.svg
new file mode 100644
index 0000000..a78771e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-log-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-log.svg b/ui/src/assets/icons/file-types/folder-log.svg
new file mode 100644
index 0000000..b2ba6a5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-log.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lottie-open.svg b/ui/src/assets/icons/file-types/folder-lottie-open.svg
new file mode 100644
index 0000000..adca025
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lottie-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lottie.svg b/ui/src/assets/icons/file-types/folder-lottie.svg
new file mode 100644
index 0000000..4d7fe34
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lottie.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lua-open.svg b/ui/src/assets/icons/file-types/folder-lua-open.svg
new file mode 100644
index 0000000..cb2ea6e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lua-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-lua.svg b/ui/src/assets/icons/file-types/folder-lua.svg
new file mode 100644
index 0000000..e32819b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-lua.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-luau-open.svg b/ui/src/assets/icons/file-types/folder-luau-open.svg
new file mode 100644
index 0000000..2b113b4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-luau-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-luau.svg b/ui/src/assets/icons/file-types/folder-luau.svg
new file mode 100644
index 0000000..a6b4551
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-luau.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-macos-open.svg b/ui/src/assets/icons/file-types/folder-macos-open.svg
new file mode 100644
index 0000000..8d0280a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-macos-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-macos.svg b/ui/src/assets/icons/file-types/folder-macos.svg
new file mode 100644
index 0000000..6afe2ed
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-macos.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-mail-open.svg b/ui/src/assets/icons/file-types/folder-mail-open.svg
new file mode 100644
index 0000000..27774cf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mail-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mail.svg b/ui/src/assets/icons/file-types/folder-mail.svg
new file mode 100644
index 0000000..513e4b1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mail.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mappings-open.svg b/ui/src/assets/icons/file-types/folder-mappings-open.svg
new file mode 100644
index 0000000..510d06b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mappings-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mappings.svg b/ui/src/assets/icons/file-types/folder-mappings.svg
new file mode 100644
index 0000000..53b58e0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mappings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-markdown-open.svg b/ui/src/assets/icons/file-types/folder-markdown-open.svg
new file mode 100644
index 0000000..75ef904
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-markdown-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-markdown.svg b/ui/src/assets/icons/file-types/folder-markdown.svg
new file mode 100644
index 0000000..5df5d0a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-markdown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mercurial-open.svg b/ui/src/assets/icons/file-types/folder-mercurial-open.svg
new file mode 100644
index 0000000..74bbb9d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mercurial-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mercurial.svg b/ui/src/assets/icons/file-types/folder-mercurial.svg
new file mode 100644
index 0000000..5175b8e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mercurial.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-messages-open.svg b/ui/src/assets/icons/file-types/folder-messages-open.svg
new file mode 100644
index 0000000..2701529
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-messages-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-messages.svg b/ui/src/assets/icons/file-types/folder-messages.svg
new file mode 100644
index 0000000..ab3e2f8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-messages.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-meta-open.svg b/ui/src/assets/icons/file-types/folder-meta-open.svg
new file mode 100644
index 0000000..de1fd82
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-meta-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-meta.svg b/ui/src/assets/icons/file-types/folder-meta.svg
new file mode 100644
index 0000000..3a1b90a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-meta.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-middleware-open.svg b/ui/src/assets/icons/file-types/folder-middleware-open.svg
new file mode 100644
index 0000000..346954c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-middleware-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-middleware.svg b/ui/src/assets/icons/file-types/folder-middleware.svg
new file mode 100644
index 0000000..f12c99d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-middleware.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mjml-open.svg b/ui/src/assets/icons/file-types/folder-mjml-open.svg
new file mode 100644
index 0000000..81843f0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mjml-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mjml.svg b/ui/src/assets/icons/file-types/folder-mjml.svg
new file mode 100644
index 0000000..8d7f067
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mjml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mobile-open.svg b/ui/src/assets/icons/file-types/folder-mobile-open.svg
new file mode 100644
index 0000000..6a5a39b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mobile-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mobile.svg b/ui/src/assets/icons/file-types/folder-mobile.svg
new file mode 100644
index 0000000..03aab13
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mobile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mock-open.svg b/ui/src/assets/icons/file-types/folder-mock-open.svg
new file mode 100644
index 0000000..c92929c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mock-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mock.svg b/ui/src/assets/icons/file-types/folder-mock.svg
new file mode 100644
index 0000000..22f88e5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mock.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mojo-open.svg b/ui/src/assets/icons/file-types/folder-mojo-open.svg
new file mode 100644
index 0000000..ce5b9be
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mojo-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-mojo.svg b/ui/src/assets/icons/file-types/folder-mojo.svg
new file mode 100644
index 0000000..67f7537
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-mojo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-molecule-open.svg b/ui/src/assets/icons/file-types/folder-molecule-open.svg
new file mode 100644
index 0000000..846e2f9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-molecule-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-molecule.svg b/ui/src/assets/icons/file-types/folder-molecule.svg
new file mode 100644
index 0000000..9c7905e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-molecule.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-moon-open.svg b/ui/src/assets/icons/file-types/folder-moon-open.svg
new file mode 100644
index 0000000..f2da8dd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-moon-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-moon.svg b/ui/src/assets/icons/file-types/folder-moon.svg
new file mode 100644
index 0000000..06613de
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-moon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-netlify-open.svg b/ui/src/assets/icons/file-types/folder-netlify-open.svg
new file mode 100644
index 0000000..d6f63b7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-netlify-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-netlify.svg b/ui/src/assets/icons/file-types/folder-netlify.svg
new file mode 100644
index 0000000..5473f42
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-netlify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-next-open.svg b/ui/src/assets/icons/file-types/folder-next-open.svg
new file mode 100644
index 0000000..c8709ca
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-next-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-next.svg b/ui/src/assets/icons/file-types/folder-next.svg
new file mode 100644
index 0000000..cab1e8f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-next.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-ngrx-store-open.svg b/ui/src/assets/icons/file-types/folder-ngrx-store-open.svg
new file mode 100644
index 0000000..2c8514e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ngrx-store-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ngrx-store.svg b/ui/src/assets/icons/file-types/folder-ngrx-store.svg
new file mode 100644
index 0000000..1f9cb2d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ngrx-store.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-node-open.svg b/ui/src/assets/icons/file-types/folder-node-open.svg
new file mode 100644
index 0000000..a785ed3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-node-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-node.svg b/ui/src/assets/icons/file-types/folder-node.svg
new file mode 100644
index 0000000..fb47492
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-node.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-nuxt-open.svg b/ui/src/assets/icons/file-types/folder-nuxt-open.svg
new file mode 100644
index 0000000..c49ff8d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-nuxt-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-nuxt.svg b/ui/src/assets/icons/file-types/folder-nuxt.svg
new file mode 100644
index 0000000..a0a52b0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-nuxt.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-obsidian-open.svg b/ui/src/assets/icons/file-types/folder-obsidian-open.svg
new file mode 100644
index 0000000..f7d1305
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-obsidian-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-obsidian.svg b/ui/src/assets/icons/file-types/folder-obsidian.svg
new file mode 100644
index 0000000..cd16a52
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-obsidian.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-open.svg b/ui/src/assets/icons/file-types/folder-open.svg
new file mode 100644
index 0000000..eac8918
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-open.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-organism-open.svg b/ui/src/assets/icons/file-types/folder-organism-open.svg
new file mode 100644
index 0000000..6be44d2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-organism-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-organism.svg b/ui/src/assets/icons/file-types/folder-organism.svg
new file mode 100644
index 0000000..50092a0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-organism.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-other-open.svg b/ui/src/assets/icons/file-types/folder-other-open.svg
new file mode 100644
index 0000000..ea4144f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-other-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-other.svg b/ui/src/assets/icons/file-types/folder-other.svg
new file mode 100644
index 0000000..df3d27f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-other.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-packages-open.svg b/ui/src/assets/icons/file-types/folder-packages-open.svg
new file mode 100644
index 0000000..7ac6075
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-packages-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-packages.svg b/ui/src/assets/icons/file-types/folder-packages.svg
new file mode 100644
index 0000000..9ba67cb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-packages.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pdf-open.svg b/ui/src/assets/icons/file-types/folder-pdf-open.svg
new file mode 100644
index 0000000..fdeccb0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pdf-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pdf.svg b/ui/src/assets/icons/file-types/folder-pdf.svg
new file mode 100644
index 0000000..db0ace7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pdf.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pdm-open.svg b/ui/src/assets/icons/file-types/folder-pdm-open.svg
new file mode 100644
index 0000000..6145f79
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pdm-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pdm.svg b/ui/src/assets/icons/file-types/folder-pdm.svg
new file mode 100644
index 0000000..9508547
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pdm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-php-open.svg b/ui/src/assets/icons/file-types/folder-php-open.svg
new file mode 100644
index 0000000..2059a9b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-php-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-php.svg b/ui/src/assets/icons/file-types/folder-php.svg
new file mode 100644
index 0000000..4304e17
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-php.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-phpmailer-open.svg b/ui/src/assets/icons/file-types/folder-phpmailer-open.svg
new file mode 100644
index 0000000..26388bb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-phpmailer-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-phpmailer.svg b/ui/src/assets/icons/file-types/folder-phpmailer.svg
new file mode 100644
index 0000000..18f696c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-phpmailer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pipe-open.svg b/ui/src/assets/icons/file-types/folder-pipe-open.svg
new file mode 100644
index 0000000..8aacef0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pipe-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pipe.svg b/ui/src/assets/icons/file-types/folder-pipe.svg
new file mode 100644
index 0000000..9ba5d0a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pipe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-plastic-open.svg b/ui/src/assets/icons/file-types/folder-plastic-open.svg
new file mode 100644
index 0000000..b93a541
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-plastic-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-plastic.svg b/ui/src/assets/icons/file-types/folder-plastic.svg
new file mode 100644
index 0000000..5e595f3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-plastic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-plugin-open.svg b/ui/src/assets/icons/file-types/folder-plugin-open.svg
new file mode 100644
index 0000000..5a7f03a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-plugin-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-plugin.svg b/ui/src/assets/icons/file-types/folder-plugin.svg
new file mode 100644
index 0000000..14a3154
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-plugin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-policy-open.svg b/ui/src/assets/icons/file-types/folder-policy-open.svg
new file mode 100644
index 0000000..c2b51d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-policy-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-policy.svg b/ui/src/assets/icons/file-types/folder-policy.svg
new file mode 100644
index 0000000..1b1781d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-policy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-powershell-open.svg b/ui/src/assets/icons/file-types/folder-powershell-open.svg
new file mode 100644
index 0000000..be4b458
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-powershell-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-powershell.svg b/ui/src/assets/icons/file-types/folder-powershell.svg
new file mode 100644
index 0000000..6f28098
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-powershell.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-prisma-open.svg b/ui/src/assets/icons/file-types/folder-prisma-open.svg
new file mode 100644
index 0000000..95df8ba
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-prisma-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-prisma.svg b/ui/src/assets/icons/file-types/folder-prisma.svg
new file mode 100644
index 0000000..a166ebd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-prisma.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-private-open.svg b/ui/src/assets/icons/file-types/folder-private-open.svg
new file mode 100644
index 0000000..19094be
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-private-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-private.svg b/ui/src/assets/icons/file-types/folder-private.svg
new file mode 100644
index 0000000..da95ece
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-private.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-project-open.svg b/ui/src/assets/icons/file-types/folder-project-open.svg
new file mode 100644
index 0000000..9da2862
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-project-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-project.svg b/ui/src/assets/icons/file-types/folder-project.svg
new file mode 100644
index 0000000..f575aa0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-project.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-prompts-open.svg b/ui/src/assets/icons/file-types/folder-prompts-open.svg
new file mode 100644
index 0000000..5ed3346
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-prompts-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-prompts.svg b/ui/src/assets/icons/file-types/folder-prompts.svg
new file mode 100644
index 0000000..969535b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-prompts.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-proto-open.svg b/ui/src/assets/icons/file-types/folder-proto-open.svg
new file mode 100644
index 0000000..710de39
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-proto-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-proto.svg b/ui/src/assets/icons/file-types/folder-proto.svg
new file mode 100644
index 0000000..935fcbc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-proto.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-public-open.svg b/ui/src/assets/icons/file-types/folder-public-open.svg
new file mode 100644
index 0000000..04449ed
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-public-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-public.svg b/ui/src/assets/icons/file-types/folder-public.svg
new file mode 100644
index 0000000..ea59939
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-public.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-python-open.svg b/ui/src/assets/icons/file-types/folder-python-open.svg
new file mode 100644
index 0000000..dbfc367
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-python-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-python.svg b/ui/src/assets/icons/file-types/folder-python.svg
new file mode 100644
index 0000000..aae0736
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-python.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pytorch-open.svg b/ui/src/assets/icons/file-types/folder-pytorch-open.svg
new file mode 100644
index 0000000..46f664f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pytorch-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-pytorch.svg b/ui/src/assets/icons/file-types/folder-pytorch.svg
new file mode 100644
index 0000000..2616b6b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-pytorch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-quasar-open.svg b/ui/src/assets/icons/file-types/folder-quasar-open.svg
new file mode 100644
index 0000000..5fb6b92
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-quasar-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-quasar.svg b/ui/src/assets/icons/file-types/folder-quasar.svg
new file mode 100644
index 0000000..b098014
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-quasar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-queue-open.svg b/ui/src/assets/icons/file-types/folder-queue-open.svg
new file mode 100644
index 0000000..5afa821
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-queue-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-queue.svg b/ui/src/assets/icons/file-types/folder-queue.svg
new file mode 100644
index 0000000..2445304
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-queue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-react-components-open.svg b/ui/src/assets/icons/file-types/folder-react-components-open.svg
new file mode 100644
index 0000000..05af544
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-react-components-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-react-components.svg b/ui/src/assets/icons/file-types/folder-react-components.svg
new file mode 100644
index 0000000..5f117a7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-react-components.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-redux-reducer-open.svg b/ui/src/assets/icons/file-types/folder-redux-reducer-open.svg
new file mode 100644
index 0000000..838bf52
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-redux-reducer-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-redux-reducer.svg b/ui/src/assets/icons/file-types/folder-redux-reducer.svg
new file mode 100644
index 0000000..a3b441f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-redux-reducer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-repository-open.svg b/ui/src/assets/icons/file-types/folder-repository-open.svg
new file mode 100644
index 0000000..9c6275d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-repository-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-repository.svg b/ui/src/assets/icons/file-types/folder-repository.svg
new file mode 100644
index 0000000..4f75206
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-repository.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-resolver-open.svg b/ui/src/assets/icons/file-types/folder-resolver-open.svg
new file mode 100644
index 0000000..5a4b752
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-resolver-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-resolver.svg b/ui/src/assets/icons/file-types/folder-resolver.svg
new file mode 100644
index 0000000..c59a6b4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-resolver.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-resource-open.svg b/ui/src/assets/icons/file-types/folder-resource-open.svg
new file mode 100644
index 0000000..0f534e1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-resource-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-resource.svg b/ui/src/assets/icons/file-types/folder-resource.svg
new file mode 100644
index 0000000..24a053a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-resource.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-review-open.svg b/ui/src/assets/icons/file-types/folder-review-open.svg
new file mode 100644
index 0000000..2384601
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-review-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-review.svg b/ui/src/assets/icons/file-types/folder-review.svg
new file mode 100644
index 0000000..c7b138c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-review.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-robot-open.svg b/ui/src/assets/icons/file-types/folder-robot-open.svg
new file mode 100644
index 0000000..cd501c4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-robot-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-robot.svg b/ui/src/assets/icons/file-types/folder-robot.svg
new file mode 100644
index 0000000..fa582f4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-robot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-routes-open.svg b/ui/src/assets/icons/file-types/folder-routes-open.svg
new file mode 100644
index 0000000..c9c875e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-routes-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-routes.svg b/ui/src/assets/icons/file-types/folder-routes.svg
new file mode 100644
index 0000000..2fb204d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-routes.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-rules-open.svg b/ui/src/assets/icons/file-types/folder-rules-open.svg
new file mode 100644
index 0000000..1f9c01f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-rules-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-rules.svg b/ui/src/assets/icons/file-types/folder-rules.svg
new file mode 100644
index 0000000..baa5b61
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-rules.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-rust-open.svg b/ui/src/assets/icons/file-types/folder-rust-open.svg
new file mode 100644
index 0000000..65be154
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-rust-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-rust.svg b/ui/src/assets/icons/file-types/folder-rust.svg
new file mode 100644
index 0000000..afe65f6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-rust.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-sandbox-open.svg b/ui/src/assets/icons/file-types/folder-sandbox-open.svg
new file mode 100644
index 0000000..e0c7a06
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-sandbox-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-sandbox.svg b/ui/src/assets/icons/file-types/folder-sandbox.svg
new file mode 100644
index 0000000..4339173
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-sandbox.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-sass-open.svg b/ui/src/assets/icons/file-types/folder-sass-open.svg
new file mode 100644
index 0000000..0a2a82e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-sass-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-sass.svg b/ui/src/assets/icons/file-types/folder-sass.svg
new file mode 100644
index 0000000..6f28731
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-sass.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-scala-open.svg b/ui/src/assets/icons/file-types/folder-scala-open.svg
new file mode 100644
index 0000000..fb4aee7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-scala-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-scala.svg b/ui/src/assets/icons/file-types/folder-scala.svg
new file mode 100644
index 0000000..d78a074
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-scala.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-scons-open.svg b/ui/src/assets/icons/file-types/folder-scons-open.svg
new file mode 100644
index 0000000..db89612
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-scons-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-scons.svg b/ui/src/assets/icons/file-types/folder-scons.svg
new file mode 100644
index 0000000..aae02b4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-scons.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-scripts-open.svg b/ui/src/assets/icons/file-types/folder-scripts-open.svg
new file mode 100644
index 0000000..981a43f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-scripts-open.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-scripts.svg b/ui/src/assets/icons/file-types/folder-scripts.svg
new file mode 100644
index 0000000..4b755ac
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-scripts.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-secure-open.svg b/ui/src/assets/icons/file-types/folder-secure-open.svg
new file mode 100644
index 0000000..163f7da
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-secure-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-secure.svg b/ui/src/assets/icons/file-types/folder-secure.svg
new file mode 100644
index 0000000..110093f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-secure.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-seeders-open.svg b/ui/src/assets/icons/file-types/folder-seeders-open.svg
new file mode 100644
index 0000000..b931940
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-seeders-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-seeders.svg b/ui/src/assets/icons/file-types/folder-seeders.svg
new file mode 100644
index 0000000..cd59776
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-seeders.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-server-open.svg b/ui/src/assets/icons/file-types/folder-server-open.svg
new file mode 100644
index 0000000..706b8af
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-server-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-server.svg b/ui/src/assets/icons/file-types/folder-server.svg
new file mode 100644
index 0000000..4f03f47
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-server.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-serverless-open.svg b/ui/src/assets/icons/file-types/folder-serverless-open.svg
new file mode 100644
index 0000000..113f73c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-serverless-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-serverless.svg b/ui/src/assets/icons/file-types/folder-serverless.svg
new file mode 100644
index 0000000..226f89d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-serverless.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-shader-open.svg b/ui/src/assets/icons/file-types/folder-shader-open.svg
new file mode 100644
index 0000000..03e00ed
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-shader-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-shader.svg b/ui/src/assets/icons/file-types/folder-shader.svg
new file mode 100644
index 0000000..57772b3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-shader.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-shared-open.svg b/ui/src/assets/icons/file-types/folder-shared-open.svg
new file mode 100644
index 0000000..6542e7f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-shared-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-shared.svg b/ui/src/assets/icons/file-types/folder-shared.svg
new file mode 100644
index 0000000..01e7a17
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-shared.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-snapcraft-open.svg b/ui/src/assets/icons/file-types/folder-snapcraft-open.svg
new file mode 100644
index 0000000..1a03068
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-snapcraft-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-snapcraft.svg b/ui/src/assets/icons/file-types/folder-snapcraft.svg
new file mode 100644
index 0000000..fc77b78
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-snapcraft.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-snippet-open.svg b/ui/src/assets/icons/file-types/folder-snippet-open.svg
new file mode 100644
index 0000000..451c291
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-snippet-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-snippet.svg b/ui/src/assets/icons/file-types/folder-snippet.svg
new file mode 100644
index 0000000..991f5c4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-snippet.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-src-open.svg b/ui/src/assets/icons/file-types/folder-src-open.svg
new file mode 100644
index 0000000..8cd9ee3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-src-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-src-tauri-open.svg b/ui/src/assets/icons/file-types/folder-src-tauri-open.svg
new file mode 100644
index 0000000..969c577
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-src-tauri-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-src-tauri.svg b/ui/src/assets/icons/file-types/folder-src-tauri.svg
new file mode 100644
index 0000000..727790c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-src-tauri.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-src.svg b/ui/src/assets/icons/file-types/folder-src.svg
new file mode 100644
index 0000000..8d45da9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-src.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-stack-open.svg b/ui/src/assets/icons/file-types/folder-stack-open.svg
new file mode 100644
index 0000000..cfd8bd0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-stack-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-stack.svg b/ui/src/assets/icons/file-types/folder-stack.svg
new file mode 100644
index 0000000..9c0b10d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-stack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-stencil-open.svg b/ui/src/assets/icons/file-types/folder-stencil-open.svg
new file mode 100644
index 0000000..6dea078
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-stencil-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-stencil.svg b/ui/src/assets/icons/file-types/folder-stencil.svg
new file mode 100644
index 0000000..c0443c9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-stencil.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-store-open.svg b/ui/src/assets/icons/file-types/folder-store-open.svg
new file mode 100644
index 0000000..13e415b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-store-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-store.svg b/ui/src/assets/icons/file-types/folder-store.svg
new file mode 100644
index 0000000..ae29c03
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-store.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-storybook-open.svg b/ui/src/assets/icons/file-types/folder-storybook-open.svg
new file mode 100644
index 0000000..9be24b2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-storybook-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-storybook.svg b/ui/src/assets/icons/file-types/folder-storybook.svg
new file mode 100644
index 0000000..26e6246
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-storybook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-stylus-open.svg b/ui/src/assets/icons/file-types/folder-stylus-open.svg
new file mode 100644
index 0000000..9615173
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-stylus-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-stylus.svg b/ui/src/assets/icons/file-types/folder-stylus.svg
new file mode 100644
index 0000000..68ae158
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-stylus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-sublime-open.svg b/ui/src/assets/icons/file-types/folder-sublime-open.svg
new file mode 100644
index 0000000..5066f3a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-sublime-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-sublime.svg b/ui/src/assets/icons/file-types/folder-sublime.svg
new file mode 100644
index 0000000..1361eda
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-sublime.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-supabase-open.svg b/ui/src/assets/icons/file-types/folder-supabase-open.svg
new file mode 100644
index 0000000..d58a692
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-supabase-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-supabase.svg b/ui/src/assets/icons/file-types/folder-supabase.svg
new file mode 100644
index 0000000..c0c8189
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-supabase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-svelte-open.svg b/ui/src/assets/icons/file-types/folder-svelte-open.svg
new file mode 100644
index 0000000..f72ae2f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-svelte-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-svelte.svg b/ui/src/assets/icons/file-types/folder-svelte.svg
new file mode 100644
index 0000000..61bf1d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-svelte.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-svg-open.svg b/ui/src/assets/icons/file-types/folder-svg-open.svg
new file mode 100644
index 0000000..f8ef72b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-svg-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-svg.svg b/ui/src/assets/icons/file-types/folder-svg.svg
new file mode 100644
index 0000000..320b9eb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-svg.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-syntax-open.svg b/ui/src/assets/icons/file-types/folder-syntax-open.svg
new file mode 100644
index 0000000..fd9d972
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-syntax-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-syntax.svg b/ui/src/assets/icons/file-types/folder-syntax.svg
new file mode 100644
index 0000000..be4ab16
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-syntax.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-target-open.svg b/ui/src/assets/icons/file-types/folder-target-open.svg
new file mode 100644
index 0000000..0004bf8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-target-open.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-target.svg b/ui/src/assets/icons/file-types/folder-target.svg
new file mode 100644
index 0000000..5872750
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-target.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-taskfile-open.svg b/ui/src/assets/icons/file-types/folder-taskfile-open.svg
new file mode 100644
index 0000000..fc2c501
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-taskfile-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-taskfile.svg b/ui/src/assets/icons/file-types/folder-taskfile.svg
new file mode 100644
index 0000000..1a3cac7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-taskfile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-tasks-open.svg b/ui/src/assets/icons/file-types/folder-tasks-open.svg
new file mode 100644
index 0000000..ed0e67f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-tasks-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-tasks.svg b/ui/src/assets/icons/file-types/folder-tasks.svg
new file mode 100644
index 0000000..1a9ef8a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-tasks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-television-open.svg b/ui/src/assets/icons/file-types/folder-television-open.svg
new file mode 100644
index 0000000..33c21d8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-television-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-television.svg b/ui/src/assets/icons/file-types/folder-television.svg
new file mode 100644
index 0000000..dc10294
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-television.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-temp-open.svg b/ui/src/assets/icons/file-types/folder-temp-open.svg
new file mode 100644
index 0000000..ec798b1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-temp-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-temp.svg b/ui/src/assets/icons/file-types/folder-temp.svg
new file mode 100644
index 0000000..3002a86
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-temp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-template-open.svg b/ui/src/assets/icons/file-types/folder-template-open.svg
new file mode 100644
index 0000000..e3f822b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-template-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-template.svg b/ui/src/assets/icons/file-types/folder-template.svg
new file mode 100644
index 0000000..1d15837
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-template.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-terraform-open.svg b/ui/src/assets/icons/file-types/folder-terraform-open.svg
new file mode 100644
index 0000000..fff197b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-terraform-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-terraform.svg b/ui/src/assets/icons/file-types/folder-terraform.svg
new file mode 100644
index 0000000..e71fba8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-terraform.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-test-open.svg b/ui/src/assets/icons/file-types/folder-test-open.svg
new file mode 100644
index 0000000..f3fefb3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-test-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-test.svg b/ui/src/assets/icons/file-types/folder-test.svg
new file mode 100644
index 0000000..92bee16
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-test.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-theme-open.svg b/ui/src/assets/icons/file-types/folder-theme-open.svg
new file mode 100644
index 0000000..5e79f99
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-theme-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-theme.svg b/ui/src/assets/icons/file-types/folder-theme.svg
new file mode 100644
index 0000000..88efa95
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-theme.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-tools-open.svg b/ui/src/assets/icons/file-types/folder-tools-open.svg
new file mode 100644
index 0000000..77ecaa8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-tools-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-tools.svg b/ui/src/assets/icons/file-types/folder-tools.svg
new file mode 100644
index 0000000..d591a1f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-tools.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-trash-open.svg b/ui/src/assets/icons/file-types/folder-trash-open.svg
new file mode 100644
index 0000000..add51b8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-trash-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-trash.svg b/ui/src/assets/icons/file-types/folder-trash.svg
new file mode 100644
index 0000000..1e81d28
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-trash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-trigger-open.svg b/ui/src/assets/icons/file-types/folder-trigger-open.svg
new file mode 100644
index 0000000..ecd80d3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-trigger-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-trigger.svg b/ui/src/assets/icons/file-types/folder-trigger.svg
new file mode 100644
index 0000000..cfe23c1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-trigger.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-turborepo-open.svg b/ui/src/assets/icons/file-types/folder-turborepo-open.svg
new file mode 100644
index 0000000..e0d7c35
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-turborepo-open.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-turborepo.svg b/ui/src/assets/icons/file-types/folder-turborepo.svg
new file mode 100644
index 0000000..ea20336
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-turborepo.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-typescript-open.svg b/ui/src/assets/icons/file-types/folder-typescript-open.svg
new file mode 100644
index 0000000..87c8e2f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-typescript-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-typescript.svg b/ui/src/assets/icons/file-types/folder-typescript.svg
new file mode 100644
index 0000000..df26f89
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-typescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ui-open.svg b/ui/src/assets/icons/file-types/folder-ui-open.svg
new file mode 100644
index 0000000..3044916
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ui-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-ui.svg b/ui/src/assets/icons/file-types/folder-ui.svg
new file mode 100644
index 0000000..fa320d1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-ui.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-unity-open.svg b/ui/src/assets/icons/file-types/folder-unity-open.svg
new file mode 100644
index 0000000..cb036d5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-unity-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-unity.svg b/ui/src/assets/icons/file-types/folder-unity.svg
new file mode 100644
index 0000000..c751de2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-unity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-update-open.svg b/ui/src/assets/icons/file-types/folder-update-open.svg
new file mode 100644
index 0000000..a6d18a9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-update-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-update.svg b/ui/src/assets/icons/file-types/folder-update.svg
new file mode 100644
index 0000000..65eaf57
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-update.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-upload-open.svg b/ui/src/assets/icons/file-types/folder-upload-open.svg
new file mode 100644
index 0000000..24fc359
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-upload-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-upload.svg b/ui/src/assets/icons/file-types/folder-upload.svg
new file mode 100644
index 0000000..423c6c1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-upload.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-utils-open.svg b/ui/src/assets/icons/file-types/folder-utils-open.svg
new file mode 100644
index 0000000..b894eff
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-utils-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-utils.svg b/ui/src/assets/icons/file-types/folder-utils.svg
new file mode 100644
index 0000000..fcc7999
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-utils.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vercel-open.svg b/ui/src/assets/icons/file-types/folder-vercel-open.svg
new file mode 100644
index 0000000..c571c63
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vercel-open.svg
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-vercel.svg b/ui/src/assets/icons/file-types/folder-vercel.svg
new file mode 100644
index 0000000..5138481
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vercel.svg
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/folder-verdaccio-open.svg b/ui/src/assets/icons/file-types/folder-verdaccio-open.svg
new file mode 100644
index 0000000..24beac5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-verdaccio-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-verdaccio.svg b/ui/src/assets/icons/file-types/folder-verdaccio.svg
new file mode 100644
index 0000000..8e78ba7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-verdaccio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-video-open.svg b/ui/src/assets/icons/file-types/folder-video-open.svg
new file mode 100644
index 0000000..ea60cd0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-video-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-video.svg b/ui/src/assets/icons/file-types/folder-video.svg
new file mode 100644
index 0000000..d138554
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-video.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-views-open.svg b/ui/src/assets/icons/file-types/folder-views-open.svg
new file mode 100644
index 0000000..1c785e4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-views-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-views.svg b/ui/src/assets/icons/file-types/folder-views.svg
new file mode 100644
index 0000000..5d41f10
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-views.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vm-open.svg b/ui/src/assets/icons/file-types/folder-vm-open.svg
new file mode 100644
index 0000000..e1a2b54
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vm-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vm.svg b/ui/src/assets/icons/file-types/folder-vm.svg
new file mode 100644
index 0000000..1ee3a95
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vscode-open.svg b/ui/src/assets/icons/file-types/folder-vscode-open.svg
new file mode 100644
index 0000000..82e3a21
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vscode-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vscode.svg b/ui/src/assets/icons/file-types/folder-vscode.svg
new file mode 100644
index 0000000..07ccbd6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vscode.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vue-directives-open.svg b/ui/src/assets/icons/file-types/folder-vue-directives-open.svg
new file mode 100644
index 0000000..341354b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vue-directives-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vue-directives.svg b/ui/src/assets/icons/file-types/folder-vue-directives.svg
new file mode 100644
index 0000000..fc28ccb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vue-directives.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vue-open.svg b/ui/src/assets/icons/file-types/folder-vue-open.svg
new file mode 100644
index 0000000..03abcaf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vue-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vue.svg b/ui/src/assets/icons/file-types/folder-vue.svg
new file mode 100644
index 0000000..c7cf38e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vuepress-open.svg b/ui/src/assets/icons/file-types/folder-vuepress-open.svg
new file mode 100644
index 0000000..af2b09b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vuepress-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vuepress.svg b/ui/src/assets/icons/file-types/folder-vuepress.svg
new file mode 100644
index 0000000..42fb0dc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vuepress.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vuex-store-open.svg b/ui/src/assets/icons/file-types/folder-vuex-store-open.svg
new file mode 100644
index 0000000..77c3c46
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vuex-store-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-vuex-store.svg b/ui/src/assets/icons/file-types/folder-vuex-store.svg
new file mode 100644
index 0000000..5c6793e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-vuex-store.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-wakatime-open.svg b/ui/src/assets/icons/file-types/folder-wakatime-open.svg
new file mode 100644
index 0000000..d1dbc38
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-wakatime-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-wakatime.svg b/ui/src/assets/icons/file-types/folder-wakatime.svg
new file mode 100644
index 0000000..860a661
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-wakatime.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-webpack-open.svg b/ui/src/assets/icons/file-types/folder-webpack-open.svg
new file mode 100644
index 0000000..acd1e19
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-webpack-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-webpack.svg b/ui/src/assets/icons/file-types/folder-webpack.svg
new file mode 100644
index 0000000..3ac887a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-webpack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-windows-open.svg b/ui/src/assets/icons/file-types/folder-windows-open.svg
new file mode 100644
index 0000000..9173ff9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-windows-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-windows.svg b/ui/src/assets/icons/file-types/folder-windows.svg
new file mode 100644
index 0000000..184de31
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-windows.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-wordpress-open.svg b/ui/src/assets/icons/file-types/folder-wordpress-open.svg
new file mode 100644
index 0000000..8cb4006
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-wordpress-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-wordpress.svg b/ui/src/assets/icons/file-types/folder-wordpress.svg
new file mode 100644
index 0000000..a954a2b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-wordpress.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-yarn-open.svg b/ui/src/assets/icons/file-types/folder-yarn-open.svg
new file mode 100644
index 0000000..ddbb988
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-yarn-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-yarn.svg b/ui/src/assets/icons/file-types/folder-yarn.svg
new file mode 100644
index 0000000..58aee64
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-yarn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-zeabur-open.svg b/ui/src/assets/icons/file-types/folder-zeabur-open.svg
new file mode 100644
index 0000000..ac2a31a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-zeabur-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder-zeabur.svg b/ui/src/assets/icons/file-types/folder-zeabur.svg
new file mode 100644
index 0000000..b0b8421
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder-zeabur.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/folder.svg b/ui/src/assets/icons/file-types/folder.svg
new file mode 100644
index 0000000..97ee81c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/folder.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/ui/src/assets/icons/file-types/font.svg b/ui/src/assets/icons/file-types/font.svg
new file mode 100644
index 0000000..961586d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/font.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/forth.svg b/ui/src/assets/icons/file-types/forth.svg
new file mode 100644
index 0000000..50b66af
--- /dev/null
+++ b/ui/src/assets/icons/file-types/forth.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/fortran.svg b/ui/src/assets/icons/file-types/fortran.svg
new file mode 100644
index 0000000..235db1a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/fortran.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/foxpro.svg b/ui/src/assets/icons/file-types/foxpro.svg
new file mode 100644
index 0000000..e2d5eb0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/foxpro.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/freemarker.svg b/ui/src/assets/icons/file-types/freemarker.svg
new file mode 100644
index 0000000..edf98f6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/freemarker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/fsharp.svg b/ui/src/assets/icons/file-types/fsharp.svg
new file mode 100644
index 0000000..1e5b7cf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/fsharp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/fusebox.svg b/ui/src/assets/icons/file-types/fusebox.svg
new file mode 100644
index 0000000..a4ad3d6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/fusebox.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gamemaker.svg b/ui/src/assets/icons/file-types/gamemaker.svg
new file mode 100644
index 0000000..4097cdd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gamemaker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/garden.svg b/ui/src/assets/icons/file-types/garden.svg
new file mode 100644
index 0000000..a96386d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/garden.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gatsby.svg b/ui/src/assets/icons/file-types/gatsby.svg
new file mode 100644
index 0000000..c267469
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gatsby.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gcp.svg b/ui/src/assets/icons/file-types/gcp.svg
new file mode 100644
index 0000000..62be904
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gcp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gemfile.svg b/ui/src/assets/icons/file-types/gemfile.svg
new file mode 100644
index 0000000..757c89d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gemfile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gemini-ai.svg b/ui/src/assets/icons/file-types/gemini-ai.svg
new file mode 100644
index 0000000..0911694
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gemini-ai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gemini.svg b/ui/src/assets/icons/file-types/gemini.svg
new file mode 100644
index 0000000..79ad4bf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gemini.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/git.svg b/ui/src/assets/icons/file-types/git.svg
new file mode 100644
index 0000000..c1e08fd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/git.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/github-actions-workflow.svg b/ui/src/assets/icons/file-types/github-actions-workflow.svg
new file mode 100644
index 0000000..1c724c5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/github-actions-workflow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/github-sponsors.svg b/ui/src/assets/icons/file-types/github-sponsors.svg
new file mode 100644
index 0000000..72fb668
--- /dev/null
+++ b/ui/src/assets/icons/file-types/github-sponsors.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gitlab.svg b/ui/src/assets/icons/file-types/gitlab.svg
new file mode 100644
index 0000000..ceeabaf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gitlab.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gitpod.svg b/ui/src/assets/icons/file-types/gitpod.svg
new file mode 100644
index 0000000..a992017
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gitpod.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gleam.svg b/ui/src/assets/icons/file-types/gleam.svg
new file mode 100644
index 0000000..76e0d0c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gleam.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gnuplot.svg b/ui/src/assets/icons/file-types/gnuplot.svg
new file mode 100644
index 0000000..8cc510b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gnuplot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/go-mod.svg b/ui/src/assets/icons/file-types/go-mod.svg
new file mode 100644
index 0000000..1689116
--- /dev/null
+++ b/ui/src/assets/icons/file-types/go-mod.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/go.svg b/ui/src/assets/icons/file-types/go.svg
new file mode 100644
index 0000000..d874e32
--- /dev/null
+++ b/ui/src/assets/icons/file-types/go.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/go_gopher.svg b/ui/src/assets/icons/file-types/go_gopher.svg
new file mode 100644
index 0000000..e465f74
--- /dev/null
+++ b/ui/src/assets/icons/file-types/go_gopher.svg
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/godot-assets.svg b/ui/src/assets/icons/file-types/godot-assets.svg
new file mode 100644
index 0000000..19e193d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/godot-assets.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/godot.svg b/ui/src/assets/icons/file-types/godot.svg
new file mode 100644
index 0000000..4b1dd7f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/godot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gradle.svg b/ui/src/assets/icons/file-types/gradle.svg
new file mode 100644
index 0000000..72d88fd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gradle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/grafana-alloy.svg b/ui/src/assets/icons/file-types/grafana-alloy.svg
new file mode 100644
index 0000000..cf00031
--- /dev/null
+++ b/ui/src/assets/icons/file-types/grafana-alloy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/grain.svg b/ui/src/assets/icons/file-types/grain.svg
new file mode 100644
index 0000000..f96d46b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/grain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/graphcool.svg b/ui/src/assets/icons/file-types/graphcool.svg
new file mode 100644
index 0000000..bdaedb9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/graphcool.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/graphql.svg b/ui/src/assets/icons/file-types/graphql.svg
new file mode 100644
index 0000000..252b0f7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/graphql.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gridsome.svg b/ui/src/assets/icons/file-types/gridsome.svg
new file mode 100644
index 0000000..8727741
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gridsome.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/groovy.svg b/ui/src/assets/icons/file-types/groovy.svg
new file mode 100644
index 0000000..9af0c08
--- /dev/null
+++ b/ui/src/assets/icons/file-types/groovy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/grunt.svg b/ui/src/assets/icons/file-types/grunt.svg
new file mode 100644
index 0000000..2b14994
--- /dev/null
+++ b/ui/src/assets/icons/file-types/grunt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/gulp.svg b/ui/src/assets/icons/file-types/gulp.svg
new file mode 100644
index 0000000..bc6a77f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/gulp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/h.svg b/ui/src/assets/icons/file-types/h.svg
new file mode 100644
index 0000000..08db8fe
--- /dev/null
+++ b/ui/src/assets/icons/file-types/h.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hack.svg b/ui/src/assets/icons/file-types/hack.svg
new file mode 100644
index 0000000..921cd73
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hadolint.svg b/ui/src/assets/icons/file-types/hadolint.svg
new file mode 100644
index 0000000..26195f5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hadolint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/haml.svg b/ui/src/assets/icons/file-types/haml.svg
new file mode 100644
index 0000000..bf08db5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/haml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/handlebars.svg b/ui/src/assets/icons/file-types/handlebars.svg
new file mode 100644
index 0000000..cf89930
--- /dev/null
+++ b/ui/src/assets/icons/file-types/handlebars.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hardhat.svg b/ui/src/assets/icons/file-types/hardhat.svg
new file mode 100644
index 0000000..dad8d45
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hardhat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/harmonix.svg b/ui/src/assets/icons/file-types/harmonix.svg
new file mode 100644
index 0000000..299fa47
--- /dev/null
+++ b/ui/src/assets/icons/file-types/harmonix.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/haskell.svg b/ui/src/assets/icons/file-types/haskell.svg
new file mode 100644
index 0000000..ae44927
--- /dev/null
+++ b/ui/src/assets/icons/file-types/haskell.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/haxe.svg b/ui/src/assets/icons/file-types/haxe.svg
new file mode 100644
index 0000000..cb28364
--- /dev/null
+++ b/ui/src/assets/icons/file-types/haxe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hcl.svg b/ui/src/assets/icons/file-types/hcl.svg
new file mode 100644
index 0000000..71edfb4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hcl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hcl_light.svg b/ui/src/assets/icons/file-types/hcl_light.svg
new file mode 100644
index 0000000..0196914
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hcl_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/helm.svg b/ui/src/assets/icons/file-types/helm.svg
new file mode 100644
index 0000000..58aa4a8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/helm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/heroku.svg b/ui/src/assets/icons/file-types/heroku.svg
new file mode 100644
index 0000000..d9d1ab0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/heroku.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hex.svg b/ui/src/assets/icons/file-types/hex.svg
new file mode 100644
index 0000000..e50c677
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/histoire.svg b/ui/src/assets/icons/file-types/histoire.svg
new file mode 100644
index 0000000..5619c16
--- /dev/null
+++ b/ui/src/assets/icons/file-types/histoire.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hjson.svg b/ui/src/assets/icons/file-types/hjson.svg
new file mode 100644
index 0000000..7725feb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hjson.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/horusec.svg b/ui/src/assets/icons/file-types/horusec.svg
new file mode 100644
index 0000000..9ea1155
--- /dev/null
+++ b/ui/src/assets/icons/file-types/horusec.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hosts.svg b/ui/src/assets/icons/file-types/hosts.svg
new file mode 100644
index 0000000..f88e7c6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hosts.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hosts_light.svg b/ui/src/assets/icons/file-types/hosts_light.svg
new file mode 100644
index 0000000..613a25e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hosts_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hpp.svg b/ui/src/assets/icons/file-types/hpp.svg
new file mode 100644
index 0000000..3e6872d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hpp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/html.svg b/ui/src/assets/icons/file-types/html.svg
new file mode 100644
index 0000000..71caf32
--- /dev/null
+++ b/ui/src/assets/icons/file-types/html.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/http.svg b/ui/src/assets/icons/file-types/http.svg
new file mode 100644
index 0000000..94574d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/http.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/huff.svg b/ui/src/assets/icons/file-types/huff.svg
new file mode 100644
index 0000000..2232914
--- /dev/null
+++ b/ui/src/assets/icons/file-types/huff.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/huff_light.svg b/ui/src/assets/icons/file-types/huff_light.svg
new file mode 100644
index 0000000..43889e0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/huff_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/hurl.svg b/ui/src/assets/icons/file-types/hurl.svg
new file mode 100644
index 0000000..227045b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/hurl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/husky.svg b/ui/src/assets/icons/file-types/husky.svg
new file mode 100644
index 0000000..b48f06a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/husky.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/i18n.svg b/ui/src/assets/icons/file-types/i18n.svg
new file mode 100644
index 0000000..4f678de
--- /dev/null
+++ b/ui/src/assets/icons/file-types/i18n.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/idris.svg b/ui/src/assets/icons/file-types/idris.svg
new file mode 100644
index 0000000..445745b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/idris.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ifanr-cloud.svg b/ui/src/assets/icons/file-types/ifanr-cloud.svg
new file mode 100644
index 0000000..c356b16
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ifanr-cloud.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/image.svg b/ui/src/assets/icons/file-types/image.svg
new file mode 100644
index 0000000..0ca446b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/image.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/imba.svg b/ui/src/assets/icons/file-types/imba.svg
new file mode 100644
index 0000000..60b0615
--- /dev/null
+++ b/ui/src/assets/icons/file-types/imba.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/installation.svg b/ui/src/assets/icons/file-types/installation.svg
new file mode 100644
index 0000000..36fa21c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/installation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ionic.svg b/ui/src/assets/icons/file-types/ionic.svg
new file mode 100644
index 0000000..2ce630d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ionic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/istanbul.svg b/ui/src/assets/icons/file-types/istanbul.svg
new file mode 100644
index 0000000..9508a98
--- /dev/null
+++ b/ui/src/assets/icons/file-types/istanbul.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jar.svg b/ui/src/assets/icons/file-types/jar.svg
new file mode 100644
index 0000000..1c81c48
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/java.svg b/ui/src/assets/icons/file-types/java.svg
new file mode 100644
index 0000000..0950bc4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/java.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/javaclass.svg b/ui/src/assets/icons/file-types/javaclass.svg
new file mode 100644
index 0000000..9abe7ad
--- /dev/null
+++ b/ui/src/assets/icons/file-types/javaclass.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/javascript-map.svg b/ui/src/assets/icons/file-types/javascript-map.svg
new file mode 100644
index 0000000..a1fcc22
--- /dev/null
+++ b/ui/src/assets/icons/file-types/javascript-map.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/javascript.svg b/ui/src/assets/icons/file-types/javascript.svg
new file mode 100644
index 0000000..254704a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/javascript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jenkins.svg b/ui/src/assets/icons/file-types/jenkins.svg
new file mode 100644
index 0000000..1517b74
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jenkins.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jest.svg b/ui/src/assets/icons/file-types/jest.svg
new file mode 100644
index 0000000..fb40ecb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jest.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jinja.svg b/ui/src/assets/icons/file-types/jinja.svg
new file mode 100644
index 0000000..8163f2a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jinja.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jinja_light.svg b/ui/src/assets/icons/file-types/jinja_light.svg
new file mode 100644
index 0000000..2233398
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jinja_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jsconfig.svg b/ui/src/assets/icons/file-types/jsconfig.svg
new file mode 100644
index 0000000..5aef481
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jsconfig.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/json.svg b/ui/src/assets/icons/file-types/json.svg
new file mode 100644
index 0000000..2590b94
--- /dev/null
+++ b/ui/src/assets/icons/file-types/json.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jsr.svg b/ui/src/assets/icons/file-types/jsr.svg
new file mode 100644
index 0000000..739f657
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jsr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jsr_light.svg b/ui/src/assets/icons/file-types/jsr_light.svg
new file mode 100644
index 0000000..c93d452
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jsr_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/julia.svg b/ui/src/assets/icons/file-types/julia.svg
new file mode 100644
index 0000000..39fca63
--- /dev/null
+++ b/ui/src/assets/icons/file-types/julia.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/jupyter.svg b/ui/src/assets/icons/file-types/jupyter.svg
new file mode 100644
index 0000000..770bffb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/jupyter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/just.svg b/ui/src/assets/icons/file-types/just.svg
new file mode 100644
index 0000000..7fc7543
--- /dev/null
+++ b/ui/src/assets/icons/file-types/just.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/karma.svg b/ui/src/assets/icons/file-types/karma.svg
new file mode 100644
index 0000000..0db4ab6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/karma.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/kcl.svg b/ui/src/assets/icons/file-types/kcl.svg
new file mode 100644
index 0000000..4f10c60
--- /dev/null
+++ b/ui/src/assets/icons/file-types/kcl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/key.svg b/ui/src/assets/icons/file-types/key.svg
new file mode 100644
index 0000000..08f67af
--- /dev/null
+++ b/ui/src/assets/icons/file-types/key.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/keystatic.svg b/ui/src/assets/icons/file-types/keystatic.svg
new file mode 100644
index 0000000..087b658
--- /dev/null
+++ b/ui/src/assets/icons/file-types/keystatic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/kivy.svg b/ui/src/assets/icons/file-types/kivy.svg
new file mode 100644
index 0000000..2a1a35c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/kivy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/kl.svg b/ui/src/assets/icons/file-types/kl.svg
new file mode 100644
index 0000000..967ef09
--- /dev/null
+++ b/ui/src/assets/icons/file-types/kl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/knip.svg b/ui/src/assets/icons/file-types/knip.svg
new file mode 100644
index 0000000..c71d0a2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/knip.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/kotlin.svg b/ui/src/assets/icons/file-types/kotlin.svg
new file mode 100644
index 0000000..740505c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/kotlin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/kubernetes.svg b/ui/src/assets/icons/file-types/kubernetes.svg
new file mode 100644
index 0000000..6726dcc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/kubernetes.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/kusto.svg b/ui/src/assets/icons/file-types/kusto.svg
new file mode 100644
index 0000000..46087e8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/kusto.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/label.svg b/ui/src/assets/icons/file-types/label.svg
new file mode 100644
index 0000000..28abeac
--- /dev/null
+++ b/ui/src/assets/icons/file-types/label.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/laravel.svg b/ui/src/assets/icons/file-types/laravel.svg
new file mode 100644
index 0000000..95ee923
--- /dev/null
+++ b/ui/src/assets/icons/file-types/laravel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/latexmk.svg b/ui/src/assets/icons/file-types/latexmk.svg
new file mode 100644
index 0000000..484318a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/latexmk.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lbx.svg b/ui/src/assets/icons/file-types/lbx.svg
new file mode 100644
index 0000000..c66f157
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lbx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lefthook.svg b/ui/src/assets/icons/file-types/lefthook.svg
new file mode 100644
index 0000000..93f6f81
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lefthook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lerna.svg b/ui/src/assets/icons/file-types/lerna.svg
new file mode 100644
index 0000000..4128d6b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lerna.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/less.svg b/ui/src/assets/icons/file-types/less.svg
new file mode 100644
index 0000000..2e13a3c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/less.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/liara.svg b/ui/src/assets/icons/file-types/liara.svg
new file mode 100644
index 0000000..2fd408c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/liara.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lib.svg b/ui/src/assets/icons/file-types/lib.svg
new file mode 100644
index 0000000..7c8fda3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lib.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lighthouse.svg b/ui/src/assets/icons/file-types/lighthouse.svg
new file mode 100644
index 0000000..0229244
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lighthouse.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lilypond.svg b/ui/src/assets/icons/file-types/lilypond.svg
new file mode 100644
index 0000000..a12aa2c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lilypond.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lintstaged.svg b/ui/src/assets/icons/file-types/lintstaged.svg
new file mode 100644
index 0000000..fbf9467
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lintstaged.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/liquid.svg b/ui/src/assets/icons/file-types/liquid.svg
new file mode 100644
index 0000000..5111ab6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/liquid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lisp.svg b/ui/src/assets/icons/file-types/lisp.svg
new file mode 100644
index 0000000..76e4f46
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lisp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/livescript.svg b/ui/src/assets/icons/file-types/livescript.svg
new file mode 100644
index 0000000..d7dcb37
--- /dev/null
+++ b/ui/src/assets/icons/file-types/livescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lock.svg b/ui/src/assets/icons/file-types/lock.svg
new file mode 100644
index 0000000..ca49d02
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lock.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/log.svg b/ui/src/assets/icons/file-types/log.svg
new file mode 100644
index 0000000..389f51d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/log.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lolcode.svg b/ui/src/assets/icons/file-types/lolcode.svg
new file mode 100644
index 0000000..f9c7595
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lolcode.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lottie.svg b/ui/src/assets/icons/file-types/lottie.svg
new file mode 100644
index 0000000..29981d3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lottie.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lua.svg b/ui/src/assets/icons/file-types/lua.svg
new file mode 100644
index 0000000..ca7a3d5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lua.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/luau.svg b/ui/src/assets/icons/file-types/luau.svg
new file mode 100644
index 0000000..7f9ad57
--- /dev/null
+++ b/ui/src/assets/icons/file-types/luau.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/lyric.svg b/ui/src/assets/icons/file-types/lyric.svg
new file mode 100644
index 0000000..06bb43e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/lyric.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/makefile.svg b/ui/src/assets/icons/file-types/makefile.svg
new file mode 100644
index 0000000..e211671
--- /dev/null
+++ b/ui/src/assets/icons/file-types/makefile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/markdoc-config.svg b/ui/src/assets/icons/file-types/markdoc-config.svg
new file mode 100644
index 0000000..13913c3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/markdoc-config.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/markdoc.svg b/ui/src/assets/icons/file-types/markdoc.svg
new file mode 100644
index 0000000..3ed2c54
--- /dev/null
+++ b/ui/src/assets/icons/file-types/markdoc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/markdown.svg b/ui/src/assets/icons/file-types/markdown.svg
new file mode 100644
index 0000000..4c22434
--- /dev/null
+++ b/ui/src/assets/icons/file-types/markdown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/markdownlint.svg b/ui/src/assets/icons/file-types/markdownlint.svg
new file mode 100644
index 0000000..37daf0d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/markdownlint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/markojs.svg b/ui/src/assets/icons/file-types/markojs.svg
new file mode 100644
index 0000000..938b6fe
--- /dev/null
+++ b/ui/src/assets/icons/file-types/markojs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mathematica.svg b/ui/src/assets/icons/file-types/mathematica.svg
new file mode 100644
index 0000000..08c2508
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mathematica.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/matlab.svg b/ui/src/assets/icons/file-types/matlab.svg
new file mode 100644
index 0000000..a2166f8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/matlab.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/maven.svg b/ui/src/assets/icons/file-types/maven.svg
new file mode 100644
index 0000000..7a88745
--- /dev/null
+++ b/ui/src/assets/icons/file-types/maven.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mdsvex.svg b/ui/src/assets/icons/file-types/mdsvex.svg
new file mode 100644
index 0000000..34b252a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mdsvex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mdx.svg b/ui/src/assets/icons/file-types/mdx.svg
new file mode 100644
index 0000000..b2ab561
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mdx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mercurial.svg b/ui/src/assets/icons/file-types/mercurial.svg
new file mode 100644
index 0000000..41f701e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mercurial.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/merlin.svg b/ui/src/assets/icons/file-types/merlin.svg
new file mode 100644
index 0000000..96b29d3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/merlin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mermaid.svg b/ui/src/assets/icons/file-types/mermaid.svg
new file mode 100644
index 0000000..b1f520d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mermaid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/meson.svg b/ui/src/assets/icons/file-types/meson.svg
new file mode 100644
index 0000000..f9d3bef
--- /dev/null
+++ b/ui/src/assets/icons/file-types/meson.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/minecraft-fabric.svg b/ui/src/assets/icons/file-types/minecraft-fabric.svg
new file mode 100644
index 0000000..4c0985b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/minecraft-fabric.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/minecraft.svg b/ui/src/assets/icons/file-types/minecraft.svg
new file mode 100644
index 0000000..219af8a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/minecraft.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mint.svg b/ui/src/assets/icons/file-types/mint.svg
new file mode 100644
index 0000000..659340a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mjml.svg b/ui/src/assets/icons/file-types/mjml.svg
new file mode 100644
index 0000000..5580ca0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mjml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mocha.svg b/ui/src/assets/icons/file-types/mocha.svg
new file mode 100644
index 0000000..bce8ac3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mocha.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/modernizr.svg b/ui/src/assets/icons/file-types/modernizr.svg
new file mode 100644
index 0000000..b340bec
--- /dev/null
+++ b/ui/src/assets/icons/file-types/modernizr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mojo.svg b/ui/src/assets/icons/file-types/mojo.svg
new file mode 100644
index 0000000..505a8f5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mojo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/moon.svg b/ui/src/assets/icons/file-types/moon.svg
new file mode 100644
index 0000000..c428ebb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/moon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/moonscript.svg b/ui/src/assets/icons/file-types/moonscript.svg
new file mode 100644
index 0000000..1d7f7ee
--- /dev/null
+++ b/ui/src/assets/icons/file-types/moonscript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/mxml.svg b/ui/src/assets/icons/file-types/mxml.svg
new file mode 100644
index 0000000..c5b84dd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/mxml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nano-staged.svg b/ui/src/assets/icons/file-types/nano-staged.svg
new file mode 100644
index 0000000..6e6cd07
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nano-staged.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nano-staged_light.svg b/ui/src/assets/icons/file-types/nano-staged_light.svg
new file mode 100644
index 0000000..698232f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nano-staged_light.svg
@@ -0,0 +1,4 @@
+
+
+
diff --git a/ui/src/assets/icons/file-types/ndst.svg b/ui/src/assets/icons/file-types/ndst.svg
new file mode 100644
index 0000000..1941313
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ndst.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nest.svg b/ui/src/assets/icons/file-types/nest.svg
new file mode 100644
index 0000000..259dc53
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nest.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/netlify.svg b/ui/src/assets/icons/file-types/netlify.svg
new file mode 100644
index 0000000..27c837f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/netlify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/netlify_light.svg b/ui/src/assets/icons/file-types/netlify_light.svg
new file mode 100644
index 0000000..b142c48
--- /dev/null
+++ b/ui/src/assets/icons/file-types/netlify_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/next.svg b/ui/src/assets/icons/file-types/next.svg
new file mode 100644
index 0000000..83fee37
--- /dev/null
+++ b/ui/src/assets/icons/file-types/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/next_light.svg b/ui/src/assets/icons/file-types/next_light.svg
new file mode 100644
index 0000000..6e5fb27
--- /dev/null
+++ b/ui/src/assets/icons/file-types/next_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nginx.svg b/ui/src/assets/icons/file-types/nginx.svg
new file mode 100644
index 0000000..658ad22
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nginx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ngrx-actions.svg b/ui/src/assets/icons/file-types/ngrx-actions.svg
new file mode 100644
index 0000000..de418d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ngrx-actions.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ngrx-effects.svg b/ui/src/assets/icons/file-types/ngrx-effects.svg
new file mode 100644
index 0000000..8f7dc89
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ngrx-effects.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ngrx-entity.svg b/ui/src/assets/icons/file-types/ngrx-entity.svg
new file mode 100644
index 0000000..af0dd05
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ngrx-entity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ngrx-reducer.svg b/ui/src/assets/icons/file-types/ngrx-reducer.svg
new file mode 100644
index 0000000..db7a553
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ngrx-reducer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ngrx-selectors.svg b/ui/src/assets/icons/file-types/ngrx-selectors.svg
new file mode 100644
index 0000000..af03c40
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ngrx-selectors.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ngrx-state.svg b/ui/src/assets/icons/file-types/ngrx-state.svg
new file mode 100644
index 0000000..258c0ac
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ngrx-state.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nim.svg b/ui/src/assets/icons/file-types/nim.svg
new file mode 100644
index 0000000..d985bb4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nim.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nix.svg b/ui/src/assets/icons/file-types/nix.svg
new file mode 100644
index 0000000..a507609
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nix.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nodejs.svg b/ui/src/assets/icons/file-types/nodejs.svg
new file mode 100644
index 0000000..ba73901
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nodejs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nodejs_alt.svg b/ui/src/assets/icons/file-types/nodejs_alt.svg
new file mode 100644
index 0000000..5b70be2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nodejs_alt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nodemon.svg b/ui/src/assets/icons/file-types/nodemon.svg
new file mode 100644
index 0000000..2bd35d1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nodemon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/npm.svg b/ui/src/assets/icons/file-types/npm.svg
new file mode 100644
index 0000000..87aa583
--- /dev/null
+++ b/ui/src/assets/icons/file-types/npm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nuget.svg b/ui/src/assets/icons/file-types/nuget.svg
new file mode 100644
index 0000000..82e298f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nuget.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nunjucks.svg b/ui/src/assets/icons/file-types/nunjucks.svg
new file mode 100644
index 0000000..9fb8890
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nunjucks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nuxt.svg b/ui/src/assets/icons/file-types/nuxt.svg
new file mode 100644
index 0000000..babf919
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nuxt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/nx.svg b/ui/src/assets/icons/file-types/nx.svg
new file mode 100644
index 0000000..8db8323
--- /dev/null
+++ b/ui/src/assets/icons/file-types/nx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/objective-c.svg b/ui/src/assets/icons/file-types/objective-c.svg
new file mode 100644
index 0000000..7a69f91
--- /dev/null
+++ b/ui/src/assets/icons/file-types/objective-c.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/objective-cpp.svg b/ui/src/assets/icons/file-types/objective-cpp.svg
new file mode 100644
index 0000000..cd55d1e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/objective-cpp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ocaml.svg b/ui/src/assets/icons/file-types/ocaml.svg
new file mode 100644
index 0000000..cb6eb6b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ocaml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/odin.svg b/ui/src/assets/icons/file-types/odin.svg
new file mode 100644
index 0000000..1877a6c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/odin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/opa.svg b/ui/src/assets/icons/file-types/opa.svg
new file mode 100644
index 0000000..3afc1c6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/opa.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/opam.svg b/ui/src/assets/icons/file-types/opam.svg
new file mode 100644
index 0000000..70f1b7f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/opam.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/openapi.svg b/ui/src/assets/icons/file-types/openapi.svg
new file mode 100644
index 0000000..5b367a1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/openapi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/openapi_light.svg b/ui/src/assets/icons/file-types/openapi_light.svg
new file mode 100644
index 0000000..179006d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/openapi_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/otne.svg b/ui/src/assets/icons/file-types/otne.svg
new file mode 100644
index 0000000..8670a61
--- /dev/null
+++ b/ui/src/assets/icons/file-types/otne.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/oxlint.svg b/ui/src/assets/icons/file-types/oxlint.svg
new file mode 100644
index 0000000..2ffad92
--- /dev/null
+++ b/ui/src/assets/icons/file-types/oxlint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/packship.svg b/ui/src/assets/icons/file-types/packship.svg
new file mode 100644
index 0000000..e03b35d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/packship.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/palette.svg b/ui/src/assets/icons/file-types/palette.svg
new file mode 100644
index 0000000..cc27f66
--- /dev/null
+++ b/ui/src/assets/icons/file-types/palette.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/panda.svg b/ui/src/assets/icons/file-types/panda.svg
new file mode 100644
index 0000000..dde4122
--- /dev/null
+++ b/ui/src/assets/icons/file-types/panda.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/parcel.svg b/ui/src/assets/icons/file-types/parcel.svg
new file mode 100644
index 0000000..39a1835
--- /dev/null
+++ b/ui/src/assets/icons/file-types/parcel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pascal.svg b/ui/src/assets/icons/file-types/pascal.svg
new file mode 100644
index 0000000..b0a2993
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pascal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pawn.svg b/ui/src/assets/icons/file-types/pawn.svg
new file mode 100644
index 0000000..b615d75
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pawn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/payload.svg b/ui/src/assets/icons/file-types/payload.svg
new file mode 100644
index 0000000..8e1e82a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/payload.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/payload_light.svg b/ui/src/assets/icons/file-types/payload_light.svg
new file mode 100644
index 0000000..7a4e9c7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/payload_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pdf.svg b/ui/src/assets/icons/file-types/pdf.svg
new file mode 100644
index 0000000..1c84fe8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pdf.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pdm.svg b/ui/src/assets/icons/file-types/pdm.svg
new file mode 100644
index 0000000..dd23bb3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pdm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/percy.svg b/ui/src/assets/icons/file-types/percy.svg
new file mode 100644
index 0000000..6d0f897
--- /dev/null
+++ b/ui/src/assets/icons/file-types/percy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/perl.svg b/ui/src/assets/icons/file-types/perl.svg
new file mode 100644
index 0000000..0534cad
--- /dev/null
+++ b/ui/src/assets/icons/file-types/perl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/php-cs-fixer.svg b/ui/src/assets/icons/file-types/php-cs-fixer.svg
new file mode 100644
index 0000000..398c214
--- /dev/null
+++ b/ui/src/assets/icons/file-types/php-cs-fixer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/php.svg b/ui/src/assets/icons/file-types/php.svg
new file mode 100644
index 0000000..1d7e336
--- /dev/null
+++ b/ui/src/assets/icons/file-types/php.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/php_elephant.svg b/ui/src/assets/icons/file-types/php_elephant.svg
new file mode 100644
index 0000000..d2c2995
--- /dev/null
+++ b/ui/src/assets/icons/file-types/php_elephant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/php_elephant_pink.svg b/ui/src/assets/icons/file-types/php_elephant_pink.svg
new file mode 100644
index 0000000..a7cad74
--- /dev/null
+++ b/ui/src/assets/icons/file-types/php_elephant_pink.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/phpstan.svg b/ui/src/assets/icons/file-types/phpstan.svg
new file mode 100644
index 0000000..34b612f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/phpstan.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/phpunit.svg b/ui/src/assets/icons/file-types/phpunit.svg
new file mode 100644
index 0000000..2132200
--- /dev/null
+++ b/ui/src/assets/icons/file-types/phpunit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pinejs.svg b/ui/src/assets/icons/file-types/pinejs.svg
new file mode 100644
index 0000000..44c0020
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pinejs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pipeline.svg b/ui/src/assets/icons/file-types/pipeline.svg
new file mode 100644
index 0000000..a3a5e66
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pipeline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pkl.svg b/ui/src/assets/icons/file-types/pkl.svg
new file mode 100644
index 0000000..3f31ead
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pkl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/plastic.svg b/ui/src/assets/icons/file-types/plastic.svg
new file mode 100644
index 0000000..cc00e5a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/plastic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/playwright.svg b/ui/src/assets/icons/file-types/playwright.svg
new file mode 100644
index 0000000..cae0b24
--- /dev/null
+++ b/ui/src/assets/icons/file-types/playwright.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/plop.svg b/ui/src/assets/icons/file-types/plop.svg
new file mode 100644
index 0000000..85e3bd2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/plop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pm2-ecosystem.svg b/ui/src/assets/icons/file-types/pm2-ecosystem.svg
new file mode 100644
index 0000000..a99d5f2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pm2-ecosystem.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pnpm.svg b/ui/src/assets/icons/file-types/pnpm.svg
new file mode 100644
index 0000000..fc52c6e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pnpm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pnpm_light.svg b/ui/src/assets/icons/file-types/pnpm_light.svg
new file mode 100644
index 0000000..4236956
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pnpm_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/poetry.svg b/ui/src/assets/icons/file-types/poetry.svg
new file mode 100644
index 0000000..4a355a7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/poetry.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/postcss.svg b/ui/src/assets/icons/file-types/postcss.svg
new file mode 100644
index 0000000..799edeb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/postcss.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/posthtml.svg b/ui/src/assets/icons/file-types/posthtml.svg
new file mode 100644
index 0000000..54dda3c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/posthtml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/powerpoint.svg b/ui/src/assets/icons/file-types/powerpoint.svg
new file mode 100644
index 0000000..eaba916
--- /dev/null
+++ b/ui/src/assets/icons/file-types/powerpoint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/powershell.svg b/ui/src/assets/icons/file-types/powershell.svg
new file mode 100644
index 0000000..a266393
--- /dev/null
+++ b/ui/src/assets/icons/file-types/powershell.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pre-commit.svg b/ui/src/assets/icons/file-types/pre-commit.svg
new file mode 100644
index 0000000..399826b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pre-commit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/prettier.svg b/ui/src/assets/icons/file-types/prettier.svg
new file mode 100644
index 0000000..a6cda34
--- /dev/null
+++ b/ui/src/assets/icons/file-types/prettier.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/prisma.svg b/ui/src/assets/icons/file-types/prisma.svg
new file mode 100644
index 0000000..121abea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/prisma.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/processing.svg b/ui/src/assets/icons/file-types/processing.svg
new file mode 100644
index 0000000..8a960ab
--- /dev/null
+++ b/ui/src/assets/icons/file-types/processing.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/prolog.svg b/ui/src/assets/icons/file-types/prolog.svg
new file mode 100644
index 0000000..7eda090
--- /dev/null
+++ b/ui/src/assets/icons/file-types/prolog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/prompt.svg b/ui/src/assets/icons/file-types/prompt.svg
new file mode 100644
index 0000000..aa37366
--- /dev/null
+++ b/ui/src/assets/icons/file-types/prompt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/proto.svg b/ui/src/assets/icons/file-types/proto.svg
new file mode 100644
index 0000000..7757c0e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/proto.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/protractor.svg b/ui/src/assets/icons/file-types/protractor.svg
new file mode 100644
index 0000000..50f4643
--- /dev/null
+++ b/ui/src/assets/icons/file-types/protractor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pug.svg b/ui/src/assets/icons/file-types/pug.svg
new file mode 100644
index 0000000..62a3602
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pug.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/puppet.svg b/ui/src/assets/icons/file-types/puppet.svg
new file mode 100644
index 0000000..3e1e9c1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/puppet.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/puppeteer.svg b/ui/src/assets/icons/file-types/puppeteer.svg
new file mode 100644
index 0000000..b553df3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/puppeteer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/purescript.svg b/ui/src/assets/icons/file-types/purescript.svg
new file mode 100644
index 0000000..d82c8f9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/purescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/python-misc.svg b/ui/src/assets/icons/file-types/python-misc.svg
new file mode 100644
index 0000000..44fb730
--- /dev/null
+++ b/ui/src/assets/icons/file-types/python-misc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/python.svg b/ui/src/assets/icons/file-types/python.svg
new file mode 100644
index 0000000..20c2508
--- /dev/null
+++ b/ui/src/assets/icons/file-types/python.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/pytorch.svg b/ui/src/assets/icons/file-types/pytorch.svg
new file mode 100644
index 0000000..4cb85d0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/pytorch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/qsharp.svg b/ui/src/assets/icons/file-types/qsharp.svg
new file mode 100644
index 0000000..de9838d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/qsharp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/quarto.svg b/ui/src/assets/icons/file-types/quarto.svg
new file mode 100644
index 0000000..3bb8ef7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/quarto.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/quasar.svg b/ui/src/assets/icons/file-types/quasar.svg
new file mode 100644
index 0000000..fa02ff0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/quasar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/quokka.svg b/ui/src/assets/icons/file-types/quokka.svg
new file mode 100644
index 0000000..bf368de
--- /dev/null
+++ b/ui/src/assets/icons/file-types/quokka.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/qwik.svg b/ui/src/assets/icons/file-types/qwik.svg
new file mode 100644
index 0000000..5555116
--- /dev/null
+++ b/ui/src/assets/icons/file-types/qwik.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/r.svg b/ui/src/assets/icons/file-types/r.svg
new file mode 100644
index 0000000..5703dd0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/r.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/racket.svg b/ui/src/assets/icons/file-types/racket.svg
new file mode 100644
index 0000000..04ca144
--- /dev/null
+++ b/ui/src/assets/icons/file-types/racket.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/raml.svg b/ui/src/assets/icons/file-types/raml.svg
new file mode 100644
index 0000000..d35d561
--- /dev/null
+++ b/ui/src/assets/icons/file-types/raml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/razor.svg b/ui/src/assets/icons/file-types/razor.svg
new file mode 100644
index 0000000..4e99091
--- /dev/null
+++ b/ui/src/assets/icons/file-types/razor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rbxmk.svg b/ui/src/assets/icons/file-types/rbxmk.svg
new file mode 100644
index 0000000..e7d4953
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rbxmk.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rc.svg b/ui/src/assets/icons/file-types/rc.svg
new file mode 100644
index 0000000..83040db
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/react.svg b/ui/src/assets/icons/file-types/react.svg
new file mode 100644
index 0000000..ced90db
--- /dev/null
+++ b/ui/src/assets/icons/file-types/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/react_ts.svg b/ui/src/assets/icons/file-types/react_ts.svg
new file mode 100644
index 0000000..887f72c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/react_ts.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/readme.svg b/ui/src/assets/icons/file-types/readme.svg
new file mode 100644
index 0000000..943d08f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/readme.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/reason.svg b/ui/src/assets/icons/file-types/reason.svg
new file mode 100644
index 0000000..0f4b3e1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/reason.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/red.svg b/ui/src/assets/icons/file-types/red.svg
new file mode 100644
index 0000000..6084231
--- /dev/null
+++ b/ui/src/assets/icons/file-types/red.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/redux-action.svg b/ui/src/assets/icons/file-types/redux-action.svg
new file mode 100644
index 0000000..a4872e7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/redux-action.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/redux-reducer.svg b/ui/src/assets/icons/file-types/redux-reducer.svg
new file mode 100644
index 0000000..cfcca98
--- /dev/null
+++ b/ui/src/assets/icons/file-types/redux-reducer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/redux-selector.svg b/ui/src/assets/icons/file-types/redux-selector.svg
new file mode 100644
index 0000000..073c286
--- /dev/null
+++ b/ui/src/assets/icons/file-types/redux-selector.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/redux-store.svg b/ui/src/assets/icons/file-types/redux-store.svg
new file mode 100644
index 0000000..8e644e7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/redux-store.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/regedit.svg b/ui/src/assets/icons/file-types/regedit.svg
new file mode 100644
index 0000000..3d63206
--- /dev/null
+++ b/ui/src/assets/icons/file-types/regedit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/remark.svg b/ui/src/assets/icons/file-types/remark.svg
new file mode 100644
index 0000000..9d6a918
--- /dev/null
+++ b/ui/src/assets/icons/file-types/remark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/remix.svg b/ui/src/assets/icons/file-types/remix.svg
new file mode 100644
index 0000000..763f57f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/remix.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/remix_light.svg b/ui/src/assets/icons/file-types/remix_light.svg
new file mode 100644
index 0000000..748b779
--- /dev/null
+++ b/ui/src/assets/icons/file-types/remix_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/renovate.svg b/ui/src/assets/icons/file-types/renovate.svg
new file mode 100644
index 0000000..bc63cbb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/renovate.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/replit.svg b/ui/src/assets/icons/file-types/replit.svg
new file mode 100644
index 0000000..f1478a5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/replit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rescript-interface.svg b/ui/src/assets/icons/file-types/rescript-interface.svg
new file mode 100644
index 0000000..db30553
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rescript-interface.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rescript.svg b/ui/src/assets/icons/file-types/rescript.svg
new file mode 100644
index 0000000..8f40a3a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/restql.svg b/ui/src/assets/icons/file-types/restql.svg
new file mode 100644
index 0000000..a056fe9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/restql.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/riot.svg b/ui/src/assets/icons/file-types/riot.svg
new file mode 100644
index 0000000..587e50d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/riot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/roadmap.svg b/ui/src/assets/icons/file-types/roadmap.svg
new file mode 100644
index 0000000..2279ead
--- /dev/null
+++ b/ui/src/assets/icons/file-types/roadmap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/roblox.svg b/ui/src/assets/icons/file-types/roblox.svg
new file mode 100644
index 0000000..56cc378
--- /dev/null
+++ b/ui/src/assets/icons/file-types/roblox.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/robot.svg b/ui/src/assets/icons/file-types/robot.svg
new file mode 100644
index 0000000..36c7225
--- /dev/null
+++ b/ui/src/assets/icons/file-types/robot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/robots.svg b/ui/src/assets/icons/file-types/robots.svg
new file mode 100644
index 0000000..11fdaae
--- /dev/null
+++ b/ui/src/assets/icons/file-types/robots.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rocket.svg b/ui/src/assets/icons/file-types/rocket.svg
new file mode 100644
index 0000000..5f62f32
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rocket.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rojo.svg b/ui/src/assets/icons/file-types/rojo.svg
new file mode 100644
index 0000000..37c46ea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rojo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rollup.svg b/ui/src/assets/icons/file-types/rollup.svg
new file mode 100644
index 0000000..7fa0153
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rollup.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rome.svg b/ui/src/assets/icons/file-types/rome.svg
new file mode 100644
index 0000000..8f5de92
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rome.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/routing.svg b/ui/src/assets/icons/file-types/routing.svg
new file mode 100644
index 0000000..ea02c90
--- /dev/null
+++ b/ui/src/assets/icons/file-types/routing.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rspec.svg b/ui/src/assets/icons/file-types/rspec.svg
new file mode 100644
index 0000000..c1bf424
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rspec.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rubocop.svg b/ui/src/assets/icons/file-types/rubocop.svg
new file mode 100644
index 0000000..e6a24a2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rubocop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rubocop_light.svg b/ui/src/assets/icons/file-types/rubocop_light.svg
new file mode 100644
index 0000000..689c023
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rubocop_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ruby.svg b/ui/src/assets/icons/file-types/ruby.svg
new file mode 100644
index 0000000..2e3215d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ruby.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/ruff.svg b/ui/src/assets/icons/file-types/ruff.svg
new file mode 100644
index 0000000..a526788
--- /dev/null
+++ b/ui/src/assets/icons/file-types/ruff.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/rust.svg b/ui/src/assets/icons/file-types/rust.svg
new file mode 100644
index 0000000..b382aa4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/rust.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/salesforce.svg b/ui/src/assets/icons/file-types/salesforce.svg
new file mode 100644
index 0000000..80e1aa9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/salesforce.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/san.svg b/ui/src/assets/icons/file-types/san.svg
new file mode 100644
index 0000000..d17b9fa
--- /dev/null
+++ b/ui/src/assets/icons/file-types/san.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sas.svg b/ui/src/assets/icons/file-types/sas.svg
new file mode 100644
index 0000000..d47c8bd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sas.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sass.svg b/ui/src/assets/icons/file-types/sass.svg
new file mode 100644
index 0000000..6f39acb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sass.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sbt.svg b/ui/src/assets/icons/file-types/sbt.svg
new file mode 100644
index 0000000..37587c5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sbt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/scala.svg b/ui/src/assets/icons/file-types/scala.svg
new file mode 100644
index 0000000..08e0c2d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/scala.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/scheme.svg b/ui/src/assets/icons/file-types/scheme.svg
new file mode 100644
index 0000000..c8f986e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/scheme.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/scons.svg b/ui/src/assets/icons/file-types/scons.svg
new file mode 100644
index 0000000..d584ea8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/scons.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/scons_light.svg b/ui/src/assets/icons/file-types/scons_light.svg
new file mode 100644
index 0000000..31f88d5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/scons_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/screwdriver.svg b/ui/src/assets/icons/file-types/screwdriver.svg
new file mode 100644
index 0000000..cac8206
--- /dev/null
+++ b/ui/src/assets/icons/file-types/screwdriver.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/search.svg b/ui/src/assets/icons/file-types/search.svg
new file mode 100644
index 0000000..3d35c8e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/search.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/semantic-release.svg b/ui/src/assets/icons/file-types/semantic-release.svg
new file mode 100644
index 0000000..17187e8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/semantic-release.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/semantic-release_light.svg b/ui/src/assets/icons/file-types/semantic-release_light.svg
new file mode 100644
index 0000000..21e42a0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/semantic-release_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/semgrep.svg b/ui/src/assets/icons/file-types/semgrep.svg
new file mode 100644
index 0000000..73a8abc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/semgrep.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sentry.svg b/ui/src/assets/icons/file-types/sentry.svg
new file mode 100644
index 0000000..319e60a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sentry.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sequelize.svg b/ui/src/assets/icons/file-types/sequelize.svg
new file mode 100644
index 0000000..0e4c788
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sequelize.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/serverless.svg b/ui/src/assets/icons/file-types/serverless.svg
new file mode 100644
index 0000000..92ccca8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/serverless.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/settings.svg b/ui/src/assets/icons/file-types/settings.svg
new file mode 100644
index 0000000..dc701ae
--- /dev/null
+++ b/ui/src/assets/icons/file-types/settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/shader.svg b/ui/src/assets/icons/file-types/shader.svg
new file mode 100644
index 0000000..f42156e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/shader.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/silverstripe.svg b/ui/src/assets/icons/file-types/silverstripe.svg
new file mode 100644
index 0000000..46cdb7e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/silverstripe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/simulink.svg b/ui/src/assets/icons/file-types/simulink.svg
new file mode 100644
index 0000000..33e97fe
--- /dev/null
+++ b/ui/src/assets/icons/file-types/simulink.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/siyuan.svg b/ui/src/assets/icons/file-types/siyuan.svg
new file mode 100644
index 0000000..7a7488d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/siyuan.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sketch.svg b/ui/src/assets/icons/file-types/sketch.svg
new file mode 100644
index 0000000..0d75406
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sketch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/slim.svg b/ui/src/assets/icons/file-types/slim.svg
new file mode 100644
index 0000000..edc7241
--- /dev/null
+++ b/ui/src/assets/icons/file-types/slim.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/slint.svg b/ui/src/assets/icons/file-types/slint.svg
new file mode 100644
index 0000000..b6434ec
--- /dev/null
+++ b/ui/src/assets/icons/file-types/slint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/slug.svg b/ui/src/assets/icons/file-types/slug.svg
new file mode 100644
index 0000000..da1dcc7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/slug.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/smarty.svg b/ui/src/assets/icons/file-types/smarty.svg
new file mode 100644
index 0000000..4572a58
--- /dev/null
+++ b/ui/src/assets/icons/file-types/smarty.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sml.svg b/ui/src/assets/icons/file-types/sml.svg
new file mode 100644
index 0000000..8f92a33
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/snakemake.svg b/ui/src/assets/icons/file-types/snakemake.svg
new file mode 100644
index 0000000..6dd08c9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/snakemake.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/snapcraft.svg b/ui/src/assets/icons/file-types/snapcraft.svg
new file mode 100644
index 0000000..17bf8d8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/snapcraft.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/snowpack.svg b/ui/src/assets/icons/file-types/snowpack.svg
new file mode 100644
index 0000000..7941fae
--- /dev/null
+++ b/ui/src/assets/icons/file-types/snowpack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/snowpack_light.svg b/ui/src/assets/icons/file-types/snowpack_light.svg
new file mode 100644
index 0000000..70389d2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/snowpack_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/snyk.svg b/ui/src/assets/icons/file-types/snyk.svg
new file mode 100644
index 0000000..90791ee
--- /dev/null
+++ b/ui/src/assets/icons/file-types/snyk.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/solidity.svg b/ui/src/assets/icons/file-types/solidity.svg
new file mode 100644
index 0000000..6ae9873
--- /dev/null
+++ b/ui/src/assets/icons/file-types/solidity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sonarcloud.svg b/ui/src/assets/icons/file-types/sonarcloud.svg
new file mode 100644
index 0000000..ee98961
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sonarcloud.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sprite.svg b/ui/src/assets/icons/file-types/sprite.svg
new file mode 100644
index 0000000..c46adad
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sprite.svg
@@ -0,0 +1,6509 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/spwn.svg b/ui/src/assets/icons/file-types/spwn.svg
new file mode 100644
index 0000000..8a8bf43
--- /dev/null
+++ b/ui/src/assets/icons/file-types/spwn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stackblitz.svg b/ui/src/assets/icons/file-types/stackblitz.svg
new file mode 100644
index 0000000..f1806a8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stackblitz.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stan.svg b/ui/src/assets/icons/file-types/stan.svg
new file mode 100644
index 0000000..bb5cf67
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stan.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/steadybit.svg b/ui/src/assets/icons/file-types/steadybit.svg
new file mode 100644
index 0000000..4871bbd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/steadybit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stencil.svg b/ui/src/assets/icons/file-types/stencil.svg
new file mode 100644
index 0000000..bf8f3ea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stencil.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stitches.svg b/ui/src/assets/icons/file-types/stitches.svg
new file mode 100644
index 0000000..a597fbc
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stitches.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stitches_light.svg b/ui/src/assets/icons/file-types/stitches_light.svg
new file mode 100644
index 0000000..8001d9d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stitches_light.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/storybook.svg b/ui/src/assets/icons/file-types/storybook.svg
new file mode 100644
index 0000000..8a4bdea
--- /dev/null
+++ b/ui/src/assets/icons/file-types/storybook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stryker.svg b/ui/src/assets/icons/file-types/stryker.svg
new file mode 100644
index 0000000..05d45e6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stryker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stylable.svg b/ui/src/assets/icons/file-types/stylable.svg
new file mode 100644
index 0000000..be55226
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stylable.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stylelint.svg b/ui/src/assets/icons/file-types/stylelint.svg
new file mode 100644
index 0000000..eb64524
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stylelint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/stylelint_light.svg b/ui/src/assets/icons/file-types/stylelint_light.svg
new file mode 100644
index 0000000..502fec3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stylelint_light.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/stylus.svg b/ui/src/assets/icons/file-types/stylus.svg
new file mode 100644
index 0000000..ae61b48
--- /dev/null
+++ b/ui/src/assets/icons/file-types/stylus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sublime.svg b/ui/src/assets/icons/file-types/sublime.svg
new file mode 100644
index 0000000..5c99fb9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sublime.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/subtitles.svg b/ui/src/assets/icons/file-types/subtitles.svg
new file mode 100644
index 0000000..15eebd6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/subtitles.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/supabase.svg b/ui/src/assets/icons/file-types/supabase.svg
new file mode 100644
index 0000000..78bfef7
--- /dev/null
+++ b/ui/src/assets/icons/file-types/supabase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/svelte.svg b/ui/src/assets/icons/file-types/svelte.svg
new file mode 100644
index 0000000..4b14a6f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/svelte.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/svg.svg b/ui/src/assets/icons/file-types/svg.svg
new file mode 100644
index 0000000..fbaf9e5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/svg.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/svgo.svg b/ui/src/assets/icons/file-types/svgo.svg
new file mode 100644
index 0000000..4b9cb89
--- /dev/null
+++ b/ui/src/assets/icons/file-types/svgo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/svgr.svg b/ui/src/assets/icons/file-types/svgr.svg
new file mode 100644
index 0000000..0144322
--- /dev/null
+++ b/ui/src/assets/icons/file-types/svgr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/swagger.svg b/ui/src/assets/icons/file-types/swagger.svg
new file mode 100644
index 0000000..1f79152
--- /dev/null
+++ b/ui/src/assets/icons/file-types/swagger.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/sway.svg b/ui/src/assets/icons/file-types/sway.svg
new file mode 100644
index 0000000..adca328
--- /dev/null
+++ b/ui/src/assets/icons/file-types/sway.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/swc.svg b/ui/src/assets/icons/file-types/swc.svg
new file mode 100644
index 0000000..5931bd3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/swc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/swift.svg b/ui/src/assets/icons/file-types/swift.svg
new file mode 100644
index 0000000..df413c8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/swift.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/syncpack.svg b/ui/src/assets/icons/file-types/syncpack.svg
new file mode 100644
index 0000000..9c64e31
--- /dev/null
+++ b/ui/src/assets/icons/file-types/syncpack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/systemd.svg b/ui/src/assets/icons/file-types/systemd.svg
new file mode 100644
index 0000000..943b77f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/systemd.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/systemd_light.svg b/ui/src/assets/icons/file-types/systemd_light.svg
new file mode 100644
index 0000000..39e81f6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/systemd_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/table.svg b/ui/src/assets/icons/file-types/table.svg
new file mode 100644
index 0000000..040b383
--- /dev/null
+++ b/ui/src/assets/icons/file-types/table.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tailwindcss.svg b/ui/src/assets/icons/file-types/tailwindcss.svg
new file mode 100644
index 0000000..a55450d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tailwindcss.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/taskfile.svg b/ui/src/assets/icons/file-types/taskfile.svg
new file mode 100644
index 0000000..99a775f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/taskfile.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tauri.svg b/ui/src/assets/icons/file-types/tauri.svg
new file mode 100644
index 0000000..2c7aa26
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tauri.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/taze.svg b/ui/src/assets/icons/file-types/taze.svg
new file mode 100644
index 0000000..c6e3a3f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/taze.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tcl.svg b/ui/src/assets/icons/file-types/tcl.svg
new file mode 100644
index 0000000..3c196a6
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tcl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/teal.svg b/ui/src/assets/icons/file-types/teal.svg
new file mode 100644
index 0000000..770b63d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/teal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/templ.svg b/ui/src/assets/icons/file-types/templ.svg
new file mode 100644
index 0000000..5b79cfe
--- /dev/null
+++ b/ui/src/assets/icons/file-types/templ.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/template.svg b/ui/src/assets/icons/file-types/template.svg
new file mode 100644
index 0000000..604a6f8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/template.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/terraform.svg b/ui/src/assets/icons/file-types/terraform.svg
new file mode 100644
index 0000000..d072809
--- /dev/null
+++ b/ui/src/assets/icons/file-types/terraform.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/test-js.svg b/ui/src/assets/icons/file-types/test-js.svg
new file mode 100644
index 0000000..d6ea994
--- /dev/null
+++ b/ui/src/assets/icons/file-types/test-js.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/test-jsx.svg b/ui/src/assets/icons/file-types/test-jsx.svg
new file mode 100644
index 0000000..ea2d4da
--- /dev/null
+++ b/ui/src/assets/icons/file-types/test-jsx.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/test-ts.svg b/ui/src/assets/icons/file-types/test-ts.svg
new file mode 100644
index 0000000..0b4ec71
--- /dev/null
+++ b/ui/src/assets/icons/file-types/test-ts.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tex.svg b/ui/src/assets/icons/file-types/tex.svg
new file mode 100644
index 0000000..83fc24a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/textlint.svg b/ui/src/assets/icons/file-types/textlint.svg
new file mode 100644
index 0000000..a619bf0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/textlint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tilt.svg b/ui/src/assets/icons/file-types/tilt.svg
new file mode 100644
index 0000000..0ab8428
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tilt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tldraw.svg b/ui/src/assets/icons/file-types/tldraw.svg
new file mode 100644
index 0000000..c4e6d6b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tldraw.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tldraw_light.svg b/ui/src/assets/icons/file-types/tldraw_light.svg
new file mode 100644
index 0000000..41faab3
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tldraw_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tobi.svg b/ui/src/assets/icons/file-types/tobi.svg
new file mode 100644
index 0000000..1a576a1
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tobi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tobimake.svg b/ui/src/assets/icons/file-types/tobimake.svg
new file mode 100644
index 0000000..0ba3b3e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tobimake.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/todo.svg b/ui/src/assets/icons/file-types/todo.svg
new file mode 100644
index 0000000..281ed65
--- /dev/null
+++ b/ui/src/assets/icons/file-types/todo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/toml.svg b/ui/src/assets/icons/file-types/toml.svg
new file mode 100644
index 0000000..aa4f24c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/toml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/toml_light.svg b/ui/src/assets/icons/file-types/toml_light.svg
new file mode 100644
index 0000000..a85712b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/toml_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/travis.svg b/ui/src/assets/icons/file-types/travis.svg
new file mode 100644
index 0000000..37a69a8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/travis.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tree.svg b/ui/src/assets/icons/file-types/tree.svg
new file mode 100644
index 0000000..a3b6d57
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tree.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/trigger.svg b/ui/src/assets/icons/file-types/trigger.svg
new file mode 100644
index 0000000..7a4f63a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/trigger.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tsconfig.svg b/ui/src/assets/icons/file-types/tsconfig.svg
new file mode 100644
index 0000000..817fb8d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tsconfig.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tsdoc.svg b/ui/src/assets/icons/file-types/tsdoc.svg
new file mode 100644
index 0000000..e7e04d0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tsdoc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tsil.svg b/ui/src/assets/icons/file-types/tsil.svg
new file mode 100644
index 0000000..261d7cd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tsil.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/tune.svg b/ui/src/assets/icons/file-types/tune.svg
new file mode 100644
index 0000000..ecbde06
--- /dev/null
+++ b/ui/src/assets/icons/file-types/tune.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/turborepo.svg b/ui/src/assets/icons/file-types/turborepo.svg
new file mode 100644
index 0000000..f0e5449
--- /dev/null
+++ b/ui/src/assets/icons/file-types/turborepo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/turborepo_light.svg b/ui/src/assets/icons/file-types/turborepo_light.svg
new file mode 100644
index 0000000..b020a35
--- /dev/null
+++ b/ui/src/assets/icons/file-types/turborepo_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/twig.svg b/ui/src/assets/icons/file-types/twig.svg
new file mode 100644
index 0000000..01f9a5d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/twig.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/twine.svg b/ui/src/assets/icons/file-types/twine.svg
new file mode 100644
index 0000000..ac1bc55
--- /dev/null
+++ b/ui/src/assets/icons/file-types/twine.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/typescript-def.svg b/ui/src/assets/icons/file-types/typescript-def.svg
new file mode 100644
index 0000000..a9ef958
--- /dev/null
+++ b/ui/src/assets/icons/file-types/typescript-def.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/typescript.svg b/ui/src/assets/icons/file-types/typescript.svg
new file mode 100644
index 0000000..acaf0dd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/typescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/typst.svg b/ui/src/assets/icons/file-types/typst.svg
new file mode 100644
index 0000000..a364734
--- /dev/null
+++ b/ui/src/assets/icons/file-types/typst.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/umi.svg b/ui/src/assets/icons/file-types/umi.svg
new file mode 100644
index 0000000..7479a4b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/umi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/uml.svg b/ui/src/assets/icons/file-types/uml.svg
new file mode 100644
index 0000000..5f70f1e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/uml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/uml_light.svg b/ui/src/assets/icons/file-types/uml_light.svg
new file mode 100644
index 0000000..c296fac
--- /dev/null
+++ b/ui/src/assets/icons/file-types/uml_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/unity.svg b/ui/src/assets/icons/file-types/unity.svg
new file mode 100644
index 0000000..f495772
--- /dev/null
+++ b/ui/src/assets/icons/file-types/unity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/unocss.svg b/ui/src/assets/icons/file-types/unocss.svg
new file mode 100644
index 0000000..eab05c4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/unocss.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/url.svg b/ui/src/assets/icons/file-types/url.svg
new file mode 100644
index 0000000..f065589
--- /dev/null
+++ b/ui/src/assets/icons/file-types/url.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/uv.svg b/ui/src/assets/icons/file-types/uv.svg
new file mode 100644
index 0000000..1549270
--- /dev/null
+++ b/ui/src/assets/icons/file-types/uv.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vagrant.svg b/ui/src/assets/icons/file-types/vagrant.svg
new file mode 100644
index 0000000..78c19f9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vagrant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vala.svg b/ui/src/assets/icons/file-types/vala.svg
new file mode 100644
index 0000000..114aff2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vala.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vanilla-extract.svg b/ui/src/assets/icons/file-types/vanilla-extract.svg
new file mode 100644
index 0000000..c1f1e59
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vanilla-extract.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/varnish.svg b/ui/src/assets/icons/file-types/varnish.svg
new file mode 100644
index 0000000..6b504af
--- /dev/null
+++ b/ui/src/assets/icons/file-types/varnish.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vedic.svg b/ui/src/assets/icons/file-types/vedic.svg
new file mode 100644
index 0000000..3dccbeb
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vedic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/velite.svg b/ui/src/assets/icons/file-types/velite.svg
new file mode 100644
index 0000000..ca50cfa
--- /dev/null
+++ b/ui/src/assets/icons/file-types/velite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/velocity.svg b/ui/src/assets/icons/file-types/velocity.svg
new file mode 100644
index 0000000..f5fb988
--- /dev/null
+++ b/ui/src/assets/icons/file-types/velocity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vercel.svg b/ui/src/assets/icons/file-types/vercel.svg
new file mode 100644
index 0000000..8ff6e49
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vercel_light.svg b/ui/src/assets/icons/file-types/vercel_light.svg
new file mode 100644
index 0000000..314b78c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vercel_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/verdaccio.svg b/ui/src/assets/icons/file-types/verdaccio.svg
new file mode 100644
index 0000000..3b5f1d4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/verdaccio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/verified.svg b/ui/src/assets/icons/file-types/verified.svg
new file mode 100644
index 0000000..0c861c5
--- /dev/null
+++ b/ui/src/assets/icons/file-types/verified.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/verilog.svg b/ui/src/assets/icons/file-types/verilog.svg
new file mode 100644
index 0000000..c546ea8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/verilog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vfl.svg b/ui/src/assets/icons/file-types/vfl.svg
new file mode 100644
index 0000000..3c371b4
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vfl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/video.svg b/ui/src/assets/icons/file-types/video.svg
new file mode 100644
index 0000000..2ade126
--- /dev/null
+++ b/ui/src/assets/icons/file-types/video.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vim.svg b/ui/src/assets/icons/file-types/vim.svg
new file mode 100644
index 0000000..1fc655d
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vim.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/virtual.svg b/ui/src/assets/icons/file-types/virtual.svg
new file mode 100644
index 0000000..0fdb620
--- /dev/null
+++ b/ui/src/assets/icons/file-types/virtual.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/visualstudio.svg b/ui/src/assets/icons/file-types/visualstudio.svg
new file mode 100644
index 0000000..15328de
--- /dev/null
+++ b/ui/src/assets/icons/file-types/visualstudio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vite.svg b/ui/src/assets/icons/file-types/vite.svg
new file mode 100644
index 0000000..d66cd5e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vitest.svg b/ui/src/assets/icons/file-types/vitest.svg
new file mode 100644
index 0000000..0a634e9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vitest.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vlang.svg b/ui/src/assets/icons/file-types/vlang.svg
new file mode 100644
index 0000000..17bf0e0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vlang.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/ui/src/assets/icons/file-types/vscode.svg b/ui/src/assets/icons/file-types/vscode.svg
new file mode 100644
index 0000000..bb3772a
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vscode.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vue-config.svg b/ui/src/assets/icons/file-types/vue-config.svg
new file mode 100644
index 0000000..bfe01c2
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vue-config.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vue.svg b/ui/src/assets/icons/file-types/vue.svg
new file mode 100644
index 0000000..359f899
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/vuex-store.svg b/ui/src/assets/icons/file-types/vuex-store.svg
new file mode 100644
index 0000000..c98a851
--- /dev/null
+++ b/ui/src/assets/icons/file-types/vuex-store.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wakatime.svg b/ui/src/assets/icons/file-types/wakatime.svg
new file mode 100644
index 0000000..66b8a6f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wakatime.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wakatime_light.svg b/ui/src/assets/icons/file-types/wakatime_light.svg
new file mode 100644
index 0000000..2b94c56
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wakatime_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wallaby.svg b/ui/src/assets/icons/file-types/wallaby.svg
new file mode 100644
index 0000000..0e7ce6e
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wallaby.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wally.svg b/ui/src/assets/icons/file-types/wally.svg
new file mode 100644
index 0000000..a5c1f24
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wally.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/watchman.svg b/ui/src/assets/icons/file-types/watchman.svg
new file mode 100644
index 0000000..74773cd
--- /dev/null
+++ b/ui/src/assets/icons/file-types/watchman.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/webassembly.svg b/ui/src/assets/icons/file-types/webassembly.svg
new file mode 100644
index 0000000..69a43aa
--- /dev/null
+++ b/ui/src/assets/icons/file-types/webassembly.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/webhint.svg b/ui/src/assets/icons/file-types/webhint.svg
new file mode 100644
index 0000000..fdaa668
--- /dev/null
+++ b/ui/src/assets/icons/file-types/webhint.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/webpack.svg b/ui/src/assets/icons/file-types/webpack.svg
new file mode 100644
index 0000000..68233d9
--- /dev/null
+++ b/ui/src/assets/icons/file-types/webpack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wepy.svg b/ui/src/assets/icons/file-types/wepy.svg
new file mode 100644
index 0000000..bed1ad0
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wepy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/werf.svg b/ui/src/assets/icons/file-types/werf.svg
new file mode 100644
index 0000000..7a89a1f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/werf.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/windicss.svg b/ui/src/assets/icons/file-types/windicss.svg
new file mode 100644
index 0000000..4f31c55
--- /dev/null
+++ b/ui/src/assets/icons/file-types/windicss.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wolframlanguage.svg b/ui/src/assets/icons/file-types/wolframlanguage.svg
new file mode 100644
index 0000000..77e8809
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wolframlanguage.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/word.svg b/ui/src/assets/icons/file-types/word.svg
new file mode 100644
index 0000000..a90b88f
--- /dev/null
+++ b/ui/src/assets/icons/file-types/word.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wrangler.svg b/ui/src/assets/icons/file-types/wrangler.svg
new file mode 100644
index 0000000..51a7983
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wrangler.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/wxt.svg b/ui/src/assets/icons/file-types/wxt.svg
new file mode 100644
index 0000000..d43b742
--- /dev/null
+++ b/ui/src/assets/icons/file-types/wxt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/xaml.svg b/ui/src/assets/icons/file-types/xaml.svg
new file mode 100644
index 0000000..0b7e865
--- /dev/null
+++ b/ui/src/assets/icons/file-types/xaml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/xmake.svg b/ui/src/assets/icons/file-types/xmake.svg
new file mode 100644
index 0000000..47b3ce8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/xmake.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/xml.svg b/ui/src/assets/icons/file-types/xml.svg
new file mode 100644
index 0000000..c3a1eaf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/xml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/yaml.svg b/ui/src/assets/icons/file-types/yaml.svg
new file mode 100644
index 0000000..1f1cc7c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/yaml.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/yang.svg b/ui/src/assets/icons/file-types/yang.svg
new file mode 100644
index 0000000..fba4bbf
--- /dev/null
+++ b/ui/src/assets/icons/file-types/yang.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/yarn.svg b/ui/src/assets/icons/file-types/yarn.svg
new file mode 100644
index 0000000..9af575c
--- /dev/null
+++ b/ui/src/assets/icons/file-types/yarn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/zeabur.svg b/ui/src/assets/icons/file-types/zeabur.svg
new file mode 100644
index 0000000..37b0ea8
--- /dev/null
+++ b/ui/src/assets/icons/file-types/zeabur.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/zeabur_light.svg b/ui/src/assets/icons/file-types/zeabur_light.svg
new file mode 100644
index 0000000..0d01f2b
--- /dev/null
+++ b/ui/src/assets/icons/file-types/zeabur_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/zig.svg b/ui/src/assets/icons/file-types/zig.svg
new file mode 100644
index 0000000..b5604df
--- /dev/null
+++ b/ui/src/assets/icons/file-types/zig.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/icons/file-types/zip.svg b/ui/src/assets/icons/file-types/zip.svg
new file mode 100644
index 0000000..1056c60
--- /dev/null
+++ b/ui/src/assets/icons/file-types/zip.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/assets/provider-logos/bailian-coding-plan.svg b/ui/src/assets/provider-logos/bailian-coding-plan.svg
new file mode 100644
index 0000000..b3a2edc
--- /dev/null
+++ b/ui/src/assets/provider-logos/bailian-coding-plan.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/ui/src/assets/provider-logos/evroc.svg b/ui/src/assets/provider-logos/evroc.svg
new file mode 100644
index 0000000..c7910df
--- /dev/null
+++ b/ui/src/assets/provider-logos/evroc.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/ui/src/assets/provider-logos/gocode.svg b/ui/src/assets/provider-logos/gocode.svg
new file mode 100644
index 0000000..7af2fbd
--- /dev/null
+++ b/ui/src/assets/provider-logos/gocode.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/ui/src/components/auth/SessionAuthGate.tsx b/ui/src/components/auth/SessionAuthGate.tsx
new file mode 100644
index 0000000..60a0f8f
--- /dev/null
+++ b/ui/src/components/auth/SessionAuthGate.tsx
@@ -0,0 +1,338 @@
+import React from 'react';
+import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
+import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
+import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
+import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
+import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
+
+const STATUS_CHECK_ENDPOINT = '/auth/session';
+
+const fetchSessionStatus = async (): Promise => {
+ console.log('[Frontend Auth] Checking session status...');
+ const response = await fetch(STATUS_CHECK_ENDPOINT, {
+ method: 'GET',
+ credentials: 'include',
+ headers: {
+ Accept: 'application/json',
+ },
+ });
+ console.log('[Frontend Auth] Session status response:', response.status, response.statusText);
+ return response;
+};
+
+const submitPassword = async (password: string): Promise => {
+ console.log('[Frontend Auth] Submitting password...');
+ const response = await fetch(STATUS_CHECK_ENDPOINT, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({ password }),
+ });
+ console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
+ return response;
+};
+
+const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+
+);
+
+const LoadingScreen: React.FC = () => (
+
+
+
+);
+
+const ErrorScreen: React.FC = ({ onRetry, errorType = 'network', retryAfter }) => {
+ const isRateLimit = errorType === 'rate-limit';
+ const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1;
+
+ return (
+
+
+
+
+ {isRateLimit ? 'Too many attempts' : 'Unable to reach server'}
+
+
+ {isRateLimit
+ ? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
+ : "We couldn't verify the UI session. Check that the service is running and try again."}
+
+
+
+ Retry
+
+
+
+ );
+};
+
+interface SessionAuthGateProps {
+ children: React.ReactNode;
+}
+
+type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
+
+interface ErrorScreenProps {
+ onRetry: () => void;
+ errorType?: 'network' | 'rate-limit';
+ retryAfter?: number;
+}
+
+export const SessionAuthGate: React.FC = ({ children }) => {
+ const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
+ const skipAuth = vscodeRuntime;
+ const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]);
+ const [state, setState] = React.useState(() => (skipAuth ? 'authenticated' : 'pending'));
+ const [password, setPassword] = React.useState('');
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState('');
+ const [retryAfter, setRetryAfter] = React.useState(undefined);
+ const passwordInputRef = React.useRef(null);
+ const hasResyncedRef = React.useRef(skipAuth);
+
+ const checkStatus = React.useCallback(async () => {
+ if (skipAuth) {
+ console.log('[Frontend Auth] VSCode runtime, skipping auth');
+ setState('authenticated');
+ return;
+ }
+
+ // 检查 cookie 是否存在
+ const cookies = document.cookie;
+ const hasAccessToken = cookies.includes('oc_ui_session=');
+ const hasRefreshToken = cookies.includes('oc_ui_refresh=');
+ console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken);
+ console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0]));
+
+ setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
+ try {
+ const response = await fetchSessionStatus();
+ const responseText = await response.text();
+ console.log('[Frontend Auth] Raw response:', response.status, responseText);
+
+ if (response.ok) {
+ console.log('[Frontend Auth] Session is authenticated');
+ setState('authenticated');
+ setErrorMessage('');
+ setRetryAfter(undefined);
+ return;
+ }
+ if (response.status === 401) {
+ console.warn('[Frontend Auth] Session is locked (401)');
+ setState('locked');
+ setRetryAfter(undefined);
+ return;
+ }
+ if (response.status === 429) {
+ let data: { retryAfter?: number } = {};
+ try {
+ data = JSON.parse(responseText);
+ } catch {
+ data = {};
+ }
+ setRetryAfter(data.retryAfter);
+ setState('rate-limited');
+ return;
+ }
+ console.error('[Frontend Auth] Unexpected response status:', response.status);
+ setState('error');
+ } catch (error) {
+ console.warn('Failed to check session status:', error);
+ setState('error');
+ }
+ }, [skipAuth]);
+
+ React.useEffect(() => {
+ if (skipAuth) {
+ return;
+ }
+ void checkStatus();
+ }, [checkStatus, skipAuth]);
+
+ React.useEffect(() => {
+ if (!skipAuth && state === 'locked') {
+ hasResyncedRef.current = false;
+ }
+ }, [skipAuth, state]);
+
+ React.useEffect(() => {
+ if (state === 'locked' && passwordInputRef.current) {
+ passwordInputRef.current.focus();
+ passwordInputRef.current.select();
+ }
+ }, [state]);
+
+ React.useEffect(() => {
+ if (skipAuth) {
+ return;
+ }
+ if (state === 'authenticated' && !hasResyncedRef.current) {
+ hasResyncedRef.current = true;
+ void (async () => {
+ await syncDesktopSettings();
+ await initializeAppearancePreferences();
+ await applyPersistedDirectoryPreferences();
+ })();
+ }
+ }, [skipAuth, state]);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!password || isSubmitting) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ setErrorMessage('');
+
+ try {
+ const response = await submitPassword(password);
+ if (response.ok) {
+ console.log('[Frontend Auth] Login successful');
+ const cookies = document.cookie;
+ const hasAccessToken = cookies.includes('oc_ui_session=');
+ const hasRefreshToken = cookies.includes('oc_ui_refresh=');
+ console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
+ console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
+ setPassword('');
+ setState('authenticated');
+ return;
+ }
+
+ if (response.status === 401) {
+ console.warn('[Frontend Auth] Login failed: Invalid password');
+ setErrorMessage('Incorrect password. Try again.');
+ setState('locked');
+ return;
+ }
+
+ if (response.status === 429) {
+ console.warn('[Frontend Auth] Login failed: Rate limited');
+ const data = await response.json().catch(() => ({}));
+ setRetryAfter(data.retryAfter);
+ setState('rate-limited');
+ return;
+ }
+
+ console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
+ setErrorMessage('Unexpected response from server.');
+ setState('error');
+ } catch (error) {
+ console.warn('Failed to submit UI password:', error);
+ setErrorMessage('Network error. Check connection and retry.');
+ setState('error');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (state === 'pending') {
+ return ;
+ }
+
+ if (state === 'error') {
+ return void checkStatus()} errorType="network" />;
+ }
+
+ if (state === 'rate-limited') {
+ return void checkStatus()} errorType="rate-limit" retryAfter={retryAfter} />;
+ }
+
+ if (state === 'locked') {
+ return (
+
+
+
+
+ Unlock OpenChamber
+
+
+ This session is password-protected.
+
+
+
+
+
+ {showHostSwitcher && (
+
+
+
+ Use Local if remote is unreachable.
+
+
+ )}
+
+
+ );
+ }
+
+ return <>{children}>;
+};
diff --git a/ui/src/components/chat/AgentMentionAutocomplete.tsx b/ui/src/components/chat/AgentMentionAutocomplete.tsx
new file mode 100644
index 0000000..3a1aa39
--- /dev/null
+++ b/ui/src/components/chat/AgentMentionAutocomplete.tsx
@@ -0,0 +1,245 @@
+import React from 'react';
+import { cn, fuzzyMatch } from '@/lib/utils';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+
+interface AgentInfo {
+ name: string;
+ description?: string;
+ mode?: string | null;
+ scope?: string;
+ isBuiltIn?: boolean;
+}
+
+export interface AgentMentionAutocompleteHandle {
+ handleKeyDown: (key: string) => void;
+}
+
+type AutocompleteTab = 'commands' | 'agents' | 'files';
+
+const isMentionableAgentMode = (mode?: string | null): boolean => {
+ if (!mode) return false;
+ return mode !== 'primary';
+};
+
+interface AgentMentionAutocompleteProps {
+ searchQuery: string;
+ onAgentSelect: (agentName: string) => void;
+ onClose: () => void;
+ showTabs?: boolean;
+ activeTab?: AutocompleteTab;
+ onTabSelect?: (tab: AutocompleteTab) => void;
+}
+
+export const AgentMentionAutocomplete = React.forwardRef(({
+ searchQuery,
+ onAgentSelect,
+ onClose,
+ showTabs,
+ activeTab = 'agents',
+ onTabSelect,
+}, ref) => {
+ const containerRef = React.useRef(null);
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
+ const [agents, setAgents] = React.useState([]);
+ const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const ignoreTabClickRef = React.useRef(false);
+ const { getVisibleAgents } = useConfigStore();
+ const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
+
+ React.useEffect(() => {
+ if (agentsWithMetadata.length === 0) {
+ void loadAgents();
+ }
+ }, [loadAgents, agentsWithMetadata.length]);
+
+ React.useEffect(() => {
+ const visibleAgents = getVisibleAgents();
+ const filtered = visibleAgents
+ .filter((agent) => isMentionableAgentMode(agent.mode))
+ .map((agent) => {
+ const metadata = agentsWithMetadata.find(a => a.name === agent.name) as (AgentWithExtras & { scope?: string }) | undefined;
+ return {
+ name: agent.name,
+ description: agent.description,
+ mode: agent.mode ?? undefined,
+ scope: metadata?.scope,
+ isBuiltIn: metadata ? isAgentBuiltIn(metadata) : false,
+ };
+ });
+
+ const normalizedQuery = searchQuery.trim();
+ const matches = normalizedQuery.length
+ ? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery))
+ : filtered;
+
+ matches.sort((a, b) => a.name.localeCompare(b.name));
+
+ setAgents(matches);
+ setSelectedIndex(0);
+ }, [getVisibleAgents, searchQuery, agentsWithMetadata]);
+
+ React.useEffect(() => {
+ itemRefs.current[selectedIndex]?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ });
+ }, [selectedIndex]);
+
+ React.useEffect(() => {
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (!target || !containerRef.current) {
+ return;
+ }
+ if (!containerRef.current.contains(target)) {
+ onClose();
+ }
+ };
+
+ document.addEventListener('pointerdown', handlePointerDown, true);
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown, true);
+ };
+ }, [onClose]);
+
+ React.useImperativeHandle(ref, () => ({
+ handleKeyDown: (key: string) => {
+ if (key === 'Escape') {
+ onClose();
+ return;
+ }
+
+ if (!agents.length) {
+ return;
+ }
+
+ if (key === 'ArrowDown') {
+ setSelectedIndex((prev) => (prev + 1) % agents.length);
+ return;
+ }
+
+ if (key === 'ArrowUp') {
+ setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
+ return;
+ }
+
+ if (key === 'Enter' || key === 'Tab') {
+ const agent = agents[(selectedIndex + agents.length) % agents.length];
+ if (agent) {
+ onAgentSelect(agent.name);
+ }
+ }
+ },
+ }), [agents, onAgentSelect, onClose, selectedIndex]);
+
+ const renderAgent = (agent: AgentInfo, index: number) => {
+ const isSystem = agent.isBuiltIn;
+ const isProject = agent.scope === 'project';
+
+ return (
+ {
+ itemRefs.current[index] = el;
+ }}
+ className={cn(
+ 'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
+ index === selectedIndex && 'bg-interactive-selection'
+ )}
+ onClick={() => onAgentSelect(agent.name)}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
+
+
+ #{agent.name}
+ {isSystem ? (
+
+ system
+
+ ) : agent.scope ? (
+
+ {agent.scope}
+
+ ) : null}
+
+ {agent.description && (
+
+ {agent.description}
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+ {showTabs ? (
+
+
+ {([
+ { id: 'commands' as const, label: 'Commands' },
+ { id: 'agents' as const, label: 'Agents' },
+ { id: 'files' as const, label: 'Files' },
+ ]).map((tab) => (
+ {
+ if (event.pointerType !== 'touch') {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ ignoreTabClickRef.current = true;
+ onTabSelect?.(tab.id);
+ }}
+ onClick={() => {
+ if (ignoreTabClickRef.current) {
+ ignoreTabClickRef.current = false;
+ return;
+ }
+ onTabSelect?.(tab.id);
+ }}
+ >
+ {tab.label}
+
+ ))}
+
+
+ ) : null}
+
+ {agents.length ? (
+
+ {agents.map((agent, index) => renderAgent(agent, index))}
+
+ ) : (
+
+ No agents found
+
+ )}
+
+
+ ↑↓ navigate • Enter select • Esc close
+
+
+ );
+});
+
+AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';
diff --git a/ui/src/components/chat/ChatContainer.tsx b/ui/src/components/chat/ChatContainer.tsx
new file mode 100644
index 0000000..aed1670
--- /dev/null
+++ b/ui/src/components/chat/ChatContainer.tsx
@@ -0,0 +1,686 @@
+import React from 'react';
+import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react';
+import { useShallow } from 'zustand/react/shallow';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+
+import { ChatInput } from './ChatInput';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { Skeleton } from '@/components/ui/skeleton';
+import ChatEmptyState from './ChatEmptyState';
+import MessageList, { type MessageListHandle } from './MessageList';
+import { ScrollShadow } from '@/components/ui/ScrollShadow';
+import { useChatScrollManager } from '@/hooks/useChatScrollManager';
+import { useDeviceInfo } from '@/lib/device';
+import { getMemoryLimits } from '@/stores/types/sessionTypes';
+import { Button } from '@/components/ui/button';
+import { ButtonSmall } from '@/components/ui/button-small';
+import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
+import { TimelineDialog } from './TimelineDialog';
+import type { PermissionRequest } from '@/types/permission';
+import type { QuestionRequest } from '@/types/question';
+import { cn } from '@/lib/utils';
+
+const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
+const EMPTY_PERMISSIONS: PermissionRequest[] = [];
+const EMPTY_QUESTIONS: QuestionRequest[] = [];
+const IDLE_SESSION_STATUS = { type: 'idle' as const };
+
+const collectVisibleSessionIdsForBlockingRequests = (
+ sessions: Array<{ id: string; parentID?: string }> | undefined,
+ currentSessionId: string | null
+): string[] => {
+ if (!currentSessionId) return [];
+ if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
+
+ const current = sessions.find((session) => session.id === currentSessionId);
+ if (!current) return [currentSessionId];
+
+ // Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
+ if (current.parentID) {
+ return [];
+ }
+
+ const childIds = sessions
+ .filter((session) => session.parentID === currentSessionId)
+ .map((session) => session.id);
+
+ return [currentSessionId, ...childIds];
+};
+
+const flattenBlockingRequests = (
+ source: Map,
+ sessionIds: string[]
+): T[] => {
+ if (sessionIds.length === 0) return [];
+ const seen = new Set();
+ const result: T[] = [];
+
+ for (const sessionId of sessionIds) {
+ const entries = source.get(sessionId);
+ if (!entries || entries.length === 0) continue;
+ for (const entry of entries) {
+ if (seen.has(entry.id)) continue;
+ seen.add(entry.id);
+ result.push(entry);
+ }
+ }
+
+ return result;
+};
+
+export const ChatContainer: React.FC = () => {
+ const {
+ currentSessionId,
+ isLoading,
+ loadMessages,
+ loadMoreMessages,
+ updateViewportAnchor,
+ openNewSessionDraft,
+ setCurrentSession,
+ trimToViewportWindow,
+ newSessionDraft,
+ } = useSessionStore(
+ useShallow((state) => ({
+ currentSessionId: state.currentSessionId,
+ isLoading: state.isLoading,
+ loadMessages: state.loadMessages,
+ loadMoreMessages: state.loadMoreMessages,
+ updateViewportAnchor: state.updateViewportAnchor,
+ openNewSessionDraft: state.openNewSessionDraft,
+ setCurrentSession: state.setCurrentSession,
+ trimToViewportWindow: state.trimToViewportWindow,
+ newSessionDraft: state.newSessionDraft,
+ }))
+ );
+
+ const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore(
+ useShallow((state) => ({
+ isSyncing: state.isSyncing,
+ messageStreamStates: state.messageStreamStates,
+ sessionMemoryStateMap: state.sessionMemoryState,
+ }))
+ );
+
+ const {
+ isTimelineDialogOpen,
+ setTimelineDialogOpen,
+ isExpandedInput,
+ stickyUserHeader,
+ } = useUIStore();
+
+ const sessionMessages = useSessionStore(
+ React.useCallback(
+ (state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
+ [currentSessionId]
+ )
+ );
+
+ const sessions = useSessionStore((state) => state.sessions);
+
+ const blockingRequestState = useSessionStore(
+ useShallow((state) => ({
+ sessions: state.sessions,
+ permissions: state.permissions,
+ questions: state.questions,
+ }))
+ );
+
+ const scopedSessionIds = React.useMemo(
+ () => collectVisibleSessionIdsForBlockingRequests(
+ blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
+ currentSessionId,
+ ),
+ [blockingRequestState.sessions, currentSessionId]
+ );
+
+ const sessionPermissions = React.useMemo(() => {
+ if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
+ return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds);
+ }, [blockingRequestState.permissions, scopedSessionIds]);
+
+ const sessionQuestions = React.useMemo(() => {
+ if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
+ return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
+ }, [blockingRequestState.questions, scopedSessionIds]);
+
+ const memoryState = useSessionStore(
+ React.useCallback(
+ (state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null),
+ [currentSessionId]
+ )
+ );
+
+ const streamingMessageId = useSessionStore(
+ React.useCallback(
+ (state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
+ [currentSessionId]
+ )
+ );
+
+ const sessionStatusForCurrent = useSessionStore(
+ React.useCallback(
+ (state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
+ [currentSessionId]
+ )
+ );
+
+ const hasSessionMessagesEntry = useSessionStore(
+ React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
+ );
+
+ const { isMobile } = useDeviceInfo();
+ const draftOpen = Boolean(newSessionDraft?.open);
+ const isDesktopExpandedInput = isExpandedInput && !isMobile;
+ const messageListRef = React.useRef(null);
+
+ const parentSession = React.useMemo(() => {
+ if (!currentSessionId) {
+ return null;
+ }
+
+ const current = sessions.find((session) => session.id === currentSessionId);
+ const parentID = current?.parentID;
+ if (!parentID) {
+ return null;
+ }
+
+ return sessions.find((session) => session.id === parentID) ?? null;
+ }, [currentSessionId, sessions]);
+
+ const handleReturnToParentSession = React.useCallback(() => {
+ if (!parentSession) {
+ return;
+ }
+ void setCurrentSession(parentSession.id);
+ }, [parentSession, setCurrentSession]);
+
+ const returnToParentButton = parentSession ? (
+
+
+ Parent
+
+ ) : null;
+
+ React.useEffect(() => {
+ if (!currentSessionId && !draftOpen) {
+ openNewSessionDraft();
+ }
+ }, [currentSessionId, draftOpen, openNewSessionDraft]);
+
+ const [turnStart, setTurnStart] = React.useState(0);
+ const turnHandleRef = React.useRef(null);
+ const turnIdleRef = React.useRef(false);
+ const initializedTurnStartSessionRef = React.useRef(null);
+ const TURN_INIT = 5;
+ const TURN_BATCH = 8;
+
+ const userTurnIndexes = React.useMemo(() => {
+ const indexes: number[] = [];
+ for (let i = 0; i < sessionMessages.length; i += 1) {
+ const message = sessionMessages[i];
+ const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
+ if (role === 'user') {
+ indexes.push(i);
+ }
+ }
+ return indexes;
+ }, [sessionMessages]);
+
+ const cancelTurnBackfill = React.useCallback(() => {
+ const handle = turnHandleRef.current;
+ if (handle === null) {
+ return;
+ }
+ turnHandleRef.current = null;
+ if (turnIdleRef.current && typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') {
+ window.cancelIdleCallback(handle);
+ return;
+ }
+ if (typeof window !== 'undefined') {
+ window.clearTimeout(handle);
+ }
+ }, []);
+
+ const renderedSessionMessages = React.useMemo(() => {
+ if (turnStart <= 0 || userTurnIndexes.length === 0) {
+ return sessionMessages;
+ }
+ const startIndex = userTurnIndexes[turnStart] ?? 0;
+ return sessionMessages.slice(startIndex);
+ }, [sessionMessages, turnStart, userTurnIndexes]);
+
+ const backfillTurns = React.useCallback(() => {
+ if (turnStart <= 0) {
+ return;
+ }
+
+ const container = typeof document !== 'undefined'
+ ? (document.querySelector('[data-scrollbar="chat"]') as HTMLDivElement | null)
+ : null;
+ const beforeTop = container?.scrollTop ?? null;
+ const beforeHeight = container?.scrollHeight ?? null;
+
+ setTurnStart((prev) => (prev - TURN_BATCH > 0 ? prev - TURN_BATCH : 0));
+
+ if (container && beforeTop !== null && beforeHeight !== null) {
+ window.requestAnimationFrame(() => {
+ const delta = container.scrollHeight - beforeHeight;
+ if (delta !== 0) {
+ container.scrollTop = beforeTop + delta;
+ }
+ });
+ }
+ }, [turnStart]);
+
+ const scheduleTurnBackfill = React.useCallback(() => {
+ if (turnHandleRef.current !== null || turnStart <= 0) {
+ return;
+ }
+
+ if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
+ turnIdleRef.current = true;
+ turnHandleRef.current = window.requestIdleCallback(() => {
+ turnHandleRef.current = null;
+ backfillTurns();
+ });
+ return;
+ }
+
+ turnIdleRef.current = false;
+ turnHandleRef.current = window.setTimeout(() => {
+ turnHandleRef.current = null;
+ backfillTurns();
+ }, 0);
+ }, [backfillTurns, turnStart]);
+
+ const sessionBlockingCards = React.useMemo(() => {
+ return [...sessionPermissions, ...sessionQuestions];
+ }, [sessionPermissions, sessionQuestions]);
+
+ const {
+ scrollRef,
+ handleMessageContentChange,
+ getAnimationHandlers,
+ showScrollButton,
+ scrollToBottom,
+ scrollToPosition,
+ isPinned,
+ } = useChatScrollManager({
+ currentSessionId,
+ sessionMessages: renderedSessionMessages,
+ streamingMessageId,
+ sessionMemoryState: sessionMemoryStateMap,
+ updateViewportAnchor,
+ isSyncing,
+ isMobile,
+ messageStreamStates,
+ sessionPermissions: sessionBlockingCards,
+ trimToViewportWindow,
+ });
+
+ React.useLayoutEffect(() => {
+ const container = scrollRef.current;
+ if (!container) {
+ return;
+ }
+
+ const updateChatScrollHeight = () => {
+ container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
+ };
+
+ updateChatScrollHeight();
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', updateChatScrollHeight);
+ return () => {
+ window.removeEventListener('resize', updateChatScrollHeight);
+ };
+ }
+
+ const resizeObserver = new ResizeObserver(updateChatScrollHeight);
+ resizeObserver.observe(container);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [currentSessionId, isDesktopExpandedInput, scrollRef]);
+
+ React.useEffect(() => {
+ cancelTurnBackfill();
+ if (!currentSessionId) {
+ initializedTurnStartSessionRef.current = null;
+ setTurnStart(0);
+ return;
+ }
+
+ if (initializedTurnStartSessionRef.current === currentSessionId) {
+ return;
+ }
+
+ if (sessionMessages.length === 0) {
+ setTurnStart(0);
+ return;
+ }
+
+ const turnCount = userTurnIndexes.length;
+ const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0;
+ setTurnStart(start);
+ initializedTurnStartSessionRef.current = currentSessionId;
+ }, [cancelTurnBackfill, currentSessionId, sessionMessages.length, userTurnIndexes.length]);
+
+ const isSessionActive = sessionStatusForCurrent.type === 'busy' || sessionStatusForCurrent.type === 'retry';
+
+ React.useEffect(() => {
+ if (isSessionActive) {
+ cancelTurnBackfill();
+ return;
+ }
+ scheduleTurnBackfill();
+ return () => {
+ cancelTurnBackfill();
+ };
+ }, [cancelTurnBackfill, isSessionActive, scheduleTurnBackfill, turnStart]);
+
+ const hasMoreAbove = React.useMemo(() => {
+ if (!memoryState) {
+ return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
+ }
+ if (memoryState.historyComplete === true) {
+ return false;
+ }
+ if (memoryState.hasMoreAbove) {
+ return true;
+ }
+ if (memoryState.historyComplete === false) {
+ return true;
+ }
+
+ // Backward compatibility: older persisted sessions may miss history flags.
+ if (memoryState.hasMoreAbove === undefined && memoryState.historyComplete === undefined) {
+ return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
+ }
+
+ return false;
+ }, [memoryState, sessionMessages.length]);
+
+ const hasHistoryMetadata = React.useMemo(() => {
+ if (!memoryState) {
+ return false;
+ }
+ return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined;
+ }, [memoryState]);
+ const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
+ React.useEffect(() => {
+ setIsLoadingOlder(false);
+ }, [currentSessionId]);
+
+ const handleLoadOlder = React.useCallback(async () => {
+ if (!currentSessionId || isLoadingOlder) {
+ return;
+ }
+
+ cancelTurnBackfill();
+ setTurnStart(0);
+
+ const container = scrollRef.current;
+ const anchor = messageListRef.current?.captureViewportAnchor() ?? null;
+ const prevHeight = container?.scrollHeight ?? null;
+ const prevTop = container?.scrollTop ?? null;
+
+ setIsLoadingOlder(true);
+ void loadMoreMessages(currentSessionId, 'up')
+ .then(() => {
+ const restored = anchor ? (messageListRef.current?.restoreViewportAnchor(anchor) ?? false) : false;
+ if (!restored && container && prevHeight !== null && prevTop !== null) {
+ const heightDiff = container.scrollHeight - prevHeight;
+ scrollToPosition(prevTop + heightDiff, { instant: true });
+ }
+ })
+ .finally(() => {
+ setIsLoadingOlder(false);
+ });
+ }, [cancelTurnBackfill, currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
+
+ const handleRenderEarlier = React.useCallback(() => {
+ cancelTurnBackfill();
+ setTurnStart(0);
+ }, [cancelTurnBackfill]);
+
+ // Scroll to a specific message by ID (for timeline dialog)
+ const scrollToMessage = React.useCallback((messageId: string) => {
+ if (messageListRef.current?.scrollToMessageId(messageId, { behavior: 'smooth' })) {
+ return;
+ }
+
+ const container = scrollRef.current;
+ if (!container) return;
+
+ // Find the message element by looking for data-message-id attribute
+ const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement;
+ if (messageElement) {
+ // Scroll to the message with some padding (50px from top)
+ const containerRect = container.getBoundingClientRect();
+ const messageRect = messageElement.getBoundingClientRect();
+ const offset = 50;
+
+ const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset;
+ container.scrollTo({
+ top: scrollTop,
+ behavior: 'smooth'
+ });
+ }
+ }, [scrollRef]);
+
+ React.useEffect(() => {
+ if (!currentSessionId) {
+ return;
+ }
+
+ const hasSessionMessages = hasSessionMessagesEntry;
+ if (hasSessionMessages && hasHistoryMetadata) {
+ return;
+ }
+
+ const load = async () => {
+ await loadMessages(currentSessionId).finally(() => {
+ const statusType = sessionStatusForCurrent.type ?? 'idle';
+ const isActivePhase = statusType === 'busy' || statusType === 'retry';
+ const shouldSkipScroll = isActivePhase && isPinned;
+
+ if (!shouldSkipScroll) {
+ if (typeof window === 'undefined') {
+ scrollToBottom({ instant: true });
+ } else {
+ window.requestAnimationFrame(() => {
+ scrollToBottom({ instant: true });
+ });
+ }
+ }
+ });
+ };
+
+ void load();
+ }, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
+
+ if (!currentSessionId && !draftOpen) {
+ return (
+
+
+
+ );
+ }
+
+ if (!currentSessionId && draftOpen) {
+ return (
+
+ {!isDesktopExpandedInput ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+ }
+
+ if (!currentSessionId) {
+ return null;
+ }
+
+ if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
+ const hasMessagesEntry = hasSessionMessagesEntry;
+ if (!hasMessagesEntry) {
+ return (
+
+ {returnToParentButton}
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+ );
+ }
+ }
+
+ if (sessionMessages.length === 0 && !streamingMessageId) {
+ return (
+
+ {returnToParentButton}
+ {!isDesktopExpandedInput ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {returnToParentButton}
+
+
+
+
+ 0}
+ onRenderEarlier={handleRenderEarlier}
+ scrollToBottom={scrollToBottom}
+ scrollRef={scrollRef}
+ />
+
+
+
+
+
+
+
+ {!isDesktopExpandedInput && showScrollButton && sessionMessages.length > 0 && (
+
+ scrollToBottom({ force: true })}
+ className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
+ aria-label="Scroll to bottom"
+ >
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/ui/src/components/chat/ChatEmptyState.tsx b/ui/src/components/chat/ChatEmptyState.tsx
new file mode 100644
index 0000000..0dbd93f
--- /dev/null
+++ b/ui/src/components/chat/ChatEmptyState.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { RiGitBranchLine } from '@remixicon/react';
+
+import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
+import { TextLoop } from '@/components/ui/TextLoop';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
+import { useGitStatus, useGitStore } from '@/stores/useGitStore';
+
+const phrases = [
+ "Fix the failing tests",
+ "Refactor this to be more readable",
+ "Add form validation",
+ "Optimize this function",
+ "Write tests for this",
+ "Explain how this works",
+ "Add a new feature",
+ "Help me debug this",
+ "Review my code",
+ "Simplify this logic",
+ "Add error handling",
+ "Create a new component",
+ "Update the documentation",
+ "Find the bug here",
+ "Improve performance",
+ "Add type definitions",
+];
+
+interface ChatEmptyStateProps {
+ showDraftContext?: boolean;
+}
+
+const ChatEmptyState: React.FC = ({
+ showDraftContext = false,
+}) => {
+ const { currentTheme } = useThemeSystem();
+ const { git } = useRuntimeAPIs();
+ const effectiveDirectory = useEffectiveDirectory();
+ const { setActiveDirectory, fetchStatus } = useGitStore();
+ const gitStatus = useGitStatus(effectiveDirectory ?? null);
+
+ // Use theme's muted foreground for secondary text
+ const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
+ const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
+ ? gitStatus.current.trim()
+ : null;
+
+ React.useEffect(() => {
+ if (!showDraftContext || !effectiveDirectory) {
+ return;
+ }
+
+ setActiveDirectory(effectiveDirectory);
+
+ const state = useGitStore.getState().directories.get(effectiveDirectory);
+ if (!state?.status && state?.isGitRepo !== false) {
+ void fetchStatus(effectiveDirectory, git, { silent: true });
+ }
+ }, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
+
+ return (
+
+
+ {showDraftContext && (
+
+ {branchName && (
+
+
+ {branchName}
+
+ )}
+
+ )}
+
+ {phrases.map((phrase) => (
+ "{phrase}…"
+ ))}
+
+
+ );
+};
+
+export default React.memo(ChatEmptyState);
diff --git a/ui/src/components/chat/ChatErrorBoundary.tsx b/ui/src/components/chat/ChatErrorBoundary.tsx
new file mode 100644
index 0000000..c2fc26a
--- /dev/null
+++ b/ui/src/components/chat/ChatErrorBoundary.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { RiChat3Line, RiRestartLine } from '@remixicon/react';
+import { Button } from '../ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
+
+interface ChatErrorBoundaryState {
+ hasError: boolean;
+ error?: Error;
+ errorInfo?: React.ErrorInfo;
+}
+
+interface ChatErrorBoundaryProps {
+ children: React.ReactNode;
+ sessionId?: string;
+}
+
+export class ChatErrorBoundary extends React.Component {
+ constructor(props: ChatErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): ChatErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ this.setState({ error, errorInfo });
+
+ if (process.env.NODE_ENV === 'development') {
+ console.error('Chat error caught by boundary:', error, errorInfo);
+ }
+ }
+
+ handleReset = () => {
+ this.setState({ hasError: false, error: undefined, errorInfo: undefined });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
+
+ Chat Error
+
+
+
+
+ The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
+
+
+ {this.props.sessionId && (
+
+ Session: {this.props.sessionId}
+
+ )}
+
+ {this.state.error && (
+
+ Error details
+
+ {this.state.error.toString()}
+
+
+ )}
+
+
+
+
+ Reset Chat
+
+
+
+
+ If the problem persists, try refreshing the page.
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/ui/src/components/chat/ChatInput.tsx b/ui/src/components/chat/ChatInput.tsx
new file mode 100644
index 0000000..b6a18b7
--- /dev/null
+++ b/ui/src/components/chat/ChatInput.tsx
@@ -0,0 +1,2822 @@
+import React from 'react';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ RiAddCircleLine,
+ RiAiAgentLine,
+ RiAttachment2,
+ RiCloseLine,
+ RiCommandLine,
+ RiExternalLinkLine,
+ RiFullscreenLine,
+ RiGitPullRequestLine,
+ RiGithubLine,
+ RiSendPlane2Line,
+} from '@remixicon/react';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
+import type { AttachedFile } from '@/stores/types/sessionTypes';
+import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
+import { appendInlineComments } from '@/lib/messages/inlineComments';
+import { AttachedFilesList } from './FileAttachment';
+import { QueuedMessageChips } from './QueuedMessageChips';
+import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
+import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
+import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
+import { cn, isMacOS } from '@/lib/utils';
+import { ModelControls } from './ModelControls';
+import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
+import { parseAgentMentions } from '@/lib/messages/agentMentions';
+import { StatusRow } from './StatusRow';
+import { MobileAgentButton } from './MobileAgentButton';
+import { MobileModelButton } from './MobileModelButton';
+import { MobileSessionStatusBar } from './MobileSessionStatusBar';
+import { useAssistantStatus } from '@/hooks/useAssistantStatus';
+import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
+import { toast } from '@/components/ui';
+import { useFileStore } from '@/stores/fileStore';
+import { useMessageStore } from '@/stores/messageStore';
+import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
+import { isIMECompositionEvent } from '@/lib/ime';
+import { StopIcon } from '@/components/icons/StopIcon';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import type { MobileControlsPanel } from './mobileControlsUtils';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
+import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
+import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
+import { opencodeClient } from '@/lib/opencode/client';
+
+const MAX_VISIBLE_TEXTAREA_LINES = 8;
+const EMPTY_QUEUE: QueuedMessage[] = [];
+const FILE_MENTION_TOKEN = /^@[^\s]+$/;
+
+interface ChatInputProps {
+ onOpenSettings?: () => void;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+}
+
+type AutocompleteOverlayPosition = {
+ top: number;
+ left: number;
+ place: 'above' | 'below';
+ maxHeight: number;
+};
+
+// Per-session draft key — preserves in-progress messages across project switches
+const getDraftKey = (sessionId: string | null): string =>
+ `openchamber_chat_input_draft_${sessionId ?? 'new'}`;
+
+// Helper to safely read from localStorage for a given session
+const getStoredDraft = (sessionId: string | null): string => {
+ try {
+ return localStorage.getItem(getDraftKey(sessionId)) ?? '';
+ } catch {
+ return '';
+ }
+};
+
+// Helper to safely write/clear a per-session draft
+const saveStoredDraft = (sessionId: string | null, draft: string): void => {
+ try {
+ if (draft) {
+ localStorage.setItem(getDraftKey(sessionId), draft);
+ } else {
+ localStorage.removeItem(getDraftKey(sessionId));
+ }
+ } catch {
+ // Ignore localStorage errors
+ }
+};
+
+export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => {
+ // Track if we restored a draft on mount (for text selection)
+ const initialDraftRef = React.useRef(null);
+ // Track initial session ID (captured at mount time for draft restoration)
+ const initialSessionIdRef = React.useRef(null);
+ const [message, setMessage] = React.useState(() => {
+ // Read per-session draft at mount time using the current session from the store
+ const sessionId = useSessionStore.getState().currentSessionId;
+ initialSessionIdRef.current = sessionId;
+ const draft = getStoredDraft(sessionId);
+ if (draft) {
+ initialDraftRef.current = draft;
+ }
+ return draft;
+ });
+ const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
+ const [isDragging, setIsDragging] = React.useState(false);
+ const [showFileMention, setShowFileMention] = React.useState(false);
+ const [mentionQuery, setMentionQuery] = React.useState('');
+ const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
+ const [commandQuery, setCommandQuery] = React.useState('');
+ const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands');
+ const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
+ const [skillQuery, setSkillQuery] = React.useState('');
+ const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
+ const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false);
+ const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null);
+ // Message history navigation state (up/down arrow to recall previous messages)
+ const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
+ const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
+ const textareaRef = React.useRef(null);
+ const dropZoneRef = React.useRef(null);
+ const canAcceptDropRef = React.useRef(false);
+ const nativeDragInsideDropZoneRef = React.useRef(false);
+ const mentionRef = React.useRef(null);
+ const commandRef = React.useRef(null);
+ const skillRef = React.useRef(null);
+ // Ref to track current message value without triggering re-renders in effects
+ const messageRef = React.useRef(message);
+
+ const sendMessage = useSessionStore((state) => state.sendMessage);
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
+ const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
+ const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
+ const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
+ const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
+ const attachedFiles = useSessionStore((state) => state.attachedFiles);
+ const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
+ const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
+ const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
+ const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
+ const pendingInputText = useSessionStore((state) => state.pendingInputText);
+ const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
+
+ const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
+ const agents = getVisibleAgents();
+ const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
+ const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
+ const { working } = useAssistantStatus();
+ const { currentTheme } = useThemeSystem();
+ const chatSearchDirectory = useChatSearchDirectory();
+ const [showAbortStatus, setShowAbortStatus] = React.useState(false);
+ const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
+
+ const isDesktopExpanded = isExpandedInput && !isMobile;
+
+ const sendableAttachedFiles = React.useMemo(
+ () => attachedFiles.filter((file) => file.source !== 'server'),
+ [attachedFiles],
+ );
+
+ const hasInlineMentionForHighlight = React.useMemo(() => {
+ if (!message || !message.includes('@') || inputMode === 'shell') {
+ return false;
+ }
+ const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
+ const mentionRegex = /@([^\s]+)/g;
+ let match: RegExpExecArray | null;
+ while ((match = mentionRegex.exec(message)) !== null) {
+ const offset = match.index;
+ const charBefore = offset > 0 ? message[offset - 1] : null;
+ if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
+ continue;
+ }
+ const mentionPath = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
+ if (!mentionPath) {
+ continue;
+ }
+ if (knownAgentNames.has(mentionPath.toLowerCase())) {
+ return true;
+ }
+ if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) {
+ return true;
+ }
+ }
+ return false;
+ }, [agents, inputMode, message]);
+
+ const highlightedComposerContent = React.useMemo(() => {
+ if (!hasInlineMentionForHighlight) {
+ return null;
+ }
+
+ const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
+ const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
+ const mentionRegex = /@([^\s]+)/g;
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = mentionRegex.exec(message)) !== null) {
+ const full = match[0];
+ const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
+ const start = match.index;
+ const end = start + full.length;
+ const charBefore = start > 0 ? message[start - 1] : null;
+ const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore);
+ const isAgentMention = isBoundary && mention.length > 0 && knownAgentNames.has(mention.toLowerCase());
+ const isFileMention = isBoundary
+ && mention.length > 0
+ && !knownAgentNames.has(mention.toLowerCase())
+ && (mention.includes('/') || mention.includes('\\') || mention.includes('.'));
+
+ if (start > lastIndex) {
+ parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' });
+ }
+ parts.push({
+ text: full,
+ mentionKind: isFileMention ? 'file' : isAgentMention ? 'agent' : 'none',
+ });
+ lastIndex = end;
+ }
+
+ if (lastIndex < message.length) {
+ parts.push({ text: message.slice(lastIndex), mentionKind: 'none' });
+ }
+
+ return parts;
+ }, [agents, hasInlineMentionForHighlight, message]);
+
+ const sanitizeAttachmentsForSend = React.useCallback(
+ (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
+ .filter((file) => file.source !== 'server')
+ .map((file) => ({ ...file })),
+ [],
+ );
+
+ const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
+ if (!rawText || !rawText.includes('@')) {
+ return { sanitizedText: rawText, attachments: [] };
+ }
+
+ const clientDirectory = opencodeClient.getDirectory() || '';
+ const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
+ const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
+ const seenPaths = new Set();
+ const attachments: AttachedFile[] = [];
+
+ const mentionRegex = /@([^\s]+)/g;
+ let match: RegExpExecArray | null;
+ while ((match = mentionRegex.exec(rawText)) !== null) {
+ const rawMentionPath = match[1];
+ const offset = match.index;
+ const original = rawText;
+ const charBefore = offset > 0 ? original[offset - 1] : null;
+ if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
+ continue;
+ }
+
+ const mentionPath = String(rawMentionPath || '')
+ .trim()
+ .replace(/^[`"'<(]+/, '')
+ .replace(/[),.;:!?`"'>]+$/g, '');
+ if (!mentionPath) {
+ continue;
+ }
+
+ if (knownAgentNames.has(mentionPath.toLowerCase())) {
+ continue;
+ }
+
+ const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.');
+ if (!looksLikeFilePath) {
+ continue;
+ }
+
+ const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
+ if (!normalizedMentionPath) {
+ continue;
+ }
+
+ const serverPath = mentionPath.startsWith('/')
+ ? mentionPath.replace(/\\/g, '/')
+ : root
+ ? `${root}/${normalizedMentionPath}`
+ : null;
+
+ if (!serverPath) {
+ continue;
+ }
+
+ const normalizedServerPath = serverPath.replace(/\/+/g, '/');
+ if (seenPaths.has(normalizedServerPath)) {
+ continue;
+ }
+ seenPaths.add(normalizedServerPath);
+
+ const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
+ attachments.push({
+ id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ file: new File([], filename, { type: 'text/plain' }),
+ filename,
+ mimeType: 'text/plain',
+ size: 0,
+ dataUrl: normalizedServerPath,
+ source: 'server',
+ serverPath: normalizedServerPath,
+ });
+ }
+
+ return {
+ sanitizedText: rawText,
+ attachments,
+ };
+ }, [agents, chatSearchDirectory]);
+ const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState(null);
+ const abortTimeoutRef = React.useRef | null>(null);
+ const prevWasAbortedRef = React.useRef(false);
+
+ // Issue linking state
+ const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
+ const [prPickerOpen, setPrPickerOpen] = React.useState(false);
+ const [linkedIssue, setLinkedIssue] = React.useState<{
+ number: number;
+ title: string;
+ url: string;
+ contextText: string;
+ author?: { login: string; avatarUrl?: string };
+ } | null>(null);
+ const [linkedPr, setLinkedPr] = React.useState<{
+ number: number;
+ title: string;
+ url: string;
+ head: string;
+ base: string;
+ includeDiff: boolean;
+ instructionsText: string;
+ contextText: string;
+ author?: { login: string; avatarUrl?: string };
+ } | null>(null);
+
+ // Message queue
+ const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
+ const queuedMessages = useMessageQueueStore(
+ React.useCallback(
+ (state) => {
+ if (!currentSessionId) return EMPTY_QUEUE;
+ return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
+ },
+ [currentSessionId]
+ )
+ );
+ const addToQueue = useMessageQueueStore((state) => state.addToQueue);
+ const clearQueue = useMessageQueueStore((state) => state.clearQueue);
+
+ // Inline comment drafts
+ const draftCount = useInlineCommentDraftStore(
+ React.useCallback(
+ (state) => {
+ const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
+ if (!sessionKey) return 0;
+ return (state.drafts[sessionKey] ?? []).length;
+ },
+ [currentSessionId, newSessionDraftOpen]
+ )
+ );
+ const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
+ const hasDrafts = draftCount > 0;
+
+ // User message history for up/down arrow navigation
+ // Get raw messages from store (stable reference)
+ const sessionMessages = useMessageStore(
+ React.useCallback(
+ (state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined),
+ [currentSessionId]
+ )
+ );
+ // Derive user message history with useMemo to avoid infinite re-renders
+ const userMessageHistory = React.useMemo(() => {
+ if (!sessionMessages) return [];
+ return sessionMessages
+ .filter((m) => m.info.role === 'user')
+ .map((m) => {
+ const textPart = m.parts.find((p) => p.type === 'text');
+ if (textPart && 'text' in textPart) {
+ return String(textPart.text);
+ }
+ return '';
+ })
+ .filter((text) => text.length > 0)
+ .reverse(); // Most recent first
+ }, [sessionMessages]);
+
+ // Keep messageRef in sync with message state
+ React.useEffect(() => {
+ messageRef.current = message;
+ }, [message]);
+
+ // Handle initial draft restoration and text selection
+ const hasHandledInitialDraftRef = React.useRef(false);
+ React.useEffect(() => {
+ if (hasHandledInitialDraftRef.current) return;
+ hasHandledInitialDraftRef.current = true;
+
+ const draft = initialDraftRef.current;
+ if (!draft) return;
+
+ if (!persistChatDraft) {
+ // Setting disabled - clear the restored draft
+ setMessage('');
+ try {
+ localStorage.removeItem(getDraftKey(initialSessionIdRef.current));
+ } catch {
+ // Ignore
+ }
+ } else {
+ // Setting enabled - select all text
+ requestAnimationFrame(() => {
+ textareaRef.current?.select();
+ });
+ }
+ }, [persistChatDraft]);
+
+ // Handle session switching: save draft for old session, restore draft for new session
+ const prevSessionIdRef = React.useRef(currentSessionId);
+ React.useEffect(() => {
+ if (prevSessionIdRef.current !== currentSessionId) {
+ const oldSessionId = prevSessionIdRef.current;
+ prevSessionIdRef.current = currentSessionId;
+ setInputMode('normal');
+
+ if (persistChatDraft) {
+ // Save current draft for the session we're leaving
+ saveStoredDraft(oldSessionId, messageRef.current);
+ // Restore draft for the session we're entering
+ const newDraft = getStoredDraft(currentSessionId);
+ setMessage(newDraft);
+ if (newDraft) {
+ requestAnimationFrame(() => {
+ textareaRef.current?.select();
+ });
+ }
+ } else {
+ // Persist disabled: clear input without saving
+ setMessage('');
+ }
+ }
+ }, [currentSessionId, persistChatDraft]);
+
+ // Focus textarea when new session draft is opened
+ const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
+ React.useEffect(() => {
+ if (!prevNewSessionDraftOpenRef.current && newSessionDraftOpen) {
+ // New session draft just opened - focus the textarea
+ requestAnimationFrame(() => {
+ if (isMobile) {
+ // On mobile, use preventScroll to avoid viewport jumping
+ textareaRef.current?.focus({ preventScroll: true });
+ } else {
+ textareaRef.current?.focus();
+ }
+ });
+ }
+ prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
+ }, [newSessionDraftOpen, isMobile]);
+
+ // Persist chat input draft to localStorage per session (only if setting enabled)
+ React.useEffect(() => {
+ if (!persistChatDraft) {
+ // Clear stored draft for current session when setting is disabled
+ try {
+ localStorage.removeItem(getDraftKey(currentSessionId));
+ } catch {
+ // Ignore
+ }
+ return;
+ }
+ saveStoredDraft(currentSessionId, message);
+ }, [message, persistChatDraft, currentSessionId]);
+
+ // Session activity for queue availability and controls
+ const { phase: sessionPhase } = useCurrentSessionActivity();
+
+ const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent) => {
+ if (!isMobile) {
+ return;
+ }
+
+ if (event.pointerType !== 'touch') {
+ return;
+ }
+
+ const textarea = textareaRef.current;
+ if (!textarea) {
+ return;
+ }
+
+ if (document.activeElement === textarea) {
+ return;
+ }
+
+ // Prevent iOS from scrolling the page to reveal the input.
+ event.preventDefault();
+ event.stopPropagation();
+
+ const scroller = document.scrollingElement;
+ if (scroller && scroller.scrollTop !== 0) {
+ scroller.scrollTop = 0;
+ }
+ if (window.scrollY !== 0) {
+ window.scrollTo(0, 0);
+ }
+
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
+
+ const len = textarea.value.length;
+ try {
+ textarea.setSelectionRange(len, len);
+ } catch {
+ // ignored
+ }
+ }, [isMobile]);
+
+ const handleOpenMobileControls = React.useCallback(() => {
+ if (!isMobile) {
+ return;
+ }
+
+ if (mobileControlsOpen) {
+ setMobileControlsOpen(false);
+ return;
+ }
+
+ setMobileControlsPanel(null);
+
+ if (isKeyboardOpen) {
+ textareaRef.current?.blur();
+ requestAnimationFrame(() => {
+ setMobileControlsOpen(true);
+ });
+ return;
+ }
+
+ setMobileControlsOpen(true);
+ }, [isMobile, isKeyboardOpen, mobileControlsOpen]);
+
+ const handleCloseMobileControls = React.useCallback(() => {
+ setMobileControlsOpen(false);
+ }, []);
+
+ const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => {
+ if (!isMobile) {
+ return;
+ }
+ setMobileControlsOpen(false);
+ textareaRef.current?.blur();
+ requestAnimationFrame(() => {
+ setMobileControlsPanel(panel);
+ });
+ }, [isMobile]);
+
+ const handleReturnToUnifiedControls = React.useCallback(() => {
+ if (!isMobile) {
+ return;
+ }
+ setMobileControlsPanel(null);
+ requestAnimationFrame(() => {
+ setMobileControlsOpen(true);
+ });
+ }, [isMobile]);
+
+ // Consume pending input text (e.g., from revert action)
+ React.useEffect(() => {
+ if (pendingInputText !== null) {
+ const pending = consumePendingInputText();
+ if (pending?.text) {
+ if (pending.mode === 'append') {
+ setMessage((prev) => {
+ const next = pending.text.trim();
+ if (!next) return prev;
+ const base = prev.trimEnd();
+ if (!base.trim()) return next;
+ return `${base} ${next}`;
+ });
+ } else {
+ setMessage(pending.text);
+ }
+ // Focus textarea after setting message
+ setTimeout(() => {
+ textareaRef.current?.focus();
+ }, 0);
+ }
+ }
+ }, [pendingInputText, consumePendingInputText]);
+
+ const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts;
+ const hasQueuedMessages = queuedMessages.length > 0;
+ const canSend = hasContent || hasQueuedMessages;
+
+ const canAbort = working.isWorking;
+
+ // Keep a ref to handleSubmit so callbacks don't depend on it.
+ type SubmitOptions = {
+ queuedOnly?: boolean;
+ };
+ const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {});
+
+ // Add message to queue instead of sending
+ const handleQueueMessage = React.useCallback(() => {
+ if (!hasContent || !currentSessionId) return;
+
+ const drafts = consumeDrafts(currentSessionId);
+
+ let messageToQueue = message.replace(/^\n+|\n+$/g, '');
+ if (drafts.length > 0) {
+ messageToQueue = appendInlineComments(messageToQueue, drafts);
+ }
+ const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
+
+ addToQueue(currentSessionId, {
+ content: messageToQueue,
+ attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
+ });
+
+ // Clear input and attachments
+ setMessage('');
+ if (attachmentsToQueue.length > 0) {
+ clearAttachedFiles();
+ }
+
+ if (!isMobile) {
+ textareaRef.current?.focus();
+ }
+ }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
+
+ const handleSubmit = async (options?: SubmitOptions) => {
+ const queuedOnly = options?.queuedOnly ?? false;
+
+ if (queuedOnly) {
+ if (!hasQueuedMessages || !currentSessionId) return;
+ } else if (!canSend || (!currentSessionId && !newSessionDraftOpen)) {
+ return;
+ }
+
+ // Re-pin and scroll to bottom when sending
+ scrollToBottom?.({ instant: true, force: true });
+
+ if (!currentProviderId || !currentModelId) {
+ console.warn('Cannot send message: provider or model not selected');
+ return;
+ }
+
+ // Build the primary message (first part) and additional parts
+ let primaryText = '';
+ let primaryAttachments: AttachedFile[] = [];
+ let agentMentionName: string | undefined;
+ const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = [];
+
+ // Consume any pending synthetic parts (from conflict resolution, etc.)
+ const syntheticParts = consumePendingSyntheticParts();
+
+ // Process queued messages first
+ for (let i = 0; i < queuedMessages.length; i++) {
+ const queuedMsg = queuedMessages[i];
+ const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
+ const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
+
+ // Use agent mention from first message that has one
+ if (!agentMentionName && mention?.name) {
+ agentMentionName = mention.name;
+ }
+
+ if (i === 0) {
+ // First queued message becomes primary
+ primaryText = queuedText;
+ primaryAttachments = [
+ ...sanitizeAttachmentsForSend(queuedMsg.attachments),
+ ...mentionAttachments,
+ ];
+ } else {
+ // Subsequent queued messages become additional parts
+ const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments);
+ additionalParts.push({
+ text: queuedText,
+ attachments: [...queuedAttachments, ...mentionAttachments],
+ });
+ }
+ }
+
+ // Add current input (skip for queued-only auto-send)
+ if (!queuedOnly && hasContent) {
+ const messageToSend = message.replace(/^\n+|\n+$/g, '');
+ const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
+ const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
+ const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
+
+ if (!agentMentionName && mention?.name) {
+ agentMentionName = mention.name;
+ }
+
+ if (queuedMessages.length === 0) {
+ // No queue - current input is primary
+ primaryText = messageText;
+ primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
+ } else {
+ // Has queue - current input is additional part
+ additionalParts.push({
+ text: messageText,
+ attachments: [...attachmentsToSend, ...mentionAttachments],
+ });
+ }
+ }
+
+ const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
+ let drafts: InlineCommentDraft[] = [];
+ if (!queuedOnly && sessionKey) {
+ drafts = consumeDrafts(sessionKey);
+ }
+
+ if (drafts.length > 0) {
+ if (queuedMessages.length === 0) {
+ primaryText = appendInlineComments(primaryText, drafts);
+ } else if (additionalParts.length > 0) {
+ const lastPart = additionalParts[additionalParts.length - 1];
+ lastPart.text = appendInlineComments(lastPart.text, drafts);
+ } else {
+ primaryText = appendInlineComments(primaryText, drafts);
+ }
+ }
+
+ // Add synthetic parts (from conflict resolution, etc.)
+ if (syntheticParts && syntheticParts.length > 0) {
+ for (const part of syntheticParts) {
+ additionalParts.push({
+ text: part.text,
+ synthetic: true,
+ });
+ }
+ }
+
+ // Add linked issue as synthetic part (only the parts with synthetic: true)
+ // The text part (synthetic: false) is completely dropped per requirements
+ if (linkedIssue) {
+ additionalParts.push({
+ text: linkedIssue.contextText,
+ synthetic: true,
+ });
+ }
+
+ if (linkedPr) {
+ additionalParts.push({
+ text: linkedPr.instructionsText,
+ synthetic: true,
+ });
+ additionalParts.push({
+ text: linkedPr.contextText,
+ synthetic: true,
+ });
+ }
+
+ if (!primaryText && additionalParts.length === 0) return;
+
+ // Clear queue and input
+ if (currentSessionId && hasQueuedMessages) {
+ clearQueue(currentSessionId);
+ }
+ if (!queuedOnly) {
+ setMessage('');
+ // Clear per-session draft on submit
+ saveStoredDraft(currentSessionId, '');
+ // Reset message history navigation state
+ setHistoryIndex(-1);
+ setDraftMessage('');
+ if (attachedFiles.length > 0) {
+ clearAttachedFiles();
+ }
+ // Close expanded input overlay when submitting
+ setExpandedInput(false);
+ }
+
+ if (isMobile) {
+ textareaRef.current?.blur();
+ }
+
+ // Handle local slash commands only in normal mode
+ const normalizedCommand = primaryText.trimStart();
+ if (inputMode === 'normal' && normalizedCommand.startsWith('/')) {
+ const commandName = normalizedCommand
+ .slice(1)
+ .trim()
+ .split(/\s+/)[0]
+ ?.toLowerCase();
+
+ // NEW: /undo - revert to last message (populates input with reverted message text)
+ if (commandName === 'undo' && currentSessionId) {
+ await useSessionStore.getState().handleSlashUndo(currentSessionId);
+ // Don't clear message - pendingInputText will populate it with reverted message
+ scrollToBottom?.({ instant: true, force: true });
+ return; // Don't send to assistant
+ }
+ // NEW: /redo - unrevert or partial redo (populates input with message text)
+ else if (commandName === 'redo' && currentSessionId) {
+ await useSessionStore.getState().handleSlashRedo(currentSessionId);
+ // Don't clear message - pendingInputText will populate it
+ scrollToBottom?.({ instant: true, force: true });
+ return; // Don't send to assistant
+ }
+ // NEW: /timeline - open timeline dialog
+ else if (commandName === 'timeline' && currentSessionId) {
+ setTimelineDialogOpen(true);
+ setMessage('');
+ return; // Don't send to assistant
+ }
+ }
+
+ // Collect all attachments for error recovery
+ const allAttachments = [
+ ...primaryAttachments,
+ ...additionalParts.flatMap(p => p.attachments ?? []),
+ ];
+
+ void sendMessage(
+ primaryText,
+ currentProviderId,
+ currentModelId,
+ currentAgentName,
+ primaryAttachments,
+ agentMentionName,
+ additionalParts.length > 0 ? additionalParts : undefined,
+ currentVariant,
+ inputMode
+ ).then(() => {
+ // Clear linked issue after successful message send
+ if (linkedIssue) {
+ setLinkedIssue(null);
+ }
+ if (linkedPr) {
+ setLinkedPr(null);
+ }
+ }).catch((error: unknown) => {
+ const rawMessage =
+ error instanceof Error
+ ? error.message
+ : typeof error === 'string'
+ ? error
+ : String(error ?? '');
+ const normalized = rawMessage.toLowerCase();
+
+ console.error('Message send failed:', rawMessage || error);
+
+ const isSoftNetworkError =
+ normalized.includes('timeout') ||
+ normalized.includes('timed out') ||
+ normalized.includes('may still be processing') ||
+ normalized.includes('being processed') ||
+ normalized.includes('failed to fetch') ||
+ normalized.includes('networkerror') ||
+ normalized.includes('network error') ||
+ normalized.includes('gateway timeout') ||
+ normalized === 'failed to send message';
+
+ if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
+ toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
+ if (allAttachments.length > 0) {
+ useFileStore.setState({ attachedFiles: allAttachments });
+ }
+ return;
+ }
+
+ if (isSoftNetworkError) {
+ if (allAttachments.length > 0) {
+ useFileStore.setState({ attachedFiles: allAttachments });
+ toast.error('Failed to send attachments. Try fewer files or smaller images.');
+ }
+ return;
+ }
+
+ if (allAttachments.length > 0) {
+ useFileStore.setState({ attachedFiles: allAttachments });
+ }
+ toast.error(rawMessage || 'Message failed to send. Attachments restored.');
+ });
+
+ if (!isMobile) {
+ textareaRef.current?.focus();
+ }
+ };
+
+ // Update ref with latest handleSubmit on every render
+ handleSubmitRef.current = handleSubmit;
+
+ // Primary action for send button - respects queue mode setting
+ const handlePrimaryAction = React.useCallback(() => {
+ const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
+ if (queueModeEnabled && canQueue) {
+ handleQueueMessage();
+ } else {
+ void handleSubmitRef.current();
+ }
+ }, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ // Early return during IME composition to prevent interference with autocomplete.
+ // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
+ if (isIMECompositionEvent(e)) return;
+
+ if (inputMode === 'shell' && e.key === 'Escape') {
+ e.preventDefault();
+ setInputMode('normal');
+ return;
+ }
+
+ if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) {
+ e.preventDefault();
+ setInputMode('normal');
+ return;
+ }
+
+ if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ const textarea = textareaRef.current;
+ const selectionStart = textarea?.selectionStart ?? message.length;
+ const selectionEnd = textarea?.selectionEnd ?? message.length;
+ const hasCollapsedSelection = selectionStart === selectionEnd;
+
+ if (hasCollapsedSelection) {
+ const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
+ if (probeIndex >= 0 && probeIndex < message.length) {
+ let tokenStart = probeIndex;
+ while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) {
+ tokenStart -= 1;
+ }
+
+ let tokenEnd = probeIndex + 1;
+ while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) {
+ tokenEnd += 1;
+ }
+
+ const token = message.slice(tokenStart, tokenEnd);
+ const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
+ && (token.includes('/') || token.includes('\\') || token.includes('.'));
+
+ if (looksLikeFileMention) {
+ const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd;
+ const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`;
+ e.preventDefault();
+ setMessage(nextMessage);
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = tokenStart;
+ textareaRef.current.selectionEnd = tokenStart;
+ }
+ adjustTextareaHeight();
+ });
+ updateAutocompleteState(nextMessage, tokenStart);
+ return;
+ }
+ }
+ }
+ }
+
+ if (showCommandAutocomplete && commandRef.current) {
+ if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
+ e.preventDefault();
+ commandRef.current.handleKeyDown(e.key);
+ return;
+ }
+ }
+
+ if (showSkillAutocomplete && skillRef.current) {
+ if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
+ e.preventDefault();
+ skillRef.current.handleKeyDown(e.key);
+ return;
+ }
+ }
+
+ if (showFileMention && mentionRef.current) {
+ if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
+ e.preventDefault();
+ mentionRef.current.handleKeyDown(e.key);
+ return;
+ }
+ }
+
+ if (isDesktopExpanded && e.key === 'Escape') {
+ e.preventDefault();
+ setExpandedInput(false);
+ return;
+ }
+
+ if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) {
+ e.preventDefault();
+ handleCycleAgent();
+ return;
+ }
+
+ // Handle ArrowUp/ArrowDown for message history navigation
+ // ArrowUp: only when cursor at start (position 0) or input is empty
+ // ArrowDown: also works when cursor at end (to cycle forward through history)
+ const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention;
+ const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
+ const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
+ const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
+ const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd);
+
+ if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) {
+ e.preventDefault();
+ if (historyIndex === -1) {
+ // Entering history mode - save current input as draft
+ setDraftMessage(message);
+ setHistoryIndex(0);
+ setMessage(userMessageHistory[0]);
+ } else if (historyIndex < userMessageHistory.length - 1) {
+ // Navigate to older message
+ const newIndex = historyIndex + 1;
+ setHistoryIndex(newIndex);
+ setMessage(userMessageHistory[newIndex]);
+ }
+ // Move cursor to start after history navigation
+ requestAnimationFrame(() => {
+ textareaRef.current?.setSelectionRange(0, 0);
+ });
+ // If at oldest message, do nothing
+ return;
+ }
+
+ if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) {
+ e.preventDefault();
+ if (historyIndex === 0) {
+ // Exit history mode - restore draft
+ setHistoryIndex(-1);
+ setMessage(draftMessage);
+ setDraftMessage('');
+ } else {
+ // Navigate to newer message
+ const newIndex = historyIndex - 1;
+ setHistoryIndex(newIndex);
+ setMessage(userMessageHistory[newIndex]);
+ }
+ return;
+ }
+
+ // Handle Enter/Ctrl+Enter based on queue mode
+ if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+
+ const isCtrlEnter = e.ctrlKey || e.metaKey;
+
+ // Queue mode: Enter queues, Ctrl+Enter sends
+ // Normal mode: Enter sends, Ctrl+Enter queues
+ // Note: Queueing only works when there's an existing session (currentSessionId)
+ // For new sessions (draft), always send immediately
+ const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
+
+ if (queueModeEnabled) {
+ if (isCtrlEnter || !canQueue) {
+ // Ctrl+Enter sends, or Enter when can't queue (new session)
+ handleSubmit();
+ } else {
+ // Enter queues when we have a session
+ handleQueueMessage();
+ }
+ } else {
+ if (isCtrlEnter && canQueue) {
+ // Ctrl+Enter queues when we have a session
+ handleQueueMessage();
+ } else {
+ // Enter sends
+ handleSubmit();
+ }
+ }
+ }
+ };
+
+ const measureCaretInTextarea = React.useCallback((textarea: HTMLTextAreaElement, cursorPosition: number) => {
+ const doc = textarea.ownerDocument;
+ const win = doc.defaultView;
+ if (!win) return null;
+
+ const style = win.getComputedStyle(textarea);
+ const mirror = doc.createElement('div');
+ const mirrorStyle = mirror.style;
+
+ mirrorStyle.position = 'absolute';
+ mirrorStyle.visibility = 'hidden';
+ mirrorStyle.pointerEvents = 'none';
+ mirrorStyle.whiteSpace = 'pre-wrap';
+ mirrorStyle.wordWrap = 'break-word';
+ mirrorStyle.overflow = 'hidden';
+ mirrorStyle.left = '-9999px';
+ mirrorStyle.top = '0';
+
+ mirrorStyle.width = `${textarea.clientWidth}px`;
+ mirrorStyle.font = style.font;
+ mirrorStyle.fontSize = style.fontSize;
+ mirrorStyle.fontFamily = style.fontFamily;
+ mirrorStyle.fontWeight = style.fontWeight;
+ mirrorStyle.fontStyle = style.fontStyle;
+ mirrorStyle.fontVariant = style.fontVariant;
+ mirrorStyle.letterSpacing = style.letterSpacing;
+ mirrorStyle.textTransform = style.textTransform;
+ mirrorStyle.textIndent = style.textIndent;
+ mirrorStyle.padding = style.padding;
+ mirrorStyle.border = style.border;
+ mirrorStyle.boxSizing = style.boxSizing;
+ mirrorStyle.lineHeight = style.lineHeight;
+ mirrorStyle.tabSize = style.tabSize;
+
+ mirror.textContent = textarea.value.slice(0, cursorPosition);
+ const marker = doc.createElement('span');
+ marker.textContent = textarea.value.slice(cursorPosition, cursorPosition + 1) || ' ';
+ mirror.appendChild(marker);
+
+ doc.body.appendChild(mirror);
+ const top = marker.offsetTop;
+ const left = marker.offsetLeft;
+ doc.body.removeChild(mirror);
+
+ return { top, left };
+ }, []);
+
+ const updateAutocompleteOverlayPosition = React.useCallback(() => {
+ if (!isDesktopExpanded) {
+ setAutocompleteOverlayPosition(null);
+ return;
+ }
+
+ if (!showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) {
+ setAutocompleteOverlayPosition(null);
+ return;
+ }
+
+ const textarea = textareaRef.current;
+ const container = dropZoneRef.current;
+ if (!textarea || !container) return;
+
+ const cursor = textarea.selectionStart ?? message.length;
+ const caret = measureCaretInTextarea(textarea, cursor);
+ if (!caret) return;
+
+ const textareaRect = textarea.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+
+ const caretY = textareaRect.top - containerRect.top + (caret.top - textarea.scrollTop);
+ const caretX = textareaRect.left - containerRect.left + (caret.left - textarea.scrollLeft);
+
+ const popupMargin = 8;
+ const estimatedPopupHeight = 260;
+ const spaceAbove = caretY - popupMargin;
+ const spaceBelow = containerRect.height - caretY - popupMargin;
+ const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above';
+
+ const desiredWidth = showFileMention ? 520 : showCommandAutocomplete ? 450 : 360;
+ const clampedLeft = Math.max(
+ popupMargin,
+ Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin)
+ );
+
+ const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove));
+
+ setAutocompleteOverlayPosition({
+ top: place === 'below' ? caretY + 22 : caretY - 6,
+ left: clampedLeft,
+ place,
+ maxHeight,
+ });
+ }, [
+ isDesktopExpanded,
+ measureCaretInTextarea,
+ message.length,
+ showCommandAutocomplete,
+ showFileMention,
+ showSkillAutocomplete,
+ ]);
+
+ React.useLayoutEffect(() => {
+ updateAutocompleteOverlayPosition();
+ }, [
+ updateAutocompleteOverlayPosition,
+ message,
+ showCommandAutocomplete,
+ showSkillAutocomplete,
+ showFileMention,
+ isDesktopExpanded,
+ ]);
+
+ React.useEffect(() => {
+ if (!isDesktopExpanded) return;
+ const onResize = () => updateAutocompleteOverlayPosition();
+ window.addEventListener('resize', onResize);
+ return () => {
+ window.removeEventListener('resize', onResize);
+ };
+ }, [isDesktopExpanded, updateAutocompleteOverlayPosition]);
+
+ const startAbortIndicator = React.useCallback(() => {
+ if (abortTimeoutRef.current) {
+ clearTimeout(abortTimeoutRef.current);
+ abortTimeoutRef.current = null;
+ }
+
+ setShowAbortStatus(true);
+
+ abortTimeoutRef.current = setTimeout(() => {
+ setShowAbortStatus(false);
+ abortTimeoutRef.current = null;
+ }, 1800);
+ }, []);
+
+ const handleAbort = React.useCallback(() => {
+ clearAbortPrompt();
+ startAbortIndicator();
+
+ void abortCurrentOperation();
+ }, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]);
+
+ const handleCycleAgent = React.useCallback(() => {
+ if (primaryAgents.length <= 1) return;
+
+ const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName);
+ const nextIndex = (currentIndex + 1) % primaryAgents.length;
+ const nextAgent = primaryAgents[nextIndex];
+
+ setAgent(nextAgent.name);
+
+ if (currentSessionId) {
+ saveSessionAgentSelection(currentSessionId, nextAgent.name);
+ }
+ }, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]);
+
+ const adjustTextareaHeight = React.useCallback(() => {
+ const textarea = textareaRef.current;
+ if (!textarea) {
+ return;
+ }
+
+ if (isDesktopExpanded) {
+ textarea.style.height = '100%';
+ textarea.style.maxHeight = 'none';
+ setTextareaSize(null);
+ return;
+ }
+
+ textarea.style.height = 'auto';
+
+ const view = textarea.ownerDocument?.defaultView;
+ const computedStyle = view ? view.getComputedStyle(textarea) : null;
+ const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN;
+ const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN;
+ const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN;
+ const fallbackLineHeight = 22;
+ const fallbackPadding = 16;
+ const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom)
+ ? fallbackPadding
+ : paddingTop + paddingBottom;
+ const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
+ const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
+ const scrollHeight = textarea.scrollHeight || textarea.offsetHeight;
+ const nextHeight = Math.min(scrollHeight, maxHeight);
+
+ textarea.style.height = `${nextHeight}px`;
+ textarea.style.maxHeight = `${maxHeight}px`;
+
+ setTextareaSize((prev) => {
+ if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
+ return prev;
+ }
+ return { height: nextHeight, maxHeight };
+ });
+ }, [isDesktopExpanded]);
+
+ React.useLayoutEffect(() => {
+ adjustTextareaHeight();
+ }, [adjustTextareaHeight, message, isMobile]);
+
+ const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
+ if (inputMode === 'shell') {
+ setShowCommandAutocomplete(false);
+ setShowFileMention(false);
+ setShowSkillAutocomplete(false);
+ return;
+ }
+
+ if (value.startsWith('/')) {
+ const firstSpace = value.indexOf(' ');
+ const firstNewline = value.indexOf('\n');
+ const commandEnd = Math.min(
+ firstSpace === -1 ? value.length : firstSpace,
+ firstNewline === -1 ? value.length : firstNewline
+ );
+
+ if (cursorPosition <= commandEnd && firstSpace === -1) {
+ const commandText = value.substring(1, commandEnd);
+ setCommandQuery(commandText);
+ setAutocompleteTab('commands');
+ setShowCommandAutocomplete(true);
+ setShowFileMention(false);
+ setShowSkillAutocomplete(false);
+ return;
+ }
+ }
+
+ setShowCommandAutocomplete(false);
+
+ const textBeforeCursor = value.substring(0, cursorPosition);
+
+ const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
+ if (lastSlashSymbol !== -1) {
+ const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
+ const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1);
+ const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n');
+ const isWordBoundary = !charBefore || /\s/.test(charBefore);
+
+ if (isWordBoundary && !hasSeparator) {
+ setSkillQuery(textAfterSlash);
+ setShowSkillAutocomplete(true);
+ setShowFileMention(false);
+ return;
+ }
+ }
+
+ setShowSkillAutocomplete(false);
+ setSkillQuery('');
+
+ const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
+ if (lastAtSymbol !== -1) {
+ const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
+ const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
+ const isWordBoundary = !charBefore || /\s/.test(charBefore);
+ if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
+ setMentionQuery(textAfterAt);
+ setAutocompleteTab('agents');
+ setShowFileMention(true);
+ } else {
+ setShowFileMention(false);
+ }
+ } else {
+ setShowFileMention(false);
+ }
+ }, [inputMode, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
+
+ const applyAutocompletePrefix = React.useCallback((prefix: '/' | '@') => {
+ const nextMessage = message.length === 0
+ ? prefix
+ : (message[0] === '/' || message[0] === '@')
+ ? `${prefix}${message.slice(1)}`
+ : `${prefix}${message}`;
+ setMessage(nextMessage);
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ const nextCursor = Math.min(nextMessage.length, textareaRef.current.value.length);
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ adjustTextareaHeight();
+ updateAutocompleteState(nextMessage, nextMessage.length);
+ });
+ }, [adjustTextareaHeight, message, setMessage, updateAutocompleteState]);
+
+ const handleAutocompleteTabSelect = React.useCallback((tab: 'commands' | 'agents' | 'files') => {
+ const textarea = textareaRef.current;
+ if (isMobile && textarea) {
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
+ const len = textarea.value.length;
+ try {
+ textarea.setSelectionRange(len, len);
+ } catch {
+ // ignored
+ }
+ }
+ setAutocompleteTab(tab);
+ setCommandQuery('');
+ setMentionQuery('');
+ if (tab === 'commands') {
+ applyAutocompletePrefix('/');
+ }
+ if (tab === 'agents') {
+ applyAutocompletePrefix('@');
+ }
+ if (tab === 'files') {
+ applyAutocompletePrefix('@');
+ }
+ setShowSkillAutocomplete(false);
+ setShowCommandAutocomplete(tab === 'commands');
+ setShowFileMention(tab === 'agents' || tab === 'files');
+ }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
+
+ const handleOpenCommandMenu = React.useCallback(() => {
+ if (!isMobile) {
+ return;
+ }
+ const textarea = textareaRef.current;
+ if (textarea) {
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
+ const len = textarea.value.length;
+ try {
+ textarea.setSelectionRange(len, len);
+ } catch {
+ // ignored
+ }
+ }
+ applyAutocompletePrefix('/');
+ setCommandQuery('');
+ setAutocompleteTab('commands');
+ setShowCommandAutocomplete(true);
+ setShowFileMention(false);
+ setShowSkillAutocomplete(false);
+ }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
+
+ const insertTextAtSelection = React.useCallback((text: string) => {
+ if (!text) {
+ return;
+ }
+
+ const textarea = textareaRef.current;
+ if (!textarea) {
+ const nextValue = message + text;
+ setMessage(nextValue);
+ updateAutocompleteState(nextValue, nextValue.length);
+ requestAnimationFrame(() => adjustTextareaHeight());
+ return;
+ }
+
+ const start = textarea.selectionStart ?? message.length;
+ const end = textarea.selectionEnd ?? message.length;
+ const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
+ setMessage(nextValue);
+ const cursorPosition = start + text.length;
+
+ requestAnimationFrame(() => {
+ const currentTextarea = textareaRef.current;
+ if (currentTextarea) {
+ currentTextarea.selectionStart = cursorPosition;
+ currentTextarea.selectionEnd = cursorPosition;
+ }
+ adjustTextareaHeight();
+ });
+
+ updateAutocompleteState(nextValue, cursorPosition);
+ }, [adjustTextareaHeight, message, updateAutocompleteState]);
+
+ const handleTextChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ const cursorPosition = e.target.selectionStart ?? value.length;
+
+ if (inputMode === 'normal' && value.startsWith('!')) {
+ const shellCommand = value.slice(1);
+ const nextCursor = Math.max(0, cursorPosition - 1);
+ setInputMode('shell');
+ setMessage(shellCommand);
+ adjustTextareaHeight();
+ setShowCommandAutocomplete(false);
+ setShowSkillAutocomplete(false);
+ setShowFileMention(false);
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ });
+ return;
+ }
+
+ setMessage(value);
+ adjustTextareaHeight();
+ updateAutocompleteState(value, cursorPosition);
+ };
+
+ const handlePaste = React.useCallback(async (e: React.ClipboardEvent) => {
+ const fileMap = new Map();
+
+ Array.from(e.clipboardData.files || []).forEach(file => {
+ if (file.type.startsWith('image/')) {
+ fileMap.set(`${file.name}-${file.size}`, file);
+ }
+ });
+
+ Array.from(e.clipboardData.items || []).forEach(item => {
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (file) {
+ fileMap.set(`${file.name}-${file.size}`, file);
+ }
+ }
+ });
+
+ const imageFiles = Array.from(fileMap.values());
+ if (imageFiles.length === 0) {
+ return;
+ }
+
+ if (!currentSessionId && !newSessionDraftOpen) {
+ return;
+ }
+
+ e.preventDefault();
+
+ const pastedText = e.clipboardData.getData('text');
+ if (pastedText) {
+ insertTextAtSelection(pastedText);
+ }
+
+ let attachedCount = 0;
+
+ for (const file of imageFiles) {
+ const sizeBefore = useSessionStore.getState().attachedFiles.length;
+ try {
+ await addAttachedFile(file);
+ const sizeAfter = useSessionStore.getState().attachedFiles.length;
+ if (sizeAfter > sizeBefore) {
+ attachedCount += 1;
+ }
+ } catch (error) {
+ console.error('Clipboard image attach failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
+ }
+ }
+
+ if (attachedCount > 0) {
+ toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`);
+ }
+ }, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
+
+ const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
+
+ const cursorPosition = textareaRef.current?.selectionStart || 0;
+ const textBeforeCursor = message.substring(0, cursorPosition);
+ const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
+
+ const mentionPath = (file.relativePath && file.relativePath.trim().length > 0)
+ ? file.relativePath.trim()
+ : (toProjectRelativeMentionPath(file.path) || file.name);
+
+ if (lastAtSymbol !== -1) {
+ const newMessage =
+ message.substring(0, lastAtSymbol) +
+ `@${mentionPath} ` +
+ message.substring(cursorPosition);
+ setMessage(newMessage);
+ const nextCursor = lastAtSymbol + mentionPath.length + 2;
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ adjustTextareaHeight();
+ updateAutocompleteState(newMessage, nextCursor);
+ });
+ } else if (textareaRef.current) {
+ const newMessage =
+ message.substring(0, cursorPosition) +
+ `@${mentionPath} ` +
+ message.substring(cursorPosition);
+ setMessage(newMessage);
+ const nextCursor = cursorPosition + mentionPath.length + 2;
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ adjustTextareaHeight();
+ updateAutocompleteState(newMessage, nextCursor);
+ });
+ }
+
+ setShowFileMention(false);
+ setMentionQuery('');
+
+ textareaRef.current?.focus();
+ };
+
+ const handleAgentSelect = (agentName: string) => {
+ const textarea = textareaRef.current;
+ const cursorPosition = textarea?.selectionStart ?? message.length;
+ const textBeforeCursor = message.substring(0, cursorPosition);
+ const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
+
+ if (lastAtSymbol !== -1) {
+ const newMessage =
+ message.substring(0, lastAtSymbol) +
+ `@${agentName} ` +
+ message.substring(cursorPosition);
+ setMessage(newMessage);
+
+ const nextCursor = lastAtSymbol + agentName.length + 2;
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ adjustTextareaHeight();
+ updateAutocompleteState(newMessage, nextCursor);
+ });
+ } else if (textareaRef.current) {
+ const newMessage =
+ message.substring(0, cursorPosition) +
+ `@${agentName} ` +
+ message.substring(cursorPosition);
+ setMessage(newMessage);
+
+ const nextCursor = cursorPosition + agentName.length + 2;
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ adjustTextareaHeight();
+ updateAutocompleteState(newMessage, nextCursor);
+ });
+ }
+
+ setShowFileMention(false);
+ setMentionQuery('');
+
+ textareaRef.current?.focus();
+ };
+
+ const handleSkillSelect = (skillName: string) => {
+ const textarea = textareaRef.current;
+ const cursorPosition = textarea?.selectionStart ?? message.length;
+ const textBeforeCursor = message.substring(0, cursorPosition);
+ const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
+
+ if (lastSlashSymbol !== -1) {
+ const newMessage =
+ message.substring(0, lastSlashSymbol) +
+ `/${skillName} ` +
+ message.substring(cursorPosition);
+ setMessage(newMessage);
+
+ const nextCursor = lastSlashSymbol + skillName.length + 2;
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ textareaRef.current.selectionStart = nextCursor;
+ textareaRef.current.selectionEnd = nextCursor;
+ }
+ adjustTextareaHeight();
+ updateAutocompleteState(newMessage, nextCursor);
+ });
+ }
+
+ setShowSkillAutocomplete(false);
+ setSkillQuery('');
+
+ textareaRef.current?.focus();
+ };
+
+ const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => {
+
+ setMessage(`/${command.name} `);
+
+ const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command };
+ if (textareaElement) {
+ textareaElement._commandMetadata = command;
+ }
+
+ setShowCommandAutocomplete(false);
+ setCommandQuery('');
+
+ const refocus = () => {
+ if (textareaRef.current) {
+ try {
+ textareaRef.current.focus({ preventScroll: true });
+ } catch {
+ textareaRef.current.focus();
+ }
+ textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
+ }
+ };
+
+ requestAnimationFrame(() => {
+ refocus();
+ requestAnimationFrame(refocus);
+ });
+ setTimeout(refocus, 60);
+ };
+
+ React.useEffect(() => {
+
+ if (currentSessionId && textareaRef.current && !isMobile) {
+ textareaRef.current.focus();
+ }
+ }, [currentSessionId, isMobile]);
+
+ React.useEffect(() => {
+ if (!isMobile) {
+ setMobileControlsOpen(false);
+ setMobileControlsPanel(null);
+ }
+ }, [isMobile]);
+
+ React.useEffect(() => {
+ if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) {
+ clearAbortPrompt();
+ }
+ }, [abortPromptSessionId, currentSessionId, clearAbortPrompt]);
+
+ React.useEffect(() => {
+ canAcceptDropRef.current = Boolean(currentSessionId || newSessionDraftOpen);
+ }, [currentSessionId, newSessionDraftOpen]);
+
+ const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => {
+ if (!dataTransfer) return false;
+ if (dataTransfer.files && dataTransfer.files.length > 0) return true;
+ if (dataTransfer.types) {
+ const types = Array.from(dataTransfer.types);
+ if (types.includes('Files')) return true;
+ if (types.includes('text/uri-list')) return true;
+ }
+
+ const uriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain');
+ return typeof uriList === 'string' && uriList.toLowerCase().includes('file://');
+ }, []);
+
+ const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => {
+ if (!dataTransfer) return [];
+
+ const directFiles = Array.from(dataTransfer.files || []);
+ if (directFiles.length > 0) {
+ return directFiles;
+ }
+
+ const fromItems = Array.from(dataTransfer.items || [])
+ .filter((item) => item.kind === 'file')
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => Boolean(file));
+
+ return fromItems;
+ }, []);
+
+ const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => {
+ if (!dataTransfer || typeof dataTransfer.getData !== 'function') return [];
+
+ const rawUriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain');
+ if (!rawUriList) return [];
+
+ const candidates = rawUriList
+ .split(/\r?\n/)
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0 && !value.startsWith('#'))
+ .filter((value) => value.toLowerCase().startsWith('file://'));
+
+ return Array.from(new Set(candidates));
+ }, []);
+
+ const attachVSCodeDroppedUris = React.useCallback(async (uris: string[]) => {
+ if (uris.length === 0) return;
+
+ try {
+ const response = await fetch('/api/vscode/drop-files', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ uris }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to attach dropped files (${response.status})`);
+ }
+
+ const data = await response.json();
+ const picked = Array.isArray(data?.files) ? data.files : [];
+ const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
+
+ if (skipped.length > 0) {
+ const summary = skipped
+ .map((entry: { name?: string; reason?: string }) => `${entry?.name || 'file'}: ${entry?.reason || 'skipped'}`)
+ .join('\n');
+ toast.error(`Some dropped files were skipped:\n${summary}`);
+ }
+
+ let attachedCount = 0;
+ for (const file of picked as Array<{ name: string; mimeType?: string; dataUrl?: string }>) {
+ if (!file?.dataUrl) continue;
+
+ const sizeBefore = useSessionStore.getState().attachedFiles.length;
+ try {
+ const [meta, base64] = file.dataUrl.split(',');
+ const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
+ if (!base64) continue;
+
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ const blob = new Blob([bytes], { type: mime });
+ const localFile = new File([blob], file.name || 'file', { type: mime });
+ await addAttachedFile(localFile);
+
+ const sizeAfter = useSessionStore.getState().attachedFiles.length;
+ if (sizeAfter > sizeBefore) {
+ attachedCount += 1;
+ }
+ } catch (error) {
+ console.error('Dropped file attach failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to attach dropped file');
+ }
+ }
+
+ if (attachedCount > 0) {
+ toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
+ }
+ } catch (error) {
+ console.error('VS Code dropped file attach failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to attach dropped files');
+ }
+ }, [addAttachedFile]);
+
+ const normalizeDroppedPath = React.useCallback((rawPath: string): string => {
+ const input = rawPath.trim();
+ if (!input.toLowerCase().startsWith('file://')) {
+ return input;
+ }
+
+ try {
+ let pathname = decodeURIComponent(new URL(input).pathname || '');
+ if (/^\/[A-Za-z]:\//.test(pathname)) {
+ pathname = pathname.slice(1);
+ }
+ return pathname || input;
+ } catch {
+ const stripped = input.replace(/^file:\/\//i, '');
+ try {
+ return decodeURIComponent(stripped);
+ } catch {
+ return stripped;
+ }
+ }
+ }, []);
+
+ const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => {
+ const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim();
+ const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, '');
+ if (!normalizedRoot) {
+ return normalizedAbsolutePath;
+ }
+ if (normalizedAbsolutePath === normalizedRoot) {
+ return normalizedAbsolutePath;
+ }
+ const rootWithSlash = `${normalizedRoot}/`;
+ if (normalizedAbsolutePath.startsWith(rootWithSlash)) {
+ return normalizedAbsolutePath.slice(rootWithSlash.length);
+ }
+ return normalizedAbsolutePath;
+ }, [chatSearchDirectory]);
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e.dataTransfer)) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
+ setIsDragging(true);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e.dataTransfer)) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = 'copy';
+ if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
+ setIsDragging(true);
+ }
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.currentTarget === e.target) {
+ setIsDragging(false);
+ }
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e.dataTransfer)) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ if (!currentSessionId && !newSessionDraftOpen) return;
+
+ const files = collectDroppedFiles(e.dataTransfer);
+
+ if (files.length === 0 && isVSCodeRuntime()) {
+ const droppedUris = collectDroppedFileUris(e.dataTransfer);
+ if (droppedUris.length > 0) {
+ await attachVSCodeDroppedUris(droppedUris);
+ }
+ return;
+ }
+
+ let attachedCount = 0;
+
+ if (files.length > 0) {
+ for (const file of files) {
+ const sizeBefore = useSessionStore.getState().attachedFiles.length;
+ try {
+ await addAttachedFile(file);
+ const sizeAfter = useSessionStore.getState().attachedFiles.length;
+ if (sizeAfter > sizeBefore) {
+ attachedCount += 1;
+ }
+ } catch (error) {
+ console.error('File attach failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to attach file');
+ }
+ }
+ }
+
+ if (attachedCount > 0) {
+ toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
+ }
+ };
+
+ // Tauri desktop: handle native file drops via onDragDropEvent
+ React.useEffect(() => {
+ if (!isTauriShell()) return;
+ let cancelled = false;
+ let unlisten: (() => void) | null = null;
+
+ void (async () => {
+ try {
+ const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow');
+ const webviewWindow = getCurrentWebviewWindow();
+ const removeListener = await webviewWindow.onDragDropEvent(async (event) => {
+ if (!canAcceptDropRef.current) return;
+
+ const payload = (event as { payload?: unknown }).payload;
+ if (!payload || typeof payload !== 'object') return;
+
+ const typed = payload as { type?: string; paths?: string[]; position?: { x?: number; y?: number } };
+ const type = typed.type;
+ const x = typed.position?.x;
+ const y = typed.position?.y;
+
+ // Check if drop is inside the chat input area
+ const zone = dropZoneRef.current;
+ let inZone: boolean | null = null;
+ if (zone && typeof x === 'number' && typeof y === 'number') {
+ const rect = zone.getBoundingClientRect();
+ inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+ // Handle retina displays where Tauri might report physical pixels
+ if (!inZone && window.devicePixelRatio > 1) {
+ const sx = x / window.devicePixelRatio;
+ const sy = y / window.devicePixelRatio;
+ inZone = sx >= rect.left && sx <= rect.right && sy >= rect.top && sy <= rect.bottom;
+ }
+ }
+
+ if (type === 'enter' || type === 'over') {
+ if (inZone !== null) {
+ nativeDragInsideDropZoneRef.current = inZone;
+ }
+ setIsDragging(nativeDragInsideDropZoneRef.current);
+ return;
+ }
+ if (type === 'leave') {
+ nativeDragInsideDropZoneRef.current = false;
+ setIsDragging(false);
+ return;
+ }
+ if (type === 'drop') {
+ const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current;
+ nativeDragInsideDropZoneRef.current = false;
+ setIsDragging(false);
+ if (!shouldHandleDrop) return;
+
+ const paths = Array.isArray(typed.paths)
+ ? typed.paths.filter((p): p is string => typeof p === 'string')
+ : [];
+ if (paths.length === 0) return;
+
+ let attachedCount = 0;
+ for (const path of paths) {
+ const sizeBefore = useSessionStore.getState().attachedFiles.length;
+ try {
+ const normalizedPath = normalizeDroppedPath(path);
+ const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
+ let file: File;
+
+ // In Tauri shell, dropped paths are local machine paths.
+ // Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
+ if (isTauriShell()) {
+ const { invoke } = await import('@tauri-apps/api/core');
+ const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
+ const byteCharacters = atob(result.base64);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' });
+ file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' });
+ } else {
+ const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`);
+ if (!response.ok) {
+ throw new Error(`Failed to read dropped file (${response.status})`);
+ }
+ const blob = await response.blob();
+ file = new File([blob], fileName, { type: blob.type || 'application/octet-stream' });
+ }
+
+ await addAttachedFile(file);
+ const sizeAfter = useSessionStore.getState().attachedFiles.length;
+ if (sizeAfter > sizeBefore) attachedCount++;
+ } catch (error) {
+ console.error('Failed to attach dropped file:', path, error);
+ toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`);
+ }
+ }
+ if (attachedCount > 0) {
+ toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
+ }
+ }
+ });
+
+ if (cancelled) {
+ removeListener();
+ return;
+ }
+ unlisten = removeListener;
+ } catch (error) {
+ if (!cancelled) {
+ console.warn('Failed to register Tauri drag-drop listener:', error);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (unlisten) unlisten();
+ };
+ }, [addAttachedFile, normalizeDroppedPath]);
+
+ const fileInputRef = React.useRef(null);
+
+ const attachFiles = React.useCallback(async (files: FileList | File[]) => {
+ let attachedCount = 0;
+ const list = Array.isArray(files) ? files : Array.from(files);
+
+ for (const file of list) {
+ const sizeBefore = useSessionStore.getState().attachedFiles.length;
+ try {
+ await addAttachedFile(file);
+ const sizeAfter = useSessionStore.getState().attachedFiles.length;
+ if (sizeAfter > sizeBefore) {
+ attachedCount += 1;
+ }
+ } catch (error) {
+ console.error('File attach failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to attach file');
+ }
+ }
+
+ if (attachedCount > 0) {
+ toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
+ }
+ }, [addAttachedFile]);
+
+ const handleVSCodePickFiles = React.useCallback(async () => {
+ try {
+ const response = await fetch('/api/vscode/pick-files');
+ const data = await response.json();
+ const picked = Array.isArray(data?.files) ? data.files : [];
+ const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
+
+ if (skipped.length > 0) {
+ const summary = skipped
+ .map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
+ .join('\n');
+ toast.error(`Some files were skipped:\n${summary}`);
+ }
+
+ const asFiles = picked
+ .map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
+ if (!file?.dataUrl) return null;
+ try {
+ const [meta, base64] = file.dataUrl.split(',');
+ const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
+ if (!base64) return null;
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ const blob = new Blob([bytes], { type: mime });
+ return new File([blob], file.name || 'file', { type: mime });
+ } catch (err) {
+ console.error('Failed to decode VS Code picked file', err);
+ return null;
+ }
+ })
+ .filter(Boolean) as File[];
+
+ if (asFiles.length > 0) {
+ await attachFiles(asFiles);
+ }
+ } catch (error) {
+ console.error('VS Code file pick failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
+ }
+ }, [attachFiles]);
+
+ const handlePickLocalFiles = React.useCallback(() => {
+ if (isVSCodeRuntime()) {
+ void handleVSCodePickFiles();
+ return;
+ }
+ fileInputRef.current?.click();
+ }, [handleVSCodePickFiles]);
+
+ const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ if (!files) return;
+ await attachFiles(files);
+ event.target.value = '';
+ }, [attachFiles]);
+
+ const footerGapClass = 'gap-x-1.5 gap-y-0';
+ const isVSCode = isVSCodeRuntime();
+ const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
+ const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
+ const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
+ const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
+ const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
+
+ const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed';
+ const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
+
+ // Send button - respects queue mode setting
+ const sendButton = (
+ {
+ if (!isMobile) {
+ return;
+ }
+
+ event.preventDefault();
+ handlePrimaryAction();
+ }}
+ className={cn(
+ footerIconButtonClass,
+ canSend && (currentSessionId || newSessionDraftOpen)
+ ? 'text-primary hover:text-primary'
+ : 'opacity-30'
+ )}
+ aria-label="Send message"
+ >
+
+
+ );
+
+ // Queue button for adding message to queue while working
+ const queueButton = (
+ {
+ if (isMobile) {
+ event.preventDefault();
+ }
+ handleQueueMessage();
+ }}
+ className={cn(
+ footerIconButtonClass,
+ 'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
+ hasContent && currentSessionId
+ ? 'text-primary hover:text-primary'
+ : 'opacity-30'
+ )}
+ aria-label="Queue message"
+ >
+
+
+ );
+
+ // Stop button replaces send button when working
+ const stopButton = (
+
+
+
+ );
+
+ // Action buttons area: either send button, or stop (+ optional queue button floating above)
+ const actionButtons = canAbort ? (
+
+ {hasContent && queueButton}
+ {stopButton}
+
+ ) : (
+ sendButton
+ );
+
+ const attachmentMenu = (
+ <>
+
+
+
+ {isVSCode ? (
+ handlePickLocalFiles()}
+ title="Attach files"
+ aria-label="Attach files"
+ >
+
+
+ ) : (
+
+
+
+
+
+
+
+ {
+ requestAnimationFrame(() => handlePickLocalFiles());
+ }}
+ >
+
+ Attach files
+
+ {
+ requestAnimationFrame(() => {
+ setIssuePickerOpen(true);
+ });
+ }}
+ >
+
+ Link GitHub Issue
+
+ {
+ requestAnimationFrame(() => {
+ setPrPickerOpen(true);
+ });
+ }}
+ >
+
+ Link GitHub PR
+
+
+
+ )}
+
+ >
+ );
+
+ const settingsButton = onOpenSettings ? (
+
+
+
+ ) : null;
+
+ const attachmentsControls = (
+
+ {isMobile ? (
+ {
+ if (event.pointerType === 'touch') {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
+ onClick={handleOpenCommandMenu}
+ title="Commands"
+ aria-label="Commands"
+ >
+
+
+ ) : null}
+ {attachmentMenu}
+ {settingsButton}
+
+ );
+
+ const workingStatusText = working.statusText;
+
+ React.useEffect(() => {
+ const pendingAbortBanner = Boolean(working.wasAborted);
+ if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
+ startAbortIndicator();
+ if (currentSessionId) {
+ acknowledgeSessionAbort(currentSessionId);
+ }
+ }
+ prevWasAbortedRef.current = pendingAbortBanner;
+ }, [
+ acknowledgeSessionAbort,
+ currentSessionId,
+ showAbortStatus,
+ startAbortIndicator,
+ working.wasAborted,
+ ]);
+
+ React.useEffect(() => {
+ return () => {
+ if (abortTimeoutRef.current) {
+ clearTimeout(abortTimeoutRef.current);
+ abortTimeoutRef.current = null;
+ }
+ };
+ }, []);
+
+ return (
+ <>
+
+
+ {/* Issue Picker Dialog */}
+ {
+ setLinkedIssue(issue);
+ setLinkedPr(null);
+ }}
+ />
+ {
+ setLinkedPr(pr);
+ setLinkedIssue(null);
+ }}
+ />
+ >
+ );
+};
diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx
new file mode 100644
index 0000000..7dfe039
--- /dev/null
+++ b/ui/src/components/chat/ChatMessage.tsx
@@ -0,0 +1,1110 @@
+import React from 'react';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import { useShallow } from 'zustand/react/shallow';
+
+import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
+import { MessageFreshnessDetector } from '@/lib/messageFreshness';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useMessageStore } from '@/stores/messageStore';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useContextStore } from '@/stores/contextStore';
+import { useDeviceInfo } from '@/lib/device';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
+import { cn } from '@/lib/utils';
+
+import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
+import MessageHeader from './message/MessageHeader';
+import MessageBody from './message/MessageBody';
+import type { AgentMentionInfo } from './message/types';
+import type { StreamPhase, ToolPopupContent } from './message/types';
+import { deriveMessageRole } from './message/messageRole';
+import { filterVisibleParts } from './message/partUtils';
+import { flattenAssistantTextParts } from '@/lib/messages/messageText';
+import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
+import { FadeInOnReveal } from './message/FadeInOnReveal';
+import type { TurnGroupingContext } from './hooks/useTurnGrouping';
+import { copyTextToClipboard } from '@/lib/clipboard';
+
+const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
+
+const TOOL_DEFAULT_EXPANSION_BY_MODE = {
+ detailed: new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']),
+ changes: new Set(['edit', 'multiedit', 'write', 'apply_patch']),
+} as const;
+
+type DefaultExpandedToolMode = keyof typeof TOOL_DEFAULT_EXPANSION_BY_MODE;
+const EXPANDED_TOOLS_CACHE_MAX = 4000;
+const expandedToolsStateCache = new Map>();
+
+const readExpandedToolsCache = (messageId: string): Set => {
+ const cached = expandedToolsStateCache.get(messageId);
+ return cached ? new Set(cached) : new Set();
+};
+
+const writeExpandedToolsCache = (messageId: string, value: Set): void => {
+ if (expandedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !expandedToolsStateCache.has(messageId)) {
+ const oldest = expandedToolsStateCache.keys().next().value;
+ if (typeof oldest === 'string') {
+ expandedToolsStateCache.delete(oldest);
+ }
+ }
+ expandedToolsStateCache.set(messageId, new Set(value));
+};
+
+const isDefaultExpandedTool = (toolName: unknown, mode: DefaultExpandedToolMode): boolean =>
+ typeof toolName === 'string' && TOOL_DEFAULT_EXPANSION_BY_MODE[mode].has(toolName.toLowerCase());
+
+function useStickyDisplayValue(value: T | null | undefined): T | null | undefined {
+ const [stickyValue, setStickyValue] = React.useState(value);
+
+ React.useEffect(() => {
+ if (value !== undefined && value !== null) {
+ setStickyValue(value);
+ }
+ }, [value]);
+
+ return value ?? stickyValue;
+}
+
+const getMessageInfoProp = (info: unknown, key: string): unknown => {
+ if (typeof info === 'object' && info !== null) {
+ return (info as Record)[key];
+ }
+ return undefined;
+};
+
+interface ChatMessageProps {
+ message: {
+ info: Message;
+ parts: Part[];
+ };
+ previousMessage?: {
+ info: Message;
+ parts: Part[];
+ };
+ nextMessage?: {
+ info: Message;
+ parts: Part[];
+ };
+ onContentChange?: (reason?: ContentChangeReason) => void;
+ animationHandlers?: AnimationHandlers;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+ turnGroupingContext?: TurnGroupingContext;
+}
+
+const ChatMessage: React.FC = ({
+ message,
+ previousMessage,
+ nextMessage,
+ onContentChange,
+ animationHandlers,
+ turnGroupingContext,
+}) => {
+ const { isMobile, hasTouchInput } = useDeviceInfo();
+ const { currentTheme } = useThemeSystem();
+ const messageContainerRef = React.useRef(null);
+
+ const sessionState = useSessionStore(
+ useShallow((state) => ({
+ lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null,
+ isStreamingMessage: (() => {
+ const sessionId =
+ (message.info as { sessionID?: string }).sessionID ??
+ state.currentSessionId ??
+ null;
+ if (!sessionId) return false;
+ return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
+ })(),
+ currentSessionId: state.currentSessionId,
+ getAgentModelForSession: state.getAgentModelForSession,
+ getSessionModelSelection: state.getSessionModelSelection,
+ revertToMessage: state.revertToMessage,
+ forkFromMessage: state.forkFromMessage,
+ }))
+ );
+
+ const {
+ lifecyclePhase,
+ isStreamingMessage,
+ currentSessionId,
+ getAgentModelForSession,
+ getSessionModelSelection,
+ revertToMessage,
+ forkFromMessage,
+ } = sessionState;
+
+ const providers = useConfigStore((state) => state.providers);
+ const { showReasoningTraces, toolCallExpansion, stickyUserHeader } = useUIStore(
+ useShallow((state) => ({
+ showReasoningTraces: state.showReasoningTraces,
+ toolCallExpansion: state.toolCallExpansion,
+ stickyUserHeader: state.stickyUserHeader,
+ }))
+ );
+
+ React.useEffect(() => {
+ if (currentSessionId) {
+ MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
+ }
+ }, [currentSessionId]);
+
+ const [copiedCode, setCopiedCode] = React.useState(null);
+ const [copiedMessage, setCopiedMessage] = React.useState(false);
+ const [expandedTools, setExpandedTools] = React.useState>(() => readExpandedToolsCache(message.info.id));
+ const [popupContent, setPopupContent] = React.useState({
+ open: false,
+ title: '',
+ content: '',
+ });
+
+ React.useEffect(() => {
+ setExpandedTools(readExpandedToolsCache(message.info.id));
+ }, [message.info.id]);
+
+ React.useEffect(() => {
+ expandedToolsStateCache.clear();
+ setExpandedTools(new Set());
+ }, [toolCallExpansion]);
+
+ const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
+ const isUser = messageRole.isUser;
+ const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
+ const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
+
+ const sessionId = message.info.sessionID;
+
+ // Subscribe to context changes so badges update immediately on mode switches.
+ const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
+ useShallow((state) => ({
+ currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined,
+ savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
+ }))
+ );
+
+ const normalizedParts = React.useMemo(() => {
+ if (!isUser) {
+ return message.parts;
+ }
+
+ const keepSyntheticUserText = (text: string): boolean => {
+ const trimmed = text.trim();
+ if (trimmed.startsWith('User has requested to enter plan mode')) return true;
+ if (trimmed.startsWith('The plan at ')) return true;
+ if (trimmed.startsWith('The following tool was executed by the user')) return true;
+ return false;
+ };
+
+ return message.parts
+ .filter((part) => {
+ const synthetic = (part as unknown as { synthetic?: boolean })?.synthetic === true;
+ if (!synthetic) return true;
+ if (part.type !== 'text') return false;
+ const text = (part as unknown as { text?: unknown })?.text;
+ return typeof text === 'string' ? keepSyntheticUserText(text) : false;
+ })
+ .map((part) => {
+ const rawPart = part as Record;
+ if (rawPart.type === 'compaction') {
+ return { type: 'text', text: '/compact' } as Part;
+ }
+ if (rawPart.type === 'text') {
+ const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : '';
+ if (text.startsWith('The following tool was executed by the user')) {
+ return { type: 'text', text: '/shell' } as Part;
+ }
+ }
+ return part;
+ });
+ }, [isUser, message.parts]);
+
+ const previousUserMetadata = React.useMemo(() => {
+ if (isUser || !previousMessage) {
+ return null;
+ }
+
+ const clientRole = getMessageInfoProp(previousMessage.info, 'clientRole');
+ const role = getMessageInfoProp(previousMessage.info, 'role');
+ const previousRole = typeof clientRole === 'string' ? clientRole : (typeof role === 'string' ? role : undefined);
+ if (previousRole !== 'user') {
+ return null;
+ }
+
+ const mode = getMessageInfoProp(previousMessage.info, 'mode');
+ const agent = getMessageInfoProp(previousMessage.info, 'agent');
+ const providerID = getMessageInfoProp(previousMessage.info, 'providerID');
+ const modelID = getMessageInfoProp(previousMessage.info, 'modelID');
+ const variant = getMessageInfoProp(previousMessage.info, 'variant');
+ const resolvedAgent =
+ typeof mode === 'string' && mode.trim().length > 0
+ ? mode
+ : (typeof agent === 'string' && agent.trim().length > 0 ? agent : undefined);
+ const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
+ const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
+ const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
+
+ if (!resolvedAgent && !resolvedProvider && !resolvedModel && !resolvedVariant) {
+ return null;
+ }
+
+ return {
+ agentName: resolvedAgent,
+ providerId: resolvedProvider,
+ modelId: resolvedModel,
+ variant: resolvedVariant,
+ };
+ }, [isUser, previousMessage]);
+
+ const previousIsModeSwitchMessage = React.useMemo(() => {
+ if (isUser || !previousMessage) return false;
+ const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : [];
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i] as unknown as { type?: string; text?: string; synthetic?: boolean };
+ if (part?.type !== 'text') continue;
+ if (part?.synthetic !== true) continue;
+ const text = typeof part.text === 'string' ? part.text.trim() : '';
+ if (text.startsWith('User has requested to enter plan mode') || text.startsWith('The plan at ')) {
+ return true;
+ }
+ }
+ return false;
+ }, [isUser, previousMessage]);
+
+ const agentName = React.useMemo(() => {
+ if (isUser) return undefined;
+
+ // While the assistant message is streaming, if the immediately previous user message is a
+ // synthetic mode switch, trust that mode for the badge.
+ const timeInfo = message.info.time as { completed?: number } | undefined;
+ const isCompleted = typeof timeInfo?.completed === 'number' && timeInfo.completed > 0;
+ if (!isCompleted && previousIsModeSwitchMessage && previousUserMetadata?.agentName) {
+ return previousUserMetadata.agentName;
+ }
+
+ const messageMode = getMessageInfoProp(message.info, 'mode');
+ if (typeof messageMode === 'string' && messageMode.trim().length > 0) {
+ return messageMode;
+ }
+
+ const messageAgent = getMessageInfoProp(message.info, 'agent');
+ if (typeof messageAgent === 'string' && messageAgent.trim().length > 0) {
+ return messageAgent;
+ }
+
+ if (previousUserMetadata?.agentName) {
+ return previousUserMetadata.agentName;
+ }
+
+ if (!sessionId) {
+ return undefined;
+ }
+
+ if (currentContextAgent) {
+ return currentContextAgent;
+ }
+
+ return savedSessionAgentSelection ?? undefined;
+ }, [isUser, message.info, previousIsModeSwitchMessage, previousUserMetadata, sessionId, currentContextAgent, savedSessionAgentSelection]);
+
+ const messageProviderID = !isUser ? getMessageInfoProp(message.info, 'providerID') : null;
+ const messageModelID = !isUser ? getMessageInfoProp(message.info, 'modelID') : null;
+
+ const contextModelSelection = React.useMemo(() => {
+ if (isUser || !sessionId) return null;
+
+ if (previousUserMetadata?.providerId && previousUserMetadata?.modelId) {
+ return {
+ providerId: previousUserMetadata.providerId,
+ modelId: previousUserMetadata.modelId,
+ };
+ }
+
+ if (agentName) {
+ const agentSelection = getAgentModelForSession(sessionId, agentName);
+ if (agentSelection?.providerId && agentSelection?.modelId) {
+ return agentSelection;
+ }
+ }
+
+ const sessionSelection = getSessionModelSelection(sessionId);
+ if (sessionSelection?.providerId && sessionSelection?.modelId) {
+ return sessionSelection;
+ }
+
+ return null;
+ }, [isUser, sessionId, agentName, previousUserMetadata, getAgentModelForSession, getSessionModelSelection]);
+
+ const providerID = React.useMemo(() => {
+ if (isUser) return null;
+ if (typeof messageProviderID === 'string' && messageProviderID.trim().length > 0) {
+ return messageProviderID;
+ }
+ return contextModelSelection?.providerId ?? null;
+ }, [isUser, messageProviderID, contextModelSelection]);
+
+ const modelID = React.useMemo(() => {
+ if (isUser) return null;
+ if (typeof messageModelID === 'string' && messageModelID.trim().length > 0) {
+ return messageModelID;
+ }
+ return contextModelSelection?.modelId ?? null;
+ }, [isUser, messageModelID, contextModelSelection]);
+
+ const modelName = React.useMemo(() => {
+ if (isUser) return undefined;
+
+ if (providerID && modelID && providers.length > 0) {
+ const provider = providers.find((p) => p.id === providerID);
+ if (provider?.models && Array.isArray(provider.models)) {
+ const model = provider.models.find((m: Record) => (m as Record).id === modelID);
+ const modelObj = model as Record | undefined;
+ const name = modelObj?.name;
+ return typeof name === 'string' ? name : undefined;
+ }
+ }
+
+ return undefined;
+ }, [isUser, providerID, modelID, providers]);
+
+ const modelHasVariants = React.useMemo(() => {
+ if (isUser) return false;
+ if (!providerID || !modelID) return false;
+
+ const provider = providers.find((p) => p.id === providerID);
+ if (!provider?.models || !Array.isArray(provider.models)) {
+ return false;
+ }
+
+ const model = provider.models.find((m: Record) => (m as Record).id === modelID) as
+ | { variants?: Record }
+ | undefined;
+
+ const variants = model?.variants;
+ return Boolean(variants && Object.keys(variants).length > 0);
+ }, [isUser, modelID, providerID, providers]);
+
+ const displayAgentName = useStickyDisplayValue(agentName);
+ const displayProviderIDValue = useStickyDisplayValue(providerID ?? undefined);
+ const displayModelName = useStickyDisplayValue(modelName);
+
+ const headerAgentName = displayAgentName ?? undefined;
+ const headerProviderID = displayProviderIDValue ?? null;
+ const headerModelName = displayModelName ?? undefined;
+
+ const messageCompletedAt = React.useMemo(() => {
+ const timeInfo = message.info.time as { completed?: number } | undefined;
+ return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null;
+ }, [message.info.time]);
+
+ const messageCreatedAt = React.useMemo(() => {
+ const timeInfo = message.info.time as { created?: number } | undefined;
+ return typeof timeInfo?.created === 'number' ? timeInfo.created : null;
+ }, [message.info.time]);
+
+ const isMessageCompleted = React.useMemo(() => {
+ if (isUser) return true;
+ return Boolean(messageCompletedAt && messageCompletedAt > 0);
+ }, [isUser, messageCompletedAt]);
+
+ const messageFinish = React.useMemo(() => {
+ const finish = (message.info as { finish?: string }).finish;
+ return typeof finish === 'string' ? finish : undefined;
+ }, [message.info]);
+
+ const visibleParts = React.useMemo(
+ () =>
+ filterVisibleParts(normalizedParts, {
+ includeReasoning: showReasoningTraces,
+ }),
+ [normalizedParts, showReasoningTraces]
+ );
+
+ const displayParts = React.useMemo(() => {
+ if (isUser) {
+ return visibleParts;
+ }
+
+ return isMessageCompleted ? visibleParts : [];
+ }, [isUser, isMessageCompleted, visibleParts]);
+
+
+ const assistantTextParts = React.useMemo(() => {
+ if (isUser) {
+ return [];
+ }
+ return visibleParts.filter((part) => part.type === 'text');
+ }, [isUser, visibleParts]);
+
+ const toolParts = React.useMemo(() => {
+ if (isUser) {
+ return [];
+ }
+ const filtered = visibleParts.filter((part) => part.type === 'tool');
+ return filtered;
+ }, [isUser, visibleParts]);
+
+ const effectiveExpandedTools = React.useMemo(() => {
+ // 'collapsed': Activity and tools start collapsed
+ // 'activity': Activity expanded, tools collapsed
+ // 'detailed': Activity expanded, only key tools expanded
+ // 'changes': Activity expanded, only edit/diff tools expanded
+
+ if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
+ // Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
+ return expandedTools;
+ }
+
+ const defaultExpansionMode =
+ toolCallExpansion === 'detailed' || toolCallExpansion === 'changes'
+ ? toolCallExpansion
+ : null;
+
+ if (!defaultExpansionMode) {
+ return expandedTools;
+ }
+
+ // 'detailed'/'changes': expand only allowlisted tools by default.
+ // expandedTools acts as a "toggled" set (XOR with defaults).
+ const defaultExpandedToolIds = new Set();
+
+ for (const part of toolParts) {
+ const toolName = (part as { tool?: unknown }).tool;
+ if (part.id && isDefaultExpandedTool(toolName, defaultExpansionMode)) {
+ defaultExpandedToolIds.add(part.id);
+ }
+ }
+
+ if (turnGroupingContext?.isFirstAssistantInTurn) {
+ for (const activity of turnGroupingContext.activityParts) {
+ if (activity.kind !== 'tool') {
+ continue;
+ }
+
+ const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
+ if (isDefaultExpandedTool(toolPart.tool, defaultExpansionMode)) {
+ if (toolPart.id) {
+ defaultExpandedToolIds.add(toolPart.id);
+ }
+ if (activity.id) {
+ defaultExpandedToolIds.add(activity.id);
+ }
+ }
+ }
+ }
+
+ const effective = new Set(defaultExpandedToolIds);
+ for (const id of expandedTools) {
+ if (effective.has(id)) {
+ effective.delete(id);
+ } else {
+ effective.add(id);
+ }
+ }
+ return effective;
+ }, [expandedTools, toolCallExpansion, toolParts, turnGroupingContext]);
+
+ const agentMention = React.useMemo(() => {
+ if (!isUser) {
+ return undefined;
+ }
+ const mentionPart = message.parts.find((part) => part.type === 'agent');
+ if (!mentionPart) {
+ return undefined;
+ }
+ const partWithName = mentionPart as { name?: string; source?: { value?: string } };
+ const name = typeof partWithName.name === 'string' ? partWithName.name : undefined;
+ if (!name) {
+ return undefined;
+ }
+ const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0
+ ? partWithName.source.value
+ : `@${name}`;
+ return { name, token: rawValue } satisfies AgentMentionInfo;
+ }, [isUser, message.parts]);
+
+ const shouldHideUserMessage = isUser && displayParts.length === 0;
+
+ // Message is considered to have an "open step" if info.finish is not yet present
+ const hasOpenStep = typeof messageFinish !== 'string';
+
+ const shouldCoordinateRendering = React.useMemo(() => {
+ if (isUser) {
+ return false;
+ }
+ if (assistantTextParts.length === 0 || toolParts.length === 0) {
+ return hasOpenStep;
+ }
+ return true;
+ }, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
+
+ const themeVariant = currentTheme?.metadata.variant;
+ const isDarkTheme = React.useMemo(() => {
+ if (themeVariant) {
+ return themeVariant === 'dark';
+ }
+ if (typeof document !== 'undefined') {
+ return document.documentElement.classList.contains('dark');
+ }
+ return false;
+ }, [themeVariant]);
+
+ const syntaxTheme = React.useMemo(() => {
+ if (currentTheme) {
+ return generateSyntaxTheme(currentTheme);
+ }
+ return isDarkTheme ? defaultCodeDark : defaultCodeLight;
+ }, [currentTheme, isDarkTheme]);
+
+ const shouldAnimateMessage = React.useMemo(() => {
+ if (isUser) return false;
+ const freshnessDetector = MessageFreshnessDetector.getInstance();
+ return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
+ }, [message.info, currentSessionId, isUser]);
+
+ const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
+
+ const previousRole = React.useMemo(() => {
+ if (!previousMessage) return null;
+ return deriveMessageRole(previousMessage.info);
+ }, [previousMessage]);
+
+ const nextRole = React.useMemo(() => {
+ if (!nextMessage) return null;
+ return deriveMessageRole(nextMessage.info);
+ }, [nextMessage]);
+
+ const isFollowedByAssistant = React.useMemo(() => {
+ if (isUser) return false;
+ if (!nextRole) return false;
+ return !nextRole.isUser && nextRole.role === 'assistant';
+ }, [isUser, nextRole]);
+
+ const streamPhase: StreamPhase = React.useMemo(() => {
+ if (isMessageCompleted) {
+ return 'completed';
+ }
+ if (lifecyclePhase) {
+ return lifecyclePhase;
+ }
+ return isStreamingMessage ? 'streaming' : 'completed';
+ }, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
+
+ React.useEffect(() => {
+ setHasStartedStreamingHeader(false);
+ }, [message.info.id]);
+
+ React.useEffect(() => {
+ const headerMessageId = turnGroupingContext?.headerMessageId;
+ if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
+ return;
+ }
+
+ const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
+ if (isCurrentlyStreaming) {
+ setHasStartedStreamingHeader(true);
+ }
+ }, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
+
+ const shouldShowHeader = React.useMemo(() => {
+ if (isUser) return true;
+
+ // Use turn grouping context if available for more precise control
+ const headerMessageId = turnGroupingContext?.headerMessageId;
+ if (headerMessageId) {
+ // For turn grouping: only show header for the first assistant message in the turn
+ const isFirstAssistantInTurn = message.info.id === headerMessageId;
+
+ if (isFirstAssistantInTurn) {
+ // For completed messages, always show header (historical messages)
+ if (streamPhase === 'completed') {
+ return true;
+ }
+
+ // For streaming messages: show header when streaming starts and keep it visible
+ const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
+ return hasStartedStreamingHeader || isCurrentlyStreaming;
+ }
+
+ // For non-first assistant messages, don't show header
+ return false;
+ }
+
+ // Fallback to original logic when turn grouping is not available
+ if (!previousRole) return true;
+ return previousRole.isUser;
+ }, [hasStartedStreamingHeader, isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
+
+ const handleCopyCode = React.useCallback((code: string) => {
+ void copyTextToClipboard(code).then((result) => {
+ if (!result.ok) {
+ return;
+ }
+ setCopiedCode(code);
+ setTimeout(() => setCopiedCode(null), 2000);
+ });
+ }, []);
+
+ const userMessageIdForTurn = turnGroupingContext?.turnId;
+ const { assistantSummaryFromStore, variantFromTurnStore } = useMessageStore(
+ useShallow((state) => {
+ if (!userMessageIdForTurn || !message.info.sessionID) {
+ return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined };
+ }
+ const sessionMessages = state.messages.get(message.info.sessionID);
+ if (!sessionMessages) {
+ return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined };
+ }
+ const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn);
+ if (!userMsg) {
+ return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined };
+ }
+ const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
+ const body = summary?.body;
+ const variant = (userMsg.info as { variant?: unknown }).variant;
+ return {
+ assistantSummaryFromStore: typeof body === 'string' && body.trim().length > 0 ? body : undefined,
+ variantFromTurnStore: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined,
+ };
+ })
+ );
+
+ const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined;
+
+ const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined;
+
+ const assistantSummaryCandidate =
+ typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
+ ? turnGroupingContext.summaryBody
+ : assistantSummaryFromStore;
+
+ const [assistantSummaryForCopy, setAssistantSummaryForCopy] = React.useState(undefined);
+
+ React.useEffect(() => {
+ setAssistantSummaryForCopy(undefined);
+ }, [userMessageIdForTurn]);
+
+ React.useEffect(() => {
+ if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) {
+ setAssistantSummaryForCopy(assistantSummaryCandidate);
+ }
+ }, [assistantSummaryCandidate]);
+
+ const assistantErrorText = React.useMemo(() => {
+ if (isUser) {
+ return undefined;
+ }
+ const errorInfo = (message.info as { error?: unknown } | undefined)?.error as
+ | { data?: { message?: unknown }; message?: unknown; name?: unknown }
+ | undefined;
+ if (!errorInfo) {
+ return undefined;
+ }
+ const dataMessage = typeof errorInfo.data?.message === 'string' ? errorInfo.data.message : undefined;
+ const errorMessage = typeof errorInfo.message === 'string' ? errorInfo.message : undefined;
+ const errorName = typeof errorInfo.name === 'string' ? errorInfo.name : undefined;
+ const detail = dataMessage || errorMessage || errorName;
+ if (!detail) {
+ return undefined;
+ }
+ if (errorName === 'SessionRetry') {
+ return `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``;
+ }
+ if (isLikelyProviderAuthFailure(detail)) {
+ return PROVIDER_AUTH_FAILURE_MESSAGE;
+ }
+ return `Opencode failed to send message with error:\n\`${detail}\``;
+ }, [isUser, message.info]);
+
+ const messageTextContent = React.useMemo(() => {
+ if (isUser) {
+ const shellOutputs = displayParts
+ .filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
+ .map((part) => {
+ const output = part.shellAction?.output;
+ return typeof output === 'string' ? output.trim() : '';
+ })
+ .filter((output) => output.length > 0);
+
+ if (shellOutputs.length > 0) {
+ return shellOutputs.join('\n\n');
+ }
+
+ const shellCommands = displayParts
+ .filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
+ .map((part) => {
+ const command = part.shellAction?.command;
+ return typeof command === 'string' ? command.trim() : '';
+ })
+ .filter((command) => command.length > 0);
+
+ if (shellCommands.length > 0) {
+ return shellCommands.join('\n');
+ }
+
+ const textParts = displayParts
+ .filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
+ .map((part) => {
+ const text = part.text || part.content || '';
+ return text.trim();
+ })
+ .filter((text) => text.length > 0);
+
+ const combined = textParts.join('\n');
+ return combined.replace(/\n\s*\n+/g, '\n');
+ }
+
+ if (assistantErrorText && assistantErrorText.trim().length > 0) {
+ return assistantErrorText;
+ }
+
+ if (assistantSummaryForCopy && assistantSummaryForCopy.trim().length > 0) {
+ return assistantSummaryForCopy;
+ }
+
+ return flattenAssistantTextParts(displayParts);
+ }, [assistantErrorText, assistantSummaryForCopy, displayParts, isUser]);
+
+ const hasTextContent = messageTextContent.length > 0;
+
+ const handleCopyMessage = React.useCallback(async () => {
+ const result = await copyTextToClipboard(messageTextContent);
+ if (!result.ok) {
+ return;
+ }
+ setCopiedMessage(true);
+ setTimeout(() => setCopiedMessage(false), 2000);
+ }, [messageTextContent]);
+
+ const handleRevert = React.useCallback(() => {
+ if (!sessionId || !message.info.id) return;
+ revertToMessage(sessionId, message.info.id);
+ }, [sessionId, message.info.id, revertToMessage]);
+
+ // NEW: Fork handler
+ const handleFork = React.useCallback(() => {
+ if (!sessionId || !message.info.id) return;
+ forkFromMessage(sessionId, message.info.id);
+ }, [sessionId, message.info.id, forkFromMessage]);
+
+ const handleToggleTool = React.useCallback((toolId: string) => {
+ setExpandedTools((prev) => {
+ const next = new Set(prev);
+ if (next.has(toolId)) {
+ next.delete(toolId);
+ } else {
+ next.add(toolId);
+ }
+ writeExpandedToolsCache(message.info.id, next);
+ return next;
+ });
+ }, [message.info.id]);
+
+ const resolvedAnimationHandlers = animationHandlers ?? null;
+ const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
+
+ const animationCompletedRef = React.useRef(false);
+ const hasRequestedReservationRef = React.useRef(false);
+ const animationStartNotifiedRef = React.useRef(false);
+ const hasTriggeredReservationOnceRef = React.useRef(false);
+
+ React.useEffect(() => {
+ animationCompletedRef.current = false;
+ hasRequestedReservationRef.current = false;
+ animationStartNotifiedRef.current = false;
+ hasTriggeredReservationOnceRef.current = false;
+ hasAnnouncedAuxiliaryScrollRef.current = false;
+ }, [message.info.id]);
+
+ const handleAuxiliaryContentComplete = React.useCallback(() => {
+ if (isUser) {
+ return;
+ }
+ if (hasAnnouncedAuxiliaryScrollRef.current) {
+ return;
+ }
+ hasAnnouncedAuxiliaryScrollRef.current = true;
+ onContentChange?.('structural');
+ }, [isUser, onContentChange]);
+
+ const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
+
+ const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
+
+ if (content.image || content.mermaid) {
+ setPopupContent(content);
+ setImagePreviewOpen(true);
+ }
+ }, [setImagePreviewOpen]);
+
+ const handlePopupChange = React.useCallback((open: boolean) => {
+ setPopupContent((prev) => ({ ...prev, open }));
+ setImagePreviewOpen(open);
+ }, [setImagePreviewOpen]);
+
+ const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
+ const isStreamingPhase = streamPhase === 'streaming';
+
+ const hasReasoningParts = React.useMemo(() => {
+ if (isUser) {
+ return false;
+ }
+ return visibleParts.some((part) => part.type === 'reasoning');
+ }, [isUser, visibleParts]);
+
+ const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase;
+ const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
+
+ React.useEffect(() => {
+ if (!resolvedAnimationHandlers?.onStreamingCandidate) {
+ return;
+ }
+
+ if (!shouldReserveAnimationSpace) {
+ if (hasRequestedReservationRef.current) {
+ if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
+ resolvedAnimationHandlers.onReasoningBlock();
+ } else if (resolvedAnimationHandlers?.onReservationCancelled) {
+ resolvedAnimationHandlers.onReservationCancelled();
+ }
+ hasRequestedReservationRef.current = false;
+ }
+ return;
+ }
+
+ if (hasTriggeredReservationOnceRef.current) {
+ return;
+ }
+
+ hasTriggeredReservationOnceRef.current = true;
+ resolvedAnimationHandlers.onStreamingCandidate();
+ hasRequestedReservationRef.current = true;
+ }, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
+
+ React.useEffect(() => {
+ if (!resolvedAnimationHandlers?.onAnimationStart) {
+ return;
+ }
+ if (!allowAnimation) {
+ return;
+ }
+ if (animationStartNotifiedRef.current) {
+ return;
+ }
+ resolvedAnimationHandlers.onAnimationStart();
+ animationStartNotifiedRef.current = true;
+ }, [resolvedAnimationHandlers, allowAnimation]);
+
+ React.useEffect(() => {
+ if (isUser) {
+ return;
+ }
+
+ const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
+ if (!handler) {
+ return;
+ }
+
+ const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
+ if (!shouldTrackHeight) {
+ return;
+ }
+
+ const element = messageContainerRef.current;
+ if (!element) {
+ return;
+ }
+
+ if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
+ handler(element.getBoundingClientRect().height);
+ return;
+ }
+
+ let rafId: number | null = null;
+ const notifyHeight = (height: number) => {
+ if (typeof window === 'undefined') {
+ handler(height);
+ return;
+ }
+ if (rafId !== null) {
+ window.cancelAnimationFrame(rafId);
+ }
+ rafId = window.requestAnimationFrame(() => {
+ handler(height);
+ });
+ };
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (!entry) {
+ return;
+ }
+ notifyHeight(entry.contentRect.height);
+ });
+
+ observer.observe(element);
+ notifyHeight(element.getBoundingClientRect().height);
+
+ return () => {
+ if (rafId !== null) {
+ window.cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ observer.disconnect();
+ };
+ }, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
+
+ if (shouldHideUserMessage) {
+ return null;
+ }
+
+ const assistantTopPaddingClass = !isUser && shouldShowHeader
+ ? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
+ : 'pt-0';
+
+ return (
+ <>
+
+
+ {isUser ? (
+ displayParts.length === 0 ? null : (
+
+
+
+
+
+
+ {useExternalUserActionsRow ? (
+
+ ) : null}
+
+ {showStickyInlineHoverRow ?
: null}
+
+
+ )
+ ) : (
+
+ {shouldShowHeader && (
+
+ )}
+
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+export default React.memo(ChatMessage);
diff --git a/ui/src/components/chat/CommandAutocomplete.tsx b/ui/src/components/chat/CommandAutocomplete.tsx
new file mode 100644
index 0000000..97565ec
--- /dev/null
+++ b/ui/src/components/chat/CommandAutocomplete.tsx
@@ -0,0 +1,419 @@
+import React from 'react';
+import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
+import { cn, fuzzyMatch } from '@/lib/utils';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useCommandsStore } from '@/stores/useCommandsStore';
+import { useSkillsStore } from '@/stores/useSkillsStore';
+import { useShallow } from 'zustand/react/shallow';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+
+interface CommandInfo {
+ name: string;
+ description?: string;
+ agent?: string;
+ model?: string;
+ isBuiltIn?: boolean;
+ isSkill?: boolean;
+ scope?: string;
+}
+
+export interface CommandAutocompleteHandle {
+ handleKeyDown: (key: string) => void;
+}
+
+type AutocompleteTab = 'commands' | 'agents' | 'files';
+
+interface CommandAutocompleteProps {
+ searchQuery: string;
+ onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
+ onClose: () => void;
+ showTabs?: boolean;
+ activeTab?: AutocompleteTab;
+ onTabSelect?: (tab: AutocompleteTab) => void;
+ style?: React.CSSProperties;
+}
+
+export const CommandAutocomplete = React.forwardRef(({
+ searchQuery,
+ onCommandSelect,
+ onClose,
+ showTabs,
+ activeTab = 'commands',
+ onTabSelect,
+ style,
+}, ref) => {
+ const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
+ useShallow((state) => {
+ const sessionId = state.currentSessionId;
+ const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
+ return {
+ hasMessagesInCurrentSession: messageCount > 0,
+ currentSessionId: sessionId,
+ };
+ })
+ );
+ const hasSession = Boolean(currentSessionId);
+
+ const [commands, setCommands] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+ const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
+ const { skills, loadSkills: refreshSkills } = useSkillsStore();
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
+ const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const containerRef = React.useRef(null);
+ const ignoreClickRef = React.useRef(false);
+ const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
+ const pointerMovedRef = React.useRef(false);
+ const ignoreTabClickRef = React.useRef(false);
+
+ React.useEffect(() => {
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (!target || !containerRef.current) {
+ return;
+ }
+ if (containerRef.current.contains(target)) {
+ return;
+ }
+ onClose();
+ };
+
+ document.addEventListener('pointerdown', handlePointerDown, true);
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown, true);
+ };
+ }, [onClose]);
+
+ React.useEffect(() => {
+ // Force refresh to get latest project context when mounting
+ void refreshCommands();
+ void refreshSkills();
+ }, [refreshCommands, refreshSkills]);
+
+ React.useEffect(() => {
+ const loadCommands = async () => {
+ setLoading(true);
+ try {
+ const skillNames = new Set(skills.map((skill) => skill.name));
+ const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({
+ name: cmd.name,
+ description: cmd.description,
+ agent: cmd.agent ?? undefined,
+ model: cmd.model ?? undefined,
+ isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
+ isSkill: skillNames.has(cmd.name),
+ scope: cmd.scope,
+ }));
+
+ const builtInCommands: CommandInfo[] = [
+ ...(hasSession && !hasMessagesInCurrentSession
+ ? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
+ : []
+ ),
+ ...(hasSession // Show when session exists, not when hasMessages
+ ? [
+ { name: 'undo', description: 'Undo the last message', isBuiltIn: true },
+ { name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
+ { name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
+ ]
+ : []
+ ),
+ { name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
+ ];
+
+ const commandMap = new Map();
+
+ builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
+
+ customCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
+
+ const allCommands = Array.from(commandMap.values());
+
+ const allowInitCommand = !hasMessagesInCurrentSession;
+ const filtered = (searchQuery
+ ? allCommands.filter(cmd =>
+ fuzzyMatch(cmd.name, searchQuery) ||
+ (cmd.description && fuzzyMatch(cmd.description, searchQuery))
+ )
+ : allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
+
+ filtered.sort((a, b) => {
+ const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
+ const bStartsWith = b.name.toLowerCase().startsWith(searchQuery.toLowerCase());
+ if (aStartsWith && !bStartsWith) return -1;
+ if (!aStartsWith && bStartsWith) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ setCommands(filtered);
+ } catch {
+
+ const allowInitCommand = !hasMessagesInCurrentSession;
+ const builtInCommands: CommandInfo[] = [
+ ...(hasSession && !hasMessagesInCurrentSession
+ ? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
+ : []
+ ),
+ ...(hasSession // Show when session exists, not when hasMessages
+ ? [
+ { name: 'undo', description: 'Undo the last message', isBuiltIn: true },
+ { name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
+ { name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
+ ]
+ : []
+ ),
+ { name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
+ ];
+
+ const filtered = (searchQuery
+ ? builtInCommands.filter(cmd =>
+ fuzzyMatch(cmd.name, searchQuery) ||
+ (cmd.description && fuzzyMatch(cmd.description, searchQuery))
+ )
+ : builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
+
+ setCommands(filtered);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadCommands();
+ }, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]);
+
+ React.useEffect(() => {
+ setSelectedIndex(0);
+ }, [commands]);
+
+ React.useEffect(() => {
+ itemRefs.current[selectedIndex]?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest'
+ });
+ }, [selectedIndex]);
+
+ React.useImperativeHandle(ref, () => ({
+ handleKeyDown: (key: string) => {
+ const total = commands.length;
+ if (key === 'Escape') {
+ onClose();
+ return;
+ }
+
+ if (total === 0) {
+ return;
+ }
+
+ if (key === 'ArrowDown') {
+ setSelectedIndex((prev) => (prev + 1) % total);
+ return;
+ }
+
+ if (key === 'ArrowUp') {
+ setSelectedIndex((prev) => (prev - 1 + total) % total);
+ return;
+ }
+
+ if (key === 'Enter' || key === 'Tab') {
+ const safeIndex = ((selectedIndex % total) + total) % total;
+ const command = commands[safeIndex];
+ if (command) {
+ onCommandSelect(command);
+ }
+ }
+ }
+ }), [commands, selectedIndex, onClose, onCommandSelect]);
+
+ const getCommandIcon = (command: CommandInfo) => {
+
+ switch (command.name) {
+ case 'init':
+ return ;
+ case 'undo':
+ return ;
+ case 'redo':
+ return ;
+ case 'timeline':
+ return ;
+ case 'compact':
+ return ;
+ case 'test':
+ case 'build':
+ case 'run':
+ return ;
+ default:
+ if (command.isBuiltIn) {
+ return ;
+ }
+ return ;
+ }
+ };
+
+ return (
+
+ {showTabs ? (
+
+
+ {([
+ { id: 'commands' as const, label: 'Commands' },
+ { id: 'agents' as const, label: 'Agents' },
+ { id: 'files' as const, label: 'Files' },
+ ]).map((tab) => (
+ {
+ if (event.pointerType !== 'touch') {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ ignoreTabClickRef.current = true;
+ onTabSelect?.(tab.id);
+ }}
+ onClick={() => {
+ if (ignoreTabClickRef.current) {
+ ignoreTabClickRef.current = false;
+ return;
+ }
+ onTabSelect?.(tab.id);
+ }}
+ >
+ {tab.label}
+
+ ))}
+
+
+ ) : null}
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {commands.map((command, index) => {
+ const isSystem = command.isBuiltIn;
+ const isProject = command.scope === 'project';
+
+ return (
+
{ itemRefs.current[index] = el; }}
+ className={cn(
+ "flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
+ index === selectedIndex && "bg-interactive-selection"
+ )}
+ onPointerDown={(event) => {
+ if (event.pointerType !== 'touch') {
+ return;
+ }
+ pointerStartRef.current = { x: event.clientX, y: event.clientY };
+ pointerMovedRef.current = false;
+ }}
+ onPointerMove={(event) => {
+ if (event.pointerType !== 'touch' || !pointerStartRef.current) {
+ return;
+ }
+ const dx = event.clientX - pointerStartRef.current.x;
+ const dy = event.clientY - pointerStartRef.current.y;
+ if (Math.hypot(dx, dy) > 6) {
+ pointerMovedRef.current = true;
+ }
+ }}
+ onPointerUp={(event) => {
+ if (event.pointerType !== 'touch') {
+ return;
+ }
+ const didMove = pointerMovedRef.current;
+ pointerStartRef.current = null;
+ pointerMovedRef.current = false;
+ if (didMove) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ ignoreClickRef.current = true;
+ onCommandSelect(command, { dismissKeyboard: true });
+ }}
+ onPointerCancel={() => {
+ pointerStartRef.current = null;
+ pointerMovedRef.current = false;
+ }}
+ onClick={() => {
+ if (ignoreClickRef.current) {
+ ignoreClickRef.current = false;
+ return;
+ }
+ onCommandSelect(command);
+ }}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
+
+ {getCommandIcon(command)}
+
+
+
+ /{command.name}
+ {command.isSkill ? (
+
+ skill
+
+ ) : null}
+ {isSystem ? (
+
+ system
+
+ ) : command.scope ? (
+
+ {command.scope}
+
+ ) : null}
+ {command.agent && (
+
+ {command.agent}
+
+ )}
+
+ {command.description && (
+
+ {command.description}
+
+ )}
+
+
+ );
+ })}
+ {commands.length === 0 && (
+
+ No commands found
+
+ )}
+
+ )}
+
+
+ ↑↓ navigate • Enter select • Esc close
+
+
+ );
+});
+
+CommandAutocomplete.displayName = 'CommandAutocomplete';
+
+export type { CommandInfo };
diff --git a/ui/src/components/chat/DiffPreview.tsx b/ui/src/components/chat/DiffPreview.tsx
new file mode 100644
index 0000000..7c144f6
--- /dev/null
+++ b/ui/src/components/chat/DiffPreview.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { cn } from '@/lib/utils';
+import { getLanguageFromExtension } from '@/lib/toolHelpers';
+import { parseDiffToUnified } from './message/toolRenderers';
+
+interface DiffPreviewProps {
+ diff: string;
+ syntaxTheme: { [key: string]: React.CSSProperties };
+ filePath?: string;
+}
+
+export const DiffPreview: React.FC = ({ diff, syntaxTheme, filePath }) => (
+
+ {parseDiffToUnified(diff).map((hunk, hunkIdx) => (
+
+
+ {`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
+
+
+
+ {hunk.lines.map((line, lineIdx) => (
+
+
+ {line.lineNumber || ''}
+
+
+
+ {line.content}
+
+
+
+ ))}
+
+
+ ))}
+
+);
+
+interface WritePreviewProps {
+ content: string;
+ syntaxTheme: { [key: string]: React.CSSProperties };
+ filePath?: string;
+}
+
+export const WritePreview: React.FC = ({ content, syntaxTheme, filePath }) => {
+ const lines = content.split('\n');
+ const language = getLanguageFromExtension(filePath ?? '') || 'text';
+ const displayPath = filePath?.split('/').pop() || 'New file';
+ const lineCount = Math.max(lines.length, 1);
+ const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
+
+ return (
+
+
+ {`${displayPath} (${headerLineLabel})`}
+
+
+ {lines.map((line, lineIdx) => (
+
+
+ {lineIdx + 1}
+
+
+
+ {line || ' '}
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/ui/src/components/chat/FileAttachment.tsx b/ui/src/components/chat/FileAttachment.tsx
new file mode 100644
index 0000000..f9b8a4e
--- /dev/null
+++ b/ui/src/components/chat/FileAttachment.tsx
@@ -0,0 +1,606 @@
+import React, { useRef, memo } from 'react';
+import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine } from '@remixicon/react';
+import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { toast } from '@/components/ui';
+import { cn } from '@/lib/utils';
+import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
+import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
+import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
+
+import type { ToolPopupContent } from './message/types';
+
+export const FileAttachmentButton = memo(() => {
+ const fileInputRef = useRef(null);
+ const { addAttachedFile } = useSessionStore();
+ const { isMobile } = useUIStore();
+ const isVSCodeRuntime = useIsVSCodeRuntime();
+ const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
+ const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
+
+ const attachFiles = async (files: FileList | File[]) => {
+ let attachedCount = 0;
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const sizeBefore = useSessionStore.getState().attachedFiles.length;
+ try {
+ await addAttachedFile(file);
+ const sizeAfter = useSessionStore.getState().attachedFiles.length;
+ if (sizeAfter > sizeBefore) {
+ attachedCount++;
+ }
+ } catch (error) {
+ console.error('File attach failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to attach file');
+ }
+ }
+ if (attachedCount > 0) {
+ toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
+ }
+ };
+
+ const handleFileSelect = async (e: React.ChangeEvent) => {
+ const files = e.target.files;
+ if (!files) return;
+ await attachFiles(files);
+
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ const handleVSCodePick = async () => {
+ try {
+ const response = await fetch('/api/vscode/pick-files');
+ const data = await response.json();
+ const picked = Array.isArray(data?.files) ? data.files : [];
+ const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
+
+ if (skipped.length > 0) {
+ const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
+ toast.error(`Some files were skipped:\n${summary}`);
+ }
+
+ const asFiles = picked
+ .map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
+ if (!file?.dataUrl) return null;
+ try {
+ const [meta, base64] = file.dataUrl.split(',');
+ const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
+ if (!base64) return null;
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ const blob = new Blob([bytes], { type: mime });
+ return new File([blob], file.name || 'file', { type: mime });
+ } catch (err) {
+ console.error('Failed to decode VS Code picked file', err);
+ return null;
+ }
+ })
+ .filter(Boolean) as File[];
+
+ if (asFiles.length > 0) {
+ await attachFiles(asFiles);
+ }
+ } catch (error) {
+ console.error('VS Code file pick failed', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
+ }
+ };
+
+ return (
+ <>
+
+
+
+ fileInputRef.current?.click()}
+ className={cn(
+ 'flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
+ 'hover:bg-muted text-muted-foreground',
+ buttonSizeClass
+ )}
+ aria-label="Attach files"
+ >
+
+
+
+
+ Attach files
+
+
+ >
+ );
+});
+
+FileAttachmentButton.displayName = 'FileAttachmentButton';
+
+interface ImagePreviewProps {
+ file: AttachedFile;
+ onRemove: () => void;
+}
+
+const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
+ const isLocalImagePreview =
+ file.source !== 'server' &&
+ file.mimeType.startsWith('image/') &&
+ typeof file.dataUrl === 'string' &&
+ file.dataUrl.startsWith('data:image/');
+
+ const imageUrl = isLocalImagePreview ? file.dataUrl : (file.serverPath || '');
+
+ const extractFilename = (path: string): string => {
+ const normalized = path.replace(/\\/g, '/');
+ const parts = normalized.split('/');
+ return parts[parts.length - 1] || path;
+ };
+
+ const getFileExtension = (filename: string): string => {
+ const parts = filename.split('.');
+ return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
+ };
+
+ const displayName = extractFilename(file.filename);
+ const extension = getFileExtension(file.filename);
+
+ if (!imageUrl) {
+ // Fallback to text-only for server images without preview
+ return (
+
+
+
+ {displayName}
+
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
+ aria-label={`Remove ${displayName}`}
+ >
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+});
+
+ImagePreview.displayName = 'ImagePreview';
+
+interface FileChipProps {
+ file: AttachedFile;
+ onRemove: () => void;
+}
+
+const FileChip = memo(({ file, onRemove }: FileChipProps) => {
+ const getFileExtension = (filename: string): string => {
+ const parts = filename.split('.');
+ return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
+ };
+
+ const formatFileSize = (bytes: number) => {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '';
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ };
+
+ const extractFilename = (path: string): string => {
+ const normalized = path.replace(/\\/g, '/');
+ const parts = normalized.split('/');
+ const filename = parts[parts.length - 1];
+ return filename || path;
+ };
+
+ const displayName = extractFilename(file.filename);
+ const fileSize = formatFileSize(file.size);
+ const extension = getFileExtension(file.filename);
+
+ return (
+ {
+ // Prevent click from bubbling if clicking the remove button
+ if ((e.target as HTMLElement).closest('[data-remove-button]')) {
+ return;
+ }
+ }}
+ className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
+ >
+
+
+ {displayName}
+ {fileSize && ({fileSize}) }
+
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
+ aria-label={`Remove ${displayName}`}
+ >
+
+
+
+ );
+});
+
+FileChip.displayName = 'FileChip';
+
+export const AttachedFilesList = memo(() => {
+ const { attachedFiles, removeAttachedFile } = useSessionStore();
+
+ const localFiles = attachedFiles.filter((file) => file.source !== 'server');
+
+ if (localFiles.length === 0) return null;
+
+ const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
+ const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/'));
+
+ return (
+
+ {/* Images row - inline with previews */}
+ {images.length > 0 && (
+
+ {images.map((file) => (
+ removeAttachedFile(file.id)}
+ />
+ ))}
+
+ )}
+
+ {/* Other files row - inline text-only */}
+ {otherFiles.length > 0 && (
+
+ {otherFiles.map((file) => (
+ removeAttachedFile(file.id)}
+ />
+ ))}
+
+ )}
+
+ );
+});
+
+AttachedFilesList.displayName = 'AttachedFilesList';
+
+interface FilePart {
+ type: string;
+ mime?: string;
+ url?: string;
+ filename?: string;
+ size?: number;
+}
+
+interface MessageFilesDisplayProps {
+ files: FilePart[];
+ onShowPopup?: (content: ToolPopupContent) => void;
+ compact?: boolean;
+}
+
+export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => {
+
+ const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url));
+
+ const extractFilename = (path?: string): string => {
+ if (!path) return 'Unnamed file';
+
+ const normalized = path.replace(/\\/g, '/');
+ const parts = normalized.split('/');
+ const filename = parts[parts.length - 1];
+
+ return filename || path;
+ };
+
+ const formatFileSize = (bytes?: number) => {
+ if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ };
+
+ const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
+ const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
+
+ const imageGallery = React.useMemo(
+ () =>
+ imageFiles.flatMap((file) => {
+ if (!file.url) return [];
+ const filename = extractFilename(file.filename) || 'Image';
+ return [{
+ url: file.url,
+ mimeType: file.mime,
+ filename,
+ size: file.size,
+ }];
+ }),
+ [imageFiles]
+ );
+
+ const handleImageClick = React.useCallback((index: number) => {
+ if (!onShowPopup) {
+ return;
+ }
+
+ const file = imageGallery[index];
+ if (!file?.url) return;
+
+ const filename = file.filename || 'Image';
+
+ onShowPopup({
+ open: true,
+ title: filename,
+ content: '',
+ metadata: {
+ tool: 'image-preview',
+ filename,
+ mime: file.mimeType,
+ size: file.size,
+ },
+ image: {
+ url: file.url,
+ mimeType: file.mimeType,
+ filename,
+ size: file.size,
+ gallery: imageGallery,
+ index,
+ },
+ });
+ }, [imageGallery, onShowPopup]);
+
+ if (fileItems.length === 0) return null;
+
+ if (compact) {
+ return (
+
+ {otherFiles.length > 0 && (
+
+ {otherFiles.map((file, index) => {
+ const fileName = extractFilename(file.filename || file.url);
+ const sizeText = formatFileSize(file.size);
+ return (
+
+
+
+ {file.mime?.includes('pdf') ? (
+
+ ) : (
+
+ )}
+
+ {fileName}
+
+
+
+
+ {fileName}{sizeText ? ` (${sizeText})` : ''}
+
+
+ );
+ })}
+
+ )}
+
+ {imageFiles.length > 0 && (
+
+
+ {imageFiles.map((file, index) => {
+ const filename = extractFilename(file.filename) || 'Image';
+
+ return (
+
+
+ handleImageClick(index)}
+ className="relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
+ aria-label={filename}
+ >
+ {file.url ? (
+ {
+ const target = e.target as HTMLImageElement;
+ target.style.visibility = 'hidden';
+ }}
+ />
+ ) : (
+
+
+
+ )}
+ {filename}
+
+
+
+ {filename}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {fileItems.map((file, index) => {
+ const fileName = extractFilename(file.filename || file.url);
+ const isImage = file.mime?.startsWith('image/');
+ const sizeText = formatFileSize(file.size);
+
+ if (isImage && file.url) {
+ return (
+
+
+
+
+
{fileName}
+ {sizeText &&
{sizeText}
}
+
+
+ );
+ }
+
+ return (
+
+
+ {
+ if (onShowPopup && file.url) {
+ onShowPopup({
+ open: true,
+ title: fileName,
+ content: '',
+ image: {
+ url: file.url,
+ mimeType: file.mime,
+ filename: fileName,
+ },
+ });
+ }
+ }}
+ className={cn(
+ "flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
+ compact ? "text-xs" : "text-sm"
+ )}
+ >
+
+ {file.mime?.startsWith('image/') ? (
+
+ ) : file.mime?.includes('pdf') ? (
+
+ ) : (
+
+ )}
+
+
+
{fileName}
+ {sizeText &&
{sizeText}
}
+
+
+
+
+ {fileName}{sizeText ? ` (${sizeText})` : ''}
+
+
+ );
+ })}
+
+ );
+});
+
+MessageFilesDisplay.displayName = 'MessageFilesDisplay';
+
+interface ImageGalleryProps {
+ urls: string[];
+ caption?: string;
+ onShowPopup?: (content: ToolPopupContent) => void;
+}
+
+export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => {
+ if (urls.length === 0) return null;
+
+ const getGridCols = () => {
+ if (urls.length === 1) return 'grid-cols-1';
+ if (urls.length === 2) return 'grid-cols-2';
+ if (urls.length <= 4) return 'grid-cols-2';
+ return 'grid-cols-3';
+ };
+
+ return (
+
+
+ {urls.map((url, index) => (
+
onShowPopup?.({
+ open: true,
+ title: caption || `Image ${index + 1} of ${urls.length}`,
+ content: '',
+ image: {
+ url,
+ gallery: urls.map(u => ({ url: u })),
+ index,
+ },
+ })}
+ className="relative aspect-square rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
+ >
+
+
+
+ ))}
+
+ {caption && (
+
{caption}
+ )}
+
+ );
+});
+
+ImageGallery.displayName = 'ImageGallery';
diff --git a/ui/src/components/chat/FileMentionAutocomplete.tsx b/ui/src/components/chat/FileMentionAutocomplete.tsx
new file mode 100644
index 0000000..051d78c
--- /dev/null
+++ b/ui/src/components/chat/FileMentionAutocomplete.tsx
@@ -0,0 +1,569 @@
+import React from 'react';
+import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
+import { cn, truncatePathMiddle } from '@/lib/utils';
+import { useFileSearchStore } from '@/stores/useFileSearchStore';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
+import { useDebouncedValue } from '@/hooks/useDebouncedValue';
+import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
+import type { ProjectFileSearchHit } from '@/lib/opencode/client';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
+import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
+
+type FileInfo = ProjectFileSearchHit;
+type AgentInfo = {
+ name: string;
+ description?: string;
+ mode?: string | null;
+};
+
+export interface FileMentionHandle {
+ handleKeyDown: (key: string) => void;
+}
+
+type AutocompleteTab = 'commands' | 'agents' | 'files';
+
+interface FileMentionAutocompleteProps {
+ searchQuery: string;
+ onFileSelect: (file: FileInfo) => void;
+ onAgentSelect?: (agentName: string) => void;
+ onClose: () => void;
+ showTabs?: boolean;
+ activeTab?: AutocompleteTab;
+ onTabSelect?: (tab: AutocompleteTab) => void;
+ style?: React.CSSProperties;
+}
+
+export const FileMentionAutocomplete = React.forwardRef(({
+ searchQuery,
+ onFileSelect,
+ onAgentSelect,
+ onClose,
+ showTabs,
+ activeTab = 'files',
+ onTabSelect,
+ style,
+}, ref) => {
+ const currentDirectory = useChatSearchDirectory() ?? '';
+ const activeProjectId = useProjectsStore((state) => state.activeProjectId);
+ const activeProjectPath = useProjectsStore(
+ React.useCallback(
+ (state) => state.projects.find((project) => project.id === activeProjectId)?.path ?? null,
+ [activeProjectId],
+ ),
+ );
+ const projectRoot = React.useMemo(() => {
+ const candidate = activeProjectPath || currentDirectory;
+ return candidate ? candidate.replace(/\\/g, '/').replace(/\/+$/, '') : null;
+ }, [activeProjectPath, currentDirectory]);
+ const projectTabs = useFilesViewTabsStore(
+ React.useCallback(
+ (state) => (projectRoot ? state.byRoot[projectRoot] : undefined),
+ [projectRoot],
+ ),
+ );
+ const { getVisibleAgents } = useConfigStore();
+ const searchFiles = useFileSearchStore((state) => state.searchFiles);
+ const debouncedQuery = useDebouncedValue(searchQuery, 180);
+ const showHidden = useDirectoryShowHidden();
+ const showGitignored = useFilesViewShowGitignored();
+ const [files, setFiles] = React.useState([]);
+ const [agents, setAgents] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
+ const [marqueeWidth, setMarqueeWidth] = React.useState(360);
+ const [overflowMap, setOverflowMap] = React.useState>({});
+ const [marqueeDurations, setMarqueeDurations] = React.useState>({});
+ const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
+ const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
+ const containerRef = React.useRef(null);
+ const ignoreTabClickRef = React.useRef(false);
+ const normalizedSearchQuery = (searchQuery ?? '').trim();
+ const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
+
+ const recentFiles = React.useMemo(() => {
+ if (!projectRoot || !projectTabs) {
+ return [] as FileInfo[];
+ }
+
+ const ordered = [
+ projectTabs.selectedPath,
+ ...projectTabs.openPaths.slice().reverse(),
+ ].filter((value): value is string => typeof value === 'string' && value.length > 0);
+
+ const seen = new Set();
+ const queryLower = normalizedSearchQuery.toLowerCase();
+ const mapped = ordered
+ .filter((filePath) => {
+ if (seen.has(filePath)) return false;
+ seen.add(filePath);
+ const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
+ if (!queryLower) return true;
+ return relative.toLowerCase().includes(queryLower);
+ })
+ .slice(0, 6)
+ .map((filePath) => {
+ const normalizedPath = filePath.replace(/\\/g, '/');
+ const name = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath;
+ const relativePath = normalizedPath.startsWith(`${projectRoot}/`)
+ ? normalizedPath.slice(projectRoot.length + 1)
+ : normalizedPath;
+ return {
+ name,
+ path: normalizedPath,
+ relativePath,
+ extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
+ } satisfies FileInfo;
+ });
+
+ return mapped;
+ }, [normalizedSearchQuery, projectRoot, projectTabs]);
+
+ React.useEffect(() => {
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (!target || !containerRef.current) {
+ return;
+ }
+ if (containerRef.current.contains(target)) {
+ return;
+ }
+ onClose();
+ };
+
+ document.addEventListener('pointerdown', handlePointerDown, true);
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown, true);
+ };
+ }, [onClose]);
+
+ React.useEffect(() => {
+ if (!currentDirectory) {
+ setFiles([]);
+ setLoading(false);
+ return;
+ }
+
+ const normalizedQuery = (debouncedQuery ?? '').trim();
+ const normalizedQueryLower = normalizedQuery
+ .replace(/^\.\//, '')
+ .replace(/^\/+/, '')
+ .toLowerCase();
+
+ if (!normalizedQueryLower) {
+ setFiles([]);
+ setLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+
+ searchFiles(currentDirectory, normalizedQueryLower, 80, {
+ includeHidden: showHidden,
+ respectGitignore: !showGitignored,
+ type: 'file',
+ })
+ .then((hits) => {
+ if (cancelled) {
+ return;
+ }
+
+ const recentSet = new Set(recentFiles.map((file) => file.path));
+ setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setFiles([]);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
+
+ React.useEffect(() => {
+ const visibleAgents = getVisibleAgents();
+ const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
+ const filtered = visibleAgents
+ .filter((agent) => agent.mode && agent.mode !== 'primary')
+ .filter((agent) => {
+ if (!normalizedQuery) return true;
+ const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
+ return haystack.includes(normalizedQuery);
+ })
+ .map((agent) => ({
+ name: agent.name,
+ description: agent.description,
+ mode: agent.mode,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ setAgents(filtered);
+ }, [getVisibleAgents, searchQuery]);
+
+ React.useEffect(() => {
+ setSelectedIndex(0);
+ setOverflowMap({});
+ setMarqueeDurations({});
+ }, [files, recentFiles.length, visibleAgents.length]);
+
+ React.useEffect(() => {
+ itemRefs.current[selectedIndex]?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest'
+ });
+ }, [selectedIndex]);
+
+ React.useEffect(() => {
+ let frameId: number | null = null;
+
+ const updateOverflow = () => {
+ if (frameId !== null) {
+ cancelAnimationFrame(frameId);
+ }
+ frameId = requestAnimationFrame(() => {
+ const next: Record = {};
+ const durations: Record = {};
+ labelRefs.current.forEach((node, index) => {
+ if (!node) {
+ return;
+ }
+ const measureNode = measureRefs.current[index];
+ const fullWidth = measureNode?.offsetWidth ?? node.scrollWidth;
+ const overflowPx = Math.max(0, fullWidth - node.clientWidth);
+ const isOverflowing = overflowPx > 8;
+ next[index] = isOverflowing;
+ if (isOverflowing) {
+ const duration = Math.max(0.6, overflowPx / 110);
+ durations[index] = duration;
+ }
+ });
+ setOverflowMap(next);
+ setMarqueeDurations(durations);
+ });
+ };
+
+ updateOverflow();
+ window.addEventListener('resize', updateOverflow);
+
+ return () => {
+ if (frameId !== null) {
+ cancelAnimationFrame(frameId);
+ }
+ window.removeEventListener('resize', updateOverflow);
+ };
+ }, [files]);
+
+ React.useEffect(() => {
+ const labelNode = labelRefs.current[selectedIndex];
+ if (!labelNode) {
+ return;
+ }
+
+ const updateWidth = () => {
+ const width = labelNode.clientWidth;
+ if (width > 0) {
+ setMarqueeWidth(width);
+ }
+ };
+
+ updateWidth();
+
+ if (typeof ResizeObserver === 'undefined') {
+ return;
+ }
+
+ const observer = new ResizeObserver(updateWidth);
+ observer.observe(labelNode);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [selectedIndex]);
+
+ const handleFileSelect = React.useCallback((file: FileInfo) => {
+ onFileSelect(file);
+ }, [onFileSelect]);
+
+ const handleAgentPick = React.useCallback((agentName: string) => {
+ onAgentSelect?.(agentName);
+ }, [onAgentSelect]);
+
+ React.useImperativeHandle(ref, () => ({
+ handleKeyDown: (key: string) => {
+ if (key === 'Escape') {
+ onClose();
+ return;
+ }
+
+ const total = visibleAgents.length + recentFiles.length + files.length;
+ if (total === 0) {
+ return;
+ }
+
+ if (key === 'ArrowDown') {
+ setSelectedIndex((prev) => (prev + 1) % total);
+ return;
+ }
+
+ if (key === 'ArrowUp') {
+ setSelectedIndex((prev) => (prev - 1 + total) % total);
+ return;
+ }
+
+ if (key === 'Enter' || key === 'Tab') {
+ const safeIndex = ((selectedIndex % total) + total) % total;
+ if (safeIndex < visibleAgents.length) {
+ const agent = visibleAgents[safeIndex];
+ if (agent) {
+ handleAgentPick(agent.name);
+ }
+ return;
+ }
+ const fileIndex = safeIndex - visibleAgents.length;
+ const selectedFile = fileIndex < recentFiles.length
+ ? recentFiles[fileIndex]
+ : files[fileIndex - recentFiles.length];
+ if (selectedFile) {
+ handleFileSelect(selectedFile);
+ }
+ }
+ }
+ }), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
+
+ const getFileIcon = (file: FileInfo) => {
+ const ext = file.extension?.toLowerCase();
+ switch (ext) {
+ case 'ts':
+ case 'tsx':
+ case 'js':
+ case 'jsx':
+ return ;
+ case 'json':
+ return ;
+ case 'md':
+ case 'mdx':
+ return ;
+ case 'png':
+ case 'jpg':
+ case 'jpeg':
+ case 'gif':
+ case 'svg':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+ {showTabs ? (
+
+
+ {([
+ { id: 'commands' as const, label: 'Commands' },
+ { id: 'agents' as const, label: 'Agents' },
+ { id: 'files' as const, label: 'Files' },
+ ]).map((tab) => (
+ {
+ if (event.pointerType !== 'touch') {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ ignoreTabClickRef.current = true;
+ onTabSelect?.(tab.id);
+ }}
+ onClick={() => {
+ if (ignoreTabClickRef.current) {
+ ignoreTabClickRef.current = false;
+ return;
+ }
+ onTabSelect?.(tab.id);
+ }}
+ >
+ {tab.label}
+
+ ))}
+
+
+ ) : null}
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {visibleAgents.map((agent, index) => {
+ const isSelected = selectedIndex === index;
+ return (
+
{ itemRefs.current[index] = el; }}
+ className={cn(
+ 'flex items-start gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg',
+ isSelected && 'bg-interactive-selection',
+ )}
+ onClick={() => handleAgentPick(agent.name)}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
+
+
@{agent.name}
+ {agent.description ? (
+
{agent.description}
+ ) : null}
+
+
+ );
+ })}
+ {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
+
+ Type to search more agents
+
+ )}
+ {visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
+
+ )}
+ {recentFiles.map((file, index) => {
+ const rowIndex = visibleAgents.length + index;
+ const relativePath = file.relativePath || file.name;
+ const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
+ const isSelected = selectedIndex === rowIndex;
+ const isOverflowing = overflowMap[rowIndex] ?? false;
+ const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6;
+
+ return (
+
{ itemRefs.current[rowIndex] = el; }}
+ className={cn(
+ "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
+ isSelected && "bg-interactive-selection"
+ )}
+ onClick={() => handleFileSelect(file)}
+ onMouseEnter={() => setSelectedIndex(rowIndex)}
+ >
+ {getFileIcon(file)}
+ { labelRefs.current[rowIndex] = el; }}
+ className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
+ style={isSelected ? {
+ ['--file-mention-marquee-width' as string]: `${marqueeWidth}px`,
+ ['--file-mention-marquee-duration' as string]: `${marqueeDuration}s`
+ } : undefined}
+ aria-label={relativePath}
+ >
+ { measureRefs.current[rowIndex] = el; }}
+ className="absolute invisible whitespace-nowrap pointer-events-none"
+ aria-hidden
+ >
+ {relativePath}
+
+ {isOverflowing && isSelected ? (
+
+ {relativePath}
+
+ ) : (
+
+ {displayPath}
+
+ )}
+
+
+ );
+ })}
+ {recentFiles.length > 0 && files.length > 0 && (
+
+ )}
+ {files.map((file, index) => {
+ const rowIndex = visibleAgents.length + recentFiles.length + index;
+ const relativePath = file.relativePath || file.name;
+ const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
+ const isSelected = selectedIndex === rowIndex;
+ const isOverflowing = overflowMap[rowIndex] ?? false;
+ const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6;
+
+ const item = (
+
{ itemRefs.current[rowIndex] = el; }}
+ className={cn(
+ "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
+ isSelected && "bg-interactive-selection"
+ )}
+ onClick={() => handleFileSelect(file)}
+ onMouseEnter={() => setSelectedIndex(rowIndex)}
+ >
+ {getFileIcon(file)}
+ { labelRefs.current[rowIndex] = el; }}
+ className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
+ style={isSelected ? {
+ ['--file-mention-marquee-width' as string]: `${marqueeWidth}px`,
+ ['--file-mention-marquee-duration' as string]: `${marqueeDuration}s`
+ } : undefined}
+ aria-label={relativePath}
+ >
+ { measureRefs.current[rowIndex] = el; }}
+ className="absolute invisible whitespace-nowrap pointer-events-none"
+ aria-hidden
+ >
+ {relativePath}
+
+ {isOverflowing && isSelected ? (
+
+ {relativePath}
+
+ ) : (
+
+ {displayPath}
+
+ )}
+
+
+ );
+
+ return (
+
+ {item}
+
+ );
+ })}
+ {files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
+
+ No matches found
+
+ )}
+
+ )}
+
+
+ ↑↓ navigate • Enter select • Esc close
+
+
+ );
+});
diff --git a/ui/src/components/chat/MarkdownRenderer.tsx b/ui/src/components/chat/MarkdownRenderer.tsx
new file mode 100644
index 0000000..cbc624a
--- /dev/null
+++ b/ui/src/components/chat/MarkdownRenderer.tsx
@@ -0,0 +1,1447 @@
+import React from 'react';
+import { Streamdown } from 'streamdown';
+import { createCodePlugin } from '@streamdown/code';
+import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
+import 'streamdown/styles.css';
+import { FadeInOnReveal } from './message/FadeInOnReveal';
+import type { Part } from '@opencode-ai/sdk/v2';
+import { cn } from '@/lib/utils';
+import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
+import { toast } from '@/components/ui';
+import { copyTextToClipboard } from '@/lib/clipboard';
+
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
+import { getDefaultTheme } from '@/lib/theme/themes';
+import type { ToolPopupContent } from './message/types';
+import { useUIStore } from '@/stores/useUIStore';
+import { useDeviceInfo } from '@/lib/device';
+import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import type { EditorAPI } from '@/lib/api/types';
+
+const withStableStringId = (value: T, id: string): T => {
+ const existingPrimitive = (value as Record)[Symbol.toPrimitive];
+ if (typeof existingPrimitive === 'function') {
+ try {
+ if ((existingPrimitive as () => unknown)() === id) {
+ return value;
+ }
+ } catch {
+ // Ignore and attempt to define below.
+ }
+ }
+
+ try {
+ Object.defineProperty(value, 'toString', {
+ value: () => id,
+ enumerable: false,
+ configurable: true,
+ });
+ } catch {
+ // Ignore if non-configurable or frozen.
+ }
+
+ try {
+ Object.defineProperty(value, Symbol.toPrimitive, {
+ value: () => id,
+ enumerable: false,
+ configurable: true,
+ });
+ } catch {
+ // Ignore if non-configurable or frozen.
+ }
+
+ return value;
+};
+
+const useMarkdownShikiThemes = (): readonly [string | object, string | object] => {
+ const themeSystem = useOptionalThemeSystem();
+
+ const isVSCode = isVSCodeRuntime() && typeof window !== 'undefined';
+
+ const fallbackLight = getDefaultTheme(false);
+ const fallbackDark = getDefaultTheme(true);
+
+ const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id;
+ const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id;
+
+ const lightTheme =
+ themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ??
+ fallbackLight;
+ const darkTheme =
+ themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
+ fallbackDark;
+
+ const fallbackThemes = React.useMemo(
+ () => getStreamdownThemePair(lightTheme, darkTheme),
+ [darkTheme, lightTheme],
+ );
+
+ const getThemes = React.useCallback((): readonly [string | object, string | object] => {
+ if (!isVSCode) {
+ return fallbackThemes;
+ }
+
+ const provided = window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__;
+ if (provided?.light && provided?.dark) {
+ const light = withStableStringId(
+ { ...(provided.light as Record) },
+ `vscode-shiki-light:${String((provided.light as { name?: unknown })?.name ?? 'theme')}`,
+ );
+ const dark = withStableStringId(
+ { ...(provided.dark as Record) },
+ `vscode-shiki-dark:${String((provided.dark as { name?: unknown })?.name ?? 'theme')}`,
+ );
+ return [light, dark] as const;
+ }
+
+ return fallbackThemes;
+ }, [fallbackThemes, isVSCode]);
+
+ const [themes, setThemes] = React.useState(getThemes);
+
+ React.useEffect(() => {
+ if (!isVSCode) {
+ return;
+ }
+
+ setThemes(getThemes());
+ }, [getThemes, isVSCode]);
+
+ React.useEffect(() => {
+ if (!isVSCode) return;
+
+ const handler = (event: Event) => {
+ // Rely on the canonical `window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__` that the webview updates
+ // before dispatching this event, so we always apply stable cache keys and avoid stale token reuse.
+ void event;
+ setThemes(getThemes());
+ };
+
+ window.addEventListener('openchamber:vscode-shiki-themes', handler as EventListener);
+ return () => window.removeEventListener('openchamber:vscode-shiki-themes', handler as EventListener);
+ }, [getThemes, isVSCode]);
+
+ return isVSCode ? themes : fallbackThemes;
+};
+
+type StreamdownCodeThemes = NonNullable[0]>['themes'];
+
+const useStreamdownPlugins = (shikiThemes: readonly [string | object, string | object]) => {
+ return React.useMemo(
+ () => ({
+ code: createCodePlugin({
+ // Streamdown code plugin runtime accepts theme objects, but current type only models bundled theme names.
+ themes: shikiThemes as unknown as StreamdownCodeThemes,
+ }),
+ }),
+ [shikiThemes],
+ );
+};
+
+const useCurrentMermaidTheme = () => {
+ const themeSystem = useOptionalThemeSystem();
+ const fallbackLight = getDefaultTheme(false);
+ const fallbackDark = getDefaultTheme(true);
+
+ return themeSystem?.currentTheme
+ ?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? fallbackDark
+ : fallbackLight);
+};
+
+// Table utility functions
+const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
+ const headers: string[] = [];
+ const rows: string[][] = [];
+
+ const thead = tableEl.querySelector('thead');
+ if (thead) {
+ const headerCells = thead.querySelectorAll('th');
+ headerCells.forEach(cell => headers.push(cell.innerText.trim()));
+ }
+
+ const tbody = tableEl.querySelector('tbody');
+ if (tbody) {
+ const rowEls = tbody.querySelectorAll('tr');
+ rowEls.forEach(row => {
+ const cells = row.querySelectorAll('td');
+ const rowData: string[] = [];
+ cells.forEach(cell => rowData.push(cell.innerText.trim()));
+ rows.push(rowData);
+ });
+ }
+
+ return { headers, rows };
+};
+
+const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
+ const escapeCell = (cell: string): string => {
+ if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
+ return `"${cell.replace(/"/g, '""')}"`;
+ }
+ return cell;
+ };
+
+ const lines: string[] = [];
+ if (headers.length > 0) {
+ lines.push(headers.map(escapeCell).join(','));
+ }
+ rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
+ return lines.join('\n');
+};
+
+const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
+ const escapeCell = (cell: string): string => {
+ return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
+ };
+
+ const lines: string[] = [];
+ if (headers.length > 0) {
+ lines.push(headers.map(escapeCell).join('\t'));
+ }
+ rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
+ return lines.join('\n');
+};
+
+const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
+ if (headers.length === 0) return '';
+
+ const escapeCell = (cell: string): string => {
+ return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
+ };
+
+ const lines: string[] = [];
+ lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
+ lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
+ rows.forEach(row => {
+ const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
+ lines.push(`| ${paddedRow.join(' | ')} |`);
+ });
+ return lines.join('\n');
+};
+
+const downloadFile = (filename: string, content: string, mimeType: string) => {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+};
+
+// Table copy button with dropdown
+const TableCopyButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => {
+ const [copied, setCopied] = React.useState(false);
+ const [showMenu, setShowMenu] = React.useState(false);
+ const menuRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setShowMenu(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleCopy = async (format: 'csv' | 'tsv') => {
+ const tableEl = tableRef.current?.querySelector('table');
+ if (!tableEl) return;
+
+ const data = extractTableData(tableEl);
+ const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
+
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ 'text/plain': new Blob([content], { type: 'text/plain' }),
+ 'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
+ }),
+ ]);
+ setCopied(true);
+ setShowMenu(false);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ const fallbackResult = await copyTextToClipboard(content);
+ if (fallbackResult.ok) {
+ setCopied(true);
+ setShowMenu(false);
+ setTimeout(() => setCopied(false), 2000);
+ return;
+ }
+ console.error('Failed to copy table:', err);
+ }
+ };
+
+ return (
+
+
setShowMenu(!showMenu)}
+ className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
+ title="Copy table"
+ >
+ {copied ? : }
+
+ {showMenu && (
+
+ handleCopy('csv')}
+ >
+ CSV
+
+ handleCopy('tsv')}
+ >
+ TSV
+
+
+ )}
+
+ );
+};
+
+// Table download button with dropdown
+const TableDownloadButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => {
+ const [showMenu, setShowMenu] = React.useState(false);
+ const menuRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setShowMenu(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleDownload = (format: 'csv' | 'markdown') => {
+ const tableEl = tableRef.current?.querySelector('table');
+ if (!tableEl) return;
+
+ const data = extractTableData(tableEl);
+ const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
+ const filename = format === 'csv' ? 'table.csv' : 'table.md';
+ const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
+ downloadFile(filename, content, mimeType);
+ setShowMenu(false);
+ toast.success(`Table downloaded as ${format.toUpperCase()}`);
+ };
+
+ return (
+
+
setShowMenu(!showMenu)}
+ className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
+ title="Download table"
+ >
+
+
+ {showMenu && (
+
+ handleDownload('csv')}
+ >
+ CSV
+
+ handleDownload('markdown')}
+ >
+ Markdown
+
+
+ )}
+
+ );
+};
+
+// Table wrapper with custom controls
+const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
+ const tableRef = React.useRef(null);
+
+ return (
+
+ );
+};
+
+type CodeBlockWrapperProps = React.HTMLAttributes & {
+ children?: React.ReactNode;
+};
+
+const getMermaidInfo = (children: React.ReactNode): { isMermaid: boolean; source: string } => {
+ if (!React.isValidElement(children)) return { isMermaid: false, source: '' };
+ const props = children.props as Record | undefined;
+ const className = typeof props?.className === 'string' ? props.className : '';
+ if (!className.includes('language-mermaid')) return { isMermaid: false, source: '' };
+ // Extract raw mermaid source from the code element's children
+ const codeChildren = props?.children;
+ let source = '';
+ if (typeof codeChildren === 'string') {
+ source = codeChildren;
+ } else if (React.isValidElement(codeChildren)) {
+ const innerProps = codeChildren.props as Record | undefined;
+ if (typeof innerProps?.children === 'string') source = innerProps.children;
+ }
+ return { isMermaid: true, source };
+};
+
+const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
+ const currentTheme = useCurrentMermaidTheme();
+ const { isMobile } = useDeviceInfo();
+ const [copied, setCopied] = React.useState(false);
+ const [downloaded, setDownloaded] = React.useState(false);
+
+ const svg = React.useMemo(() => {
+ if (mode !== 'svg') return '';
+ try {
+ return renderMermaidSVG(source, {
+ bg: currentTheme.colors.surface.elevated,
+ fg: currentTheme.colors.surface.foreground,
+ line: currentTheme.colors.interactive.border,
+ accent: currentTheme.colors.primary.base,
+ muted: currentTheme.colors.surface.mutedForeground,
+ surface: currentTheme.colors.surface.muted,
+ border: currentTheme.colors.interactive.border,
+ transparent: true,
+ font: 'IBM Plex Sans, sans-serif',
+ });
+ } catch {
+ return '';
+ }
+ }, [currentTheme, mode, source]);
+
+ const ascii = React.useMemo(() => {
+ if (mode !== 'ascii') return '';
+ try {
+ return renderMermaidASCII(source);
+ } catch {
+ return '';
+ }
+ }, [mode, source]);
+
+ const copyVisibilityClass = isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
+
+ const handleCopyAscii = async (asciiText: string) => {
+ if (!asciiText) return;
+ const result = await copyTextToClipboard(asciiText);
+ if (result.ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ const handleCopyMermaidSource = async () => {
+ if (!source) return;
+ const result = await copyTextToClipboard(source);
+ if (result.ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ const handleDownloadSvg = () => {
+ if (!svg) return;
+ try {
+ const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `diagram-${Date.now()}.svg`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ setDownloaded(true);
+ setTimeout(() => setDownloaded(false), 2000);
+ } catch {
+ toast.error('Failed to download diagram');
+ }
+ };
+
+ if (mode === 'ascii') {
+ const asciiText = ascii || source;
+
+ return (
+
+
+
+ handleCopyAscii(asciiText)}
+ className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
+ title="Copy"
+ >
+ {copied ? : }
+
+
+
+ );
+ }
+
+ if (!svg) {
+ return (
+
+
+
+ handleCopyAscii(source)}
+ className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
+ title="Copy"
+ >
+ {copied ? : }
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {copied ? : }
+
+
+ {downloaded ? : }
+
+
+
+ );
+};
+
+const CodeBlockWrapper: React.FC = ({ children, className, style, ...props }) => {
+ const mermaidInfo = getMermaidInfo(children);
+ const mermaidRenderingMode = useUIStore((state) => state.mermaidRenderingMode);
+ const codeChild = React.useMemo(
+ () => (
+ React.isValidElement(children)
+ ? React.cloneElement(children as React.ReactElement>, { 'data-block': true })
+ : children
+ ),
+ [children],
+ );
+
+ const normalizedStyle = React.useMemo(() => {
+ if (!style) return style;
+
+ const next: React.CSSProperties = { ...style };
+
+ const normalizeDeclarationString = (
+ raw: unknown
+ ): { value?: string; vars: Record } => {
+ if (typeof raw !== 'string') return { value: undefined, vars: {} };
+
+ const [valuePart, ...rest] = raw.split(';').map((p) => p.trim()).filter(Boolean);
+ const vars: Record = {};
+ for (const decl of rest) {
+ const idx = decl.indexOf(':');
+ if (idx === -1) continue;
+ const prop = decl.slice(0, idx).trim();
+ const value = decl.slice(idx + 1).trim();
+ if (!prop.startsWith('--') || value.length === 0) continue;
+ vars[prop] = value;
+ }
+ return { value: valuePart, vars };
+ };
+
+ const bg = normalizeDeclarationString((style as React.CSSProperties).backgroundColor);
+ if (bg.value) {
+ next.backgroundColor = bg.value;
+ }
+ for (const [k, v] of Object.entries(bg.vars)) {
+ (next as Record)[k] = v;
+ }
+
+ const fg = normalizeDeclarationString((style as React.CSSProperties).color);
+ if (fg.value) {
+ next.color = fg.value;
+ }
+ for (const [k, v] of Object.entries(fg.vars)) {
+ (next as Record)[k] = v;
+ }
+
+ return next;
+ }, [style]);
+
+ if (mermaidInfo.isMermaid) {
+ return ;
+ }
+
+ return (
+
+ {codeChild}
+
+ );
+};
+
+const streamdownComponents = {
+ pre: CodeBlockWrapper,
+ table: TableWrapper,
+};
+
+const streamdownControls = {
+ code: true,
+ table: false,
+};
+
+type MermaidControlOptions = {
+ download: boolean;
+ copy: boolean;
+ fullscreen: boolean;
+ panZoom: boolean;
+};
+
+const extractMermaidBlocks = (markdown: string): string[] => {
+ const blocks: string[] = [];
+ const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
+ let match: RegExpExecArray | null = regex.exec(markdown);
+
+ while (match) {
+ const block = (match[2] ?? '').replace(/\s+$/, '');
+ blocks.push(block);
+ match = regex.exec(markdown);
+ }
+
+ return blocks;
+};
+
+const stripLeadingFrontmatter = (markdown: string): string => {
+ const frontmatterMatch = markdown.match(
+ /^(?:\uFEFF)?(---|\+\+\+)[^\S\r\n]*\r?\n[\s\S]*?\r?\n\1[^\S\r\n]*(?:\r?\n|$)/,
+ );
+
+ if (!frontmatterMatch) {
+ return markdown;
+ }
+
+ return markdown.slice(frontmatterMatch[0].length);
+};
+
+export type MarkdownVariant = 'assistant' | 'tool';
+
+interface MarkdownRendererProps {
+ content: string;
+ part?: Part;
+ messageId: string;
+ isAnimated?: boolean;
+ className?: string;
+ isStreaming?: boolean;
+ variant?: MarkdownVariant;
+ onShowPopup?: (content: ToolPopupContent) => void;
+}
+
+const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]';
+const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
+
+type ParsedFileReference = {
+ path: string;
+ line?: number;
+ column?: number;
+};
+
+const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
+const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
+const KNOWN_FILE_BASENAMES = new Set([
+ 'dockerfile',
+ 'makefile',
+ 'readme',
+ 'license',
+ '.env',
+ '.gitignore',
+ '.npmrc',
+]);
+const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
+ .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('|');
+
+const normalizePath = (value: string): string => {
+ const source = (value || '').trim();
+ if (!source) {
+ return '';
+ }
+
+ const withSlashes = source.replace(/\\/g, '/');
+ const hadUncPrefix = withSlashes.startsWith('//');
+
+ let normalized = withSlashes.replace(/\/+/g, '/');
+ if (hadUncPrefix && !normalized.startsWith('//')) {
+ normalized = `/${normalized}`;
+ }
+
+ const isUnixRoot = normalized === '/';
+ const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
+ if (!isUnixRoot && !isWindowsDriveRoot) {
+ normalized = normalized.replace(/\/+$/, '');
+ }
+
+ return normalized;
+};
+
+const isAbsolutePath = (value: string): boolean => {
+ return value.startsWith('/')
+ || WINDOWS_DRIVE_PATH_PATTERN.test(value)
+ || WINDOWS_UNC_PATH_PATTERN.test(value)
+ || value.startsWith('//');
+};
+
+const toAbsolutePath = (basePath: string, targetPath: string): string => {
+ const normalizedTarget = normalizePath(targetPath);
+ if (!normalizedTarget) {
+ return normalizePath(basePath);
+ }
+
+ if (isAbsolutePath(normalizedTarget)) {
+ return normalizedTarget;
+ }
+
+ const normalizedBase = normalizePath(basePath);
+ if (!normalizedBase) {
+ return normalizedTarget;
+ }
+
+ const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase);
+ const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : '';
+ const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase;
+
+ const stack = baseRemainder.split('/').filter(Boolean);
+ const parts = normalizedTarget.split('/').filter(Boolean);
+ for (const part of parts) {
+ if (part === '.') {
+ continue;
+ }
+ if (part === '..') {
+ if (stack.length > 0) {
+ stack.pop();
+ }
+ continue;
+ }
+ stack.push(part);
+ }
+
+ if (isWindowsDriveBase) {
+ return `${prefix}/${stack.join('/')}`;
+ }
+
+ return `/${stack.join('/')}`;
+};
+
+const trimPathCandidate = (value: string): string => {
+ let next = (value || '').trim();
+ if (!next) {
+ return '';
+ }
+
+ if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
+ next = next.slice(1, -1).trim();
+ }
+
+ next = next.replace(/[.,;!?]+$/g, '');
+
+ if (next.endsWith(')') && !next.includes('(')) {
+ next = next.slice(0, -1);
+ }
+ if (next.endsWith(']') && !next.includes('[')) {
+ next = next.slice(0, -1);
+ }
+
+ return next;
+};
+
+const stripTrailingReference = (value: string): string => {
+ let next = trimPathCandidate(value);
+ if (!next) {
+ return '';
+ }
+
+ const semicolonIndex = next.indexOf(';');
+ if (semicolonIndex >= 0) {
+ next = next.slice(0, semicolonIndex);
+ }
+
+ next = next.replace(/#.*$/, '');
+
+ const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
+ if (extensionSuffixMatch) {
+ next = extensionSuffixMatch[1] ?? next;
+ }
+
+ const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
+ ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
+ : null;
+ if (basenameSuffixMatch) {
+ next = basenameSuffixMatch[1] ?? next;
+ }
+
+ return trimPathCandidate(next);
+};
+
+const parseFileReference = (value: string): ParsedFileReference | null => {
+ const trimmed = trimPathCandidate(value);
+ if (!trimmed) {
+ return null;
+ }
+
+ const semicolonIndex = trimmed.indexOf(';');
+ const withoutSemicolonSuffix = semicolonIndex >= 0
+ ? trimPathCandidate(trimmed.slice(0, semicolonIndex))
+ : trimmed;
+ if (!withoutSemicolonSuffix) {
+ return null;
+ }
+
+ const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
+ if (hashMatch) {
+ const path = stripTrailingReference(hashMatch[1] ?? '');
+ const line = Number.parseInt(hashMatch[2] ?? '', 10);
+ const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
+ if (!path || !Number.isFinite(line)) {
+ return null;
+ }
+
+ return {
+ path,
+ line,
+ column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
+ };
+ }
+
+ const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
+ if (colonMatch) {
+ const path = stripTrailingReference(colonMatch[1] ?? '');
+ const line = Number.parseInt(colonMatch[2] ?? '', 10);
+ const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
+ if (!path || !Number.isFinite(line)) {
+ return null;
+ }
+
+ return {
+ path,
+ line,
+ column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
+ };
+ }
+
+ const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
+ if (!pathOnly) {
+ return null;
+ }
+
+ return { path: pathOnly };
+};
+
+const hasFileExtension = (path: string): boolean => {
+ const base = path.split('/').filter(Boolean).pop() ?? '';
+ if (!base || base.endsWith('.')) {
+ return false;
+ }
+ return /\.[A-Za-z0-9_-]{1,16}$/.test(base);
+};
+
+const isLikelyFilePathValue = (path: string): boolean => {
+ if (!path || path.startsWith('--') || path.includes('://')) {
+ return false;
+ }
+
+ if (/[<>]/.test(path) || /\s{2,}/.test(path)) {
+ return false;
+ }
+
+ const normalized = normalizePath(path);
+ const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized;
+ if (!baseName || baseName === '.' || baseName === '..') {
+ return false;
+ }
+
+ const base = baseName.toLowerCase();
+ if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) {
+ return true;
+ }
+
+ return hasFileExtension(normalized);
+};
+
+const isLikelyFilePath = (value: string): boolean => {
+ const parsed = parseFileReference(value);
+ if (!parsed) {
+ return false;
+ }
+ return isLikelyFilePathValue(parsed.path);
+};
+
+const extractPathCandidateFromElement = (element: HTMLElement): string => {
+ if (element.tagName.toLowerCase() === 'a') {
+ const href = element.getAttribute('href')?.trim();
+ if (href && isLikelyFilePath(href)) {
+ return href;
+ }
+ }
+
+ return (element.textContent || '').trim();
+};
+
+const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
+ const parsed = parseFileReference(rawValue);
+ if (!parsed || !isLikelyFilePathValue(parsed.path)) {
+ return null;
+ }
+
+ const resolvedPath = isAbsolutePath(parsed.path)
+ ? normalizePath(parsed.path)
+ : toAbsolutePath(effectiveDirectory, parsed.path);
+ if (!resolvedPath) {
+ return null;
+ }
+
+ return {
+ ...parsed,
+ resolvedPath,
+ };
+};
+
+const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
+ const normalizedDirectory = normalizePath(effectiveDirectory);
+ if (normalizedDirectory) {
+ return normalizedDirectory;
+ }
+
+ const normalizedPath = normalizePath(resolvedPath);
+ const parent = normalizedPath.replace(/\/[^/]*$/, '');
+ return parent || normalizedPath;
+};
+
+const useFileReferenceInteractions = ({
+ containerRef,
+ effectiveDirectory,
+ readFile,
+ editor,
+ preferRuntimeEditor,
+}: {
+ containerRef: React.RefObject;
+ effectiveDirectory: string;
+ readFile?: (path: string) => Promise<{ content: string; path: string }>;
+ editor?: EditorAPI;
+ preferRuntimeEditor?: boolean;
+}) => {
+ const validationCacheRef = React.useRef>(new Map());
+ const inFlightValidationsRef = React.useRef>>(new Map());
+ const annotationPassRef = React.useRef(0);
+ const annotationDebounceRef = React.useRef(null);
+ const isValidationSweepRunningRef = React.useRef(false);
+
+ React.useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ let disposed = false;
+
+ const isPathResolvable = async (resolvedPath: string): Promise => {
+ const cache = validationCacheRef.current;
+ if (cache.has(resolvedPath)) {
+ return cache.get(resolvedPath) === true;
+ }
+
+ const inFlight = inFlightValidationsRef.current.get(resolvedPath);
+ if (inFlight) {
+ return inFlight;
+ }
+
+ const checkPromise = (async () => {
+ try {
+ if (!readFile) {
+ return false;
+ }
+ await readFile(resolvedPath);
+ cache.set(resolvedPath, true);
+ return true;
+ } catch {
+ cache.set(resolvedPath, false);
+ return false;
+ } finally {
+ inFlightValidationsRef.current.delete(resolvedPath);
+ }
+ })();
+
+ inFlightValidationsRef.current.set(resolvedPath, checkPromise);
+ return checkPromise;
+ };
+
+ const clearCandidateLinkAttrs = (candidate: HTMLElement) => {
+ candidate.removeAttribute('data-openchamber-file-link');
+ candidate.removeAttribute('data-openchamber-file-ref');
+ candidate.removeAttribute('data-openchamber-file-path');
+ if (candidate.getAttribute('title') === 'Open file') {
+ candidate.removeAttribute('title');
+ }
+ if (candidate.tagName.toLowerCase() !== 'a') {
+ candidate.removeAttribute('role');
+ candidate.removeAttribute('tabindex');
+ }
+ };
+
+ const applyCandidateLinkAttrs = (candidate: HTMLElement, rawCandidate: string, resolvedPath: string) => {
+ candidate.setAttribute('data-openchamber-file-link', 'true');
+ candidate.setAttribute('data-openchamber-file-ref', rawCandidate);
+ candidate.setAttribute('data-openchamber-file-path', resolvedPath);
+ candidate.setAttribute('title', 'Open file');
+ if (candidate.tagName.toLowerCase() !== 'a') {
+ candidate.setAttribute('role', 'button');
+ candidate.setAttribute('tabindex', '0');
+ }
+ };
+
+ const runValidationSweep = async (paths: string[], expectedPassID: number) => {
+ if (isValidationSweepRunningRef.current || paths.length === 0) {
+ return;
+ }
+
+ isValidationSweepRunningRef.current = true;
+ const maxConcurrent = 3;
+ let cursor = 0;
+
+ const worker = async () => {
+ while (!disposed && cursor < paths.length) {
+ const index = cursor;
+ cursor += 1;
+ const pathToCheck = paths[index];
+ if (!pathToCheck) {
+ continue;
+ }
+ await isPathResolvable(pathToCheck);
+ }
+ };
+
+ try {
+ await Promise.all(Array.from({ length: Math.min(maxConcurrent, paths.length) }, () => worker()));
+ } finally {
+ isValidationSweepRunningRef.current = false;
+ }
+
+ if (!disposed && annotationPassRef.current === expectedPassID) {
+ void annotateFileLinks();
+ }
+ };
+
+ const annotateFileLinks = async () => {
+ const passID = annotationPassRef.current + 1;
+ annotationPassRef.current = passID;
+ const candidates = container.querySelectorAll('[data-streamdown="inline-code"], a');
+ const unresolvedPaths = new Set();
+
+ for (const candidate of Array.from(candidates)) {
+ const rawCandidate = extractPathCandidateFromElement(candidate);
+ const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
+ if (!resolved) {
+ clearCandidateLinkAttrs(candidate);
+ continue;
+ }
+
+ if (annotationPassRef.current !== passID) {
+ return;
+ }
+
+ const cachedResult = validationCacheRef.current.get(resolved.resolvedPath);
+ if (cachedResult === true) {
+ applyCandidateLinkAttrs(candidate, rawCandidate, resolved.resolvedPath);
+ continue;
+ }
+
+ clearCandidateLinkAttrs(candidate);
+ if (cachedResult !== false) {
+ unresolvedPaths.add(resolved.resolvedPath);
+ }
+ }
+
+ if (unresolvedPaths.size > 0) {
+ void runValidationSweep(Array.from(unresolvedPaths), passID);
+ }
+ };
+
+ const openFileReference = async (sourceElement: HTMLElement): Promise => {
+ const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
+ const resolved = getResolvedReference(raw, effectiveDirectory);
+ if (!resolved) {
+ return false;
+ }
+
+ const isResolvable = await isPathResolvable(resolved.resolvedPath);
+ if (!isResolvable) {
+ sourceElement.removeAttribute('data-openchamber-file-link');
+ sourceElement.removeAttribute('data-openchamber-file-ref');
+ sourceElement.removeAttribute('data-openchamber-file-path');
+ if (sourceElement.getAttribute('title') === 'Open file') {
+ sourceElement.removeAttribute('title');
+ }
+ return false;
+ }
+
+ const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
+ if (preferRuntimeEditor && editor) {
+ await editor.openFile(
+ resolved.resolvedPath,
+ Number.isFinite(resolved.line ?? Number.NaN)
+ ? Math.max(1, Math.trunc(resolved.line as number))
+ : undefined,
+ Number.isFinite(resolved.column ?? Number.NaN)
+ ? Math.max(1, Math.trunc(resolved.column as number))
+ : undefined,
+ );
+ return true;
+ }
+
+ const uiStore = useUIStore.getState();
+ if (Number.isFinite(resolved.line ?? Number.NaN)) {
+ uiStore.openContextFileAtLine(
+ contextDirectory,
+ resolved.resolvedPath,
+ Math.max(1, Math.trunc(resolved.line as number)),
+ Number.isFinite(resolved.column ?? Number.NaN)
+ ? Math.max(1, Math.trunc(resolved.column as number))
+ : 1,
+ );
+ } else {
+ uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
+ }
+ return true;
+ };
+
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) {
+ return;
+ }
+
+ const fileRefElement = target.closest(FILE_LINK_SELECTOR);
+ if (!(fileRefElement instanceof HTMLElement)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ void openFileReference(fileRefElement);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return;
+ }
+
+ const target = event.target;
+ if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ void openFileReference(target);
+ };
+
+ void annotateFileLinks();
+
+ const observer = new MutationObserver(() => {
+ if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
+ window.clearTimeout(annotationDebounceRef.current);
+ }
+ if (typeof window === 'undefined') {
+ void annotateFileLinks();
+ return;
+ }
+ annotationDebounceRef.current = window.setTimeout(() => {
+ annotationDebounceRef.current = null;
+ void annotateFileLinks();
+ }, 120);
+ });
+ observer.observe(container, { childList: true, subtree: true, characterData: true });
+
+ container.addEventListener('click', handleClick);
+ container.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ disposed = true;
+ annotationPassRef.current += 1;
+ if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
+ window.clearTimeout(annotationDebounceRef.current);
+ }
+ annotationDebounceRef.current = null;
+ observer.disconnect();
+ container.removeEventListener('click', handleClick);
+ container.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
+};
+
+const useMermaidInlineInteractions = ({
+ containerRef,
+ mermaidBlocks,
+ onShowPopup,
+ allowWheelZoom,
+}: {
+ containerRef: React.RefObject;
+ mermaidBlocks: string[];
+ onShowPopup?: (content: ToolPopupContent) => void;
+ allowWheelZoom?: boolean;
+}) => {
+ React.useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ const handleMermaidClick = (event: MouseEvent) => {
+ if (!onShowPopup) {
+ return;
+ }
+
+ const target = event.target;
+ if (!(target instanceof Element)) {
+ return;
+ }
+
+ if (target.closest('button, a, [role="button"]')) {
+ return;
+ }
+
+ const block = target.closest(MERMAID_BLOCK_SELECTOR);
+ if (!block) {
+ return;
+ }
+
+ const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
+ const blockIndex = renderedBlocks.indexOf(block);
+ if (blockIndex < 0) {
+ return;
+ }
+
+ const source = mermaidBlocks[blockIndex];
+ if (!source || source.trim().length === 0) {
+ return;
+ }
+
+ const filename = `Diagram ${blockIndex + 1}`;
+ onShowPopup({
+ open: true,
+ title: filename,
+ content: '',
+ metadata: {
+ tool: 'mermaid-preview',
+ filename,
+ },
+ mermaid: {
+ url: `data:text/plain;charset=utf-8,${encodeURIComponent(source)}`,
+ source,
+ filename,
+ },
+ });
+ };
+
+ const handleInlineWheel = (event: WheelEvent) => {
+ if (allowWheelZoom) {
+ return;
+ }
+
+ const target = event.target;
+ if (!(target instanceof Element)) {
+ return;
+ }
+
+ const block = target.closest(MERMAID_BLOCK_SELECTOR);
+ if (!block) {
+ return;
+ }
+
+ // Keep regular page scroll while preventing Streamdown inline wheel-zoom handlers.
+ event.stopPropagation();
+ };
+
+ container.addEventListener('click', handleMermaidClick);
+ container.addEventListener('wheel', handleInlineWheel, { capture: true, passive: true });
+
+ return () => {
+ container.removeEventListener('click', handleMermaidClick);
+ container.removeEventListener('wheel', handleInlineWheel, true);
+ };
+ }, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
+};
+
+export const MarkdownRenderer: React.FC = ({
+ content,
+ part,
+ messageId,
+ isAnimated = true,
+ className,
+ isStreaming = false,
+ variant = 'assistant',
+ onShowPopup,
+}) => {
+ const { files, editor, runtime } = useRuntimeAPIs();
+ const streamdownContainerRef = React.useRef(null);
+ const effectiveDirectory = useEffectiveDirectory() ?? '';
+ const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
+ useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
+ useFileReferenceInteractions({
+ containerRef: streamdownContainerRef,
+ effectiveDirectory,
+ readFile: files.readFile,
+ editor,
+ preferRuntimeEditor: runtime.isVSCode,
+ });
+
+ const shikiThemes = useMarkdownShikiThemes();
+ const streamdownPlugins = useStreamdownPlugins(shikiThemes);
+ const currentMermaidTheme = useCurrentMermaidTheme();
+ const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
+
+ const streamdownClassName = variant === 'tool'
+ ? 'streamdown-content streamdown-tool'
+ : 'streamdown-content';
+
+ const markdownContent = (
+
+
+ {content}
+
+
+ );
+
+ if (isAnimated) {
+ return (
+
+ {markdownContent}
+
+ );
+ }
+
+ return markdownContent;
+};
+
+export const SimpleMarkdownRenderer: React.FC<{
+ content: string;
+ className?: string;
+ variant?: MarkdownVariant;
+ disableLinkSafety?: boolean;
+ stripFrontmatter?: boolean;
+ onShowPopup?: (content: ToolPopupContent) => void;
+ mermaidControls?: MermaidControlOptions;
+ allowMermaidWheelZoom?: boolean;
+}> = ({
+ content,
+ className,
+ variant = 'assistant',
+ disableLinkSafety,
+ stripFrontmatter = false,
+ onShowPopup,
+ allowMermaidWheelZoom = false,
+}) => {
+ const { files, editor, runtime } = useRuntimeAPIs();
+ const renderedContent = React.useMemo(
+ () => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
+ [content, stripFrontmatter],
+ );
+ const streamdownContainerRef = React.useRef(null);
+ const effectiveDirectory = useEffectiveDirectory() ?? '';
+ const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
+ useMermaidInlineInteractions({
+ containerRef: streamdownContainerRef,
+ mermaidBlocks,
+ onShowPopup,
+ allowWheelZoom: allowMermaidWheelZoom,
+ });
+ useFileReferenceInteractions({
+ containerRef: streamdownContainerRef,
+ effectiveDirectory,
+ readFile: files.readFile,
+ editor,
+ preferRuntimeEditor: runtime.isVSCode,
+ });
+
+ const shikiThemes = useMarkdownShikiThemes();
+ const streamdownPlugins = useStreamdownPlugins(shikiThemes);
+ const currentMermaidTheme = useCurrentMermaidTheme();
+
+ const streamdownClassName = variant === 'tool'
+ ? 'streamdown-content streamdown-tool'
+ : 'streamdown-content';
+
+ return (
+
+
+ {renderedContent}
+
+
+ );
+};
diff --git a/ui/src/components/chat/MessageList.tsx b/ui/src/components/chat/MessageList.tsx
new file mode 100644
index 0000000..deb48e8
--- /dev/null
+++ b/ui/src/components/chat/MessageList.tsx
@@ -0,0 +1,1075 @@
+import React from 'react';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import { flushSync } from 'react-dom';
+import { elementScroll, observeElementOffset, observeElementRect, Virtualizer } from '@tanstack/react-virtual';
+import { useShallow } from 'zustand/react/shallow';
+import type { ReactVirtualizerOptions, VirtualItem } from '@tanstack/react-virtual';
+
+import ChatMessage from './ChatMessage';
+import { PermissionCard } from './PermissionCard';
+import { QuestionCard } from './QuestionCard';
+import type { PermissionRequest } from '@/types/permission';
+import type { QuestionRequest } from '@/types/question';
+import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
+import { filterSyntheticParts } from '@/lib/messages/synthetic';
+import { detectTurns, type Turn } from './hooks/useTurnGrouping';
+import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic } from './contexts/TurnGroupingContext';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useDeviceInfo } from '@/lib/device';
+import { FadeInDisabledProvider } from './message/FadeInOnReveal';
+
+const MESSAGE_VIRTUALIZE_THRESHOLD = 40;
+const MESSAGE_VIRTUAL_OVERSCAN_MOBILE = 2;
+const MESSAGE_VIRTUAL_OVERSCAN_DESKTOP = 4;
+
+type MessageListVirtualizerOptions = Omit<
+ ReactVirtualizerOptions,
+ 'scrollToFn' | 'observeElementRect' | 'observeElementOffset'
+>
+
+const useMessageListVirtualizer = (
+ options: MessageListVirtualizerOptions,
+): Virtualizer => {
+ const [, forceRender] = React.useReducer(() => ({}), {});
+ const { useFlushSync = true, onChange, ...baseOptions } = options;
+
+ const handleChange = React.useCallback((instance: Virtualizer, sync: boolean) => {
+ if (useFlushSync && sync) {
+ flushSync(forceRender);
+ } else {
+ forceRender();
+ }
+
+ onChange?.(instance, sync);
+ }, [onChange, useFlushSync]);
+
+ const [virtualizer] = React.useState(() => new Virtualizer({
+ ...baseOptions,
+ onChange: handleChange,
+ observeElementRect,
+ observeElementOffset,
+ scrollToFn: elementScroll,
+ }));
+
+ virtualizer.setOptions({
+ ...baseOptions,
+ onChange: handleChange,
+ observeElementRect,
+ observeElementOffset,
+ scrollToFn: elementScroll,
+ });
+
+ React.useLayoutEffect(() => virtualizer._didMount(), [virtualizer]);
+ React.useLayoutEffect(() => virtualizer._willUpdate(), [virtualizer]);
+
+ return virtualizer;
+};
+
+interface ChatMessageEntry {
+ info: Message;
+ parts: Part[];
+}
+
+const USER_SHELL_MARKER = 'The following tool was executed by the user';
+
+const resolveMessageRole = (message: ChatMessageEntry): string | null => {
+ const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
+ return (typeof info.clientRole === 'string' ? info.clientRole : null)
+ ?? (typeof info.role === 'string' ? info.role : null)
+ ?? null;
+};
+
+const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => {
+ if (!message) return false;
+ if (resolveMessageRole(message) !== 'user') return false;
+ return message.parts.some((part) => part?.type === 'subtask');
+};
+
+const getMessageId = (message: ChatMessageEntry | undefined): string | null => {
+ if (!message) return null;
+ const id = (message.info as unknown as { id?: unknown }).id;
+ return typeof id === 'string' && id.trim().length > 0 ? id : null;
+};
+
+const getMessageParentId = (message: ChatMessageEntry): string | null => {
+ const parentID = (message.info as unknown as { parentID?: unknown }).parentID;
+ return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
+};
+
+const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
+ if (!message) return false;
+ if (resolveMessageRole(message) !== 'user') return false;
+
+ return message.parts.some((part) => {
+ if (part?.type !== 'text') return false;
+ const text = (part as unknown as { text?: unknown }).text;
+ const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
+ return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER);
+ });
+};
+
+type ShellBridgeDetails = {
+ command?: string;
+ output?: string;
+ status?: string;
+};
+
+const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => {
+ if (resolveMessageRole(message) !== 'assistant') {
+ return { hide: false, details: null };
+ }
+
+ if (expectedParentId && getMessageParentId(message) !== expectedParentId) {
+ return { hide: false, details: null };
+ }
+
+ if (message.parts.length !== 1) {
+ return { hide: false, details: null };
+ }
+
+ const part = message.parts[0] as unknown as {
+ type?: unknown;
+ tool?: unknown;
+ state?: {
+ status?: unknown;
+ input?: { command?: unknown };
+ output?: unknown;
+ metadata?: { output?: unknown };
+ };
+ };
+
+ if (part.type !== 'tool') {
+ return { hide: false, details: null };
+ }
+
+ const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
+ if (toolName !== 'bash') {
+ return { hide: false, details: null };
+ }
+
+ const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined;
+ const output =
+ (typeof part.state?.output === 'string' ? part.state.output : undefined)
+ ?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined);
+ const status = typeof part.state?.status === 'string' ? part.state.status : undefined;
+
+ return {
+ hide: true,
+ details: {
+ command,
+ output,
+ status,
+ },
+ };
+};
+
+const readTaskSessionId = (toolPart: Part): string | null => {
+ const partRecord = toolPart as unknown as {
+ state?: {
+ metadata?: { sessionId?: unknown; sessionID?: unknown };
+ output?: unknown;
+ };
+ };
+ const metadata = partRecord.state?.metadata;
+ const fromMetadata =
+ (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0
+ ? metadata.sessionId.trim()
+ : null)
+ ?? (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0
+ ? metadata.sessionID.trim()
+ : null);
+ if (fromMetadata) return fromMetadata;
+
+ const output = partRecord.state?.output;
+ if (typeof output === 'string') {
+ const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
+ if (match?.[1]) {
+ return match[1];
+ }
+ }
+
+ return null;
+};
+
+const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: boolean; taskSessionId: string | null } => {
+ if (resolveMessageRole(message) !== 'assistant') {
+ return { hide: false, taskSessionId: null };
+ }
+
+ if (message.parts.length !== 1) {
+ return { hide: false, taskSessionId: null };
+ }
+
+ const onlyPart = message.parts[0] as unknown as {
+ type?: unknown;
+ tool?: unknown;
+ };
+
+ if (onlyPart.type !== 'tool') {
+ return { hide: false, taskSessionId: null };
+ }
+
+ const toolName = typeof onlyPart.tool === 'string' ? onlyPart.tool.toLowerCase() : '';
+ if (toolName !== 'task') {
+ return { hide: false, taskSessionId: null };
+ }
+
+ return {
+ hide: true,
+ taskSessionId: readTaskSessionId(message.parts[0]),
+ };
+};
+
+const withSubtaskSessionId = (message: ChatMessageEntry, taskSessionId: string | null): ChatMessageEntry => {
+ if (!taskSessionId) return message;
+ const nextParts = message.parts.map((part) => {
+ if (part?.type !== 'subtask') return part;
+ const existing = (part as unknown as { taskSessionID?: unknown }).taskSessionID;
+ if (typeof existing === 'string' && existing.trim().length > 0) return part;
+ return {
+ ...part,
+ taskSessionID: taskSessionId,
+ } as Part;
+ });
+
+ return {
+ ...message,
+ parts: nextParts,
+ };
+};
+
+const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeDetails | null): ChatMessageEntry => {
+ const command = typeof details?.command === 'string' ? details.command.trim() : '';
+ const output = typeof details?.output === 'string' ? details.output : '';
+ const status = typeof details?.status === 'string' ? details.status.trim() : '';
+
+ const nextParts: Part[] = [];
+ let injected = false;
+
+ for (const part of message.parts) {
+ if (!injected && part?.type === 'text') {
+ const text = (part as unknown as { text?: unknown }).text;
+ const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
+ if (synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER)) {
+ nextParts.push({
+ type: 'text',
+ text: '/shell',
+ shellAction: {
+ ...(command ? { command } : {}),
+ ...(output ? { output } : {}),
+ ...(status ? { status } : {}),
+ },
+ } as unknown as Part);
+ injected = true;
+ continue;
+ }
+ }
+ nextParts.push(part);
+ }
+
+ if (!injected) {
+ nextParts.push({
+ type: 'text',
+ text: '/shell',
+ shellAction: {
+ ...(command ? { command } : {}),
+ ...(output ? { output } : {}),
+ ...(status ? { status } : {}),
+ },
+ } as unknown as Part);
+ }
+
+ return {
+ ...message,
+ parts: nextParts,
+ };
+};
+
+interface MessageListProps {
+ messages: ChatMessageEntry[];
+ permissions: PermissionRequest[];
+ questions: QuestionRequest[];
+ onMessageContentChange: (reason?: ContentChangeReason) => void;
+ getAnimationHandlers: (messageId: string) => AnimationHandlers;
+ hasMoreAbove: boolean;
+ isLoadingOlder: boolean;
+ onLoadOlder: () => void;
+ hasRenderEarlier?: boolean;
+ onRenderEarlier?: () => void;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+ scrollRef?: React.RefObject;
+}
+
+export interface MessageListHandle {
+ scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
+ captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
+ restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
+}
+
+type RenderEntry =
+ | { kind: 'ungrouped'; key: string; message: ChatMessageEntry; isInLastTurn: boolean }
+ | { kind: 'turn'; key: string; turn: Turn; isLastTurn: boolean };
+
+interface MessageRowProps {
+ message: ChatMessageEntry;
+ onContentChange: (reason?: ContentChangeReason) => void;
+ animationHandlers: AnimationHandlers;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+}
+
+// Static MessageRow - does NOT subscribe to dynamic context
+// Used for messages NOT in the last turn - no re-renders during streaming
+const StaticMessageRow = React.memo(({
+ message,
+ onContentChange,
+ animationHandlers,
+ scrollToBottom,
+}) => {
+ const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
+ const turnGroupingContext = useTurnGroupingContextStatic(message.info.id);
+
+ return (
+
+ );
+});
+
+StaticMessageRow.displayName = 'StaticMessageRow';
+
+// Dynamic MessageRow - subscribes to dynamic context for streaming state
+// Used for messages in the LAST turn only
+const DynamicMessageRow = React.memo(({
+ message,
+ onContentChange,
+ animationHandlers,
+ scrollToBottom,
+}) => {
+ const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
+ const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id);
+
+ return (
+
+ );
+});
+
+DynamicMessageRow.displayName = 'DynamicMessageRow';
+
+interface TurnBlockProps {
+ turn: Turn;
+ isLastTurn: boolean;
+ onMessageContentChange: (reason?: ContentChangeReason) => void;
+ getAnimationHandlers: (messageId: string) => AnimationHandlers;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+ stickyUserHeader?: boolean;
+}
+
+const TurnBlock: React.FC = ({
+ turn,
+ isLastTurn,
+ onMessageContentChange,
+ getAnimationHandlers,
+ scrollToBottom,
+ stickyUserHeader = true,
+}) => {
+ const renderMessage = React.useCallback(
+ (message: ChatMessageEntry) => {
+ const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
+ const isInLastTurn = role !== 'user' && isLastTurn;
+ const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
+
+ return (
+
+ );
+ },
+ [getAnimationHandlers, isLastTurn, onMessageContentChange, scrollToBottom]
+ );
+
+ return (
+
+ {stickyUserHeader ? (
+
+
+ {renderMessage(turn.userMessage)}
+
+
+
+ ) : (
+ renderMessage(turn.userMessage)
+ )}
+
+
+ {turn.assistantMessages.map((message) => renderMessage(message))}
+
+
+ );
+};
+
+TurnBlock.displayName = 'TurnBlock';
+
+interface UngroupedMessageRowProps {
+ message: ChatMessageEntry;
+ isInLastTurn: boolean;
+ onMessageContentChange: (reason?: ContentChangeReason) => void;
+ getAnimationHandlers: (messageId: string) => AnimationHandlers;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+}
+
+const UngroupedMessageRow: React.FC = React.memo(({
+ message,
+ isInLastTurn,
+ onMessageContentChange,
+ getAnimationHandlers,
+ scrollToBottom,
+}) => {
+ const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
+
+ return (
+
+ );
+});
+
+UngroupedMessageRow.displayName = 'UngroupedMessageRow';
+
+interface MessageListEntryProps {
+ entry: RenderEntry;
+ onMessageContentChange: (reason?: ContentChangeReason) => void;
+ getAnimationHandlers: (messageId: string) => AnimationHandlers;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+ stickyUserHeader?: boolean;
+}
+
+const MessageListEntry: React.FC = React.memo(({
+ entry,
+ onMessageContentChange,
+ getAnimationHandlers,
+ scrollToBottom,
+ stickyUserHeader,
+}) => {
+ if (entry.kind === 'ungrouped') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}, areMessageListEntryPropsEqual);
+
+MessageListEntry.displayName = 'MessageListEntry';
+
+function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextProps: MessageListEntryProps): boolean {
+ if (prevProps.stickyUserHeader !== nextProps.stickyUserHeader) return false;
+ if (prevProps.onMessageContentChange !== nextProps.onMessageContentChange) return false;
+ if (prevProps.getAnimationHandlers !== nextProps.getAnimationHandlers) return false;
+ if (prevProps.scrollToBottom !== nextProps.scrollToBottom) return false;
+
+ const prevEntry = prevProps.entry;
+ const nextEntry = nextProps.entry;
+ if (prevEntry.kind !== nextEntry.kind) return false;
+ if (prevEntry.key !== nextEntry.key) return false;
+
+ if (prevEntry.kind === 'turn' && nextEntry.kind === 'turn') {
+ return prevEntry.turn === nextEntry.turn && prevEntry.isLastTurn === nextEntry.isLastTurn;
+ }
+
+ if (prevEntry.kind === 'ungrouped' && nextEntry.kind === 'ungrouped') {
+ return prevEntry.message === nextEntry.message && prevEntry.isInLastTurn === nextEntry.isInLastTurn;
+ }
+
+ return false;
+}
+
+// Inner component that renders messages with access to context hooks
+const MessageListContent: React.FC<{
+ entries: RenderEntry[];
+ onMessageContentChange: (reason?: ContentChangeReason) => void;
+ getAnimationHandlers: (messageId: string) => AnimationHandlers;
+ scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
+ stickyUserHeader: boolean;
+}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader }) => {
+ return (
+ <>
+ {entries.map((entry) => (
+
+ ))}
+ >
+ );
+};
+
+const MessageList = React.forwardRef(({
+ messages,
+ permissions,
+ questions,
+ onMessageContentChange,
+ getAnimationHandlers,
+ hasMoreAbove,
+ isLoadingOlder,
+ onLoadOlder,
+ hasRenderEarlier,
+ onRenderEarlier,
+ scrollToBottom,
+ scrollRef,
+}, ref) => {
+ const { isMobile } = useDeviceInfo();
+ const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
+
+ React.useEffect(() => {
+ if (permissions.length === 0 && questions.length === 0) {
+ return;
+ }
+ onMessageContentChange('permission');
+ }, [permissions, questions, onMessageContentChange]);
+
+ const baseDisplayMessages = React.useMemo(() => {
+ const seenIdsFromTail = new Set();
+
+ const dedupedMessages: ChatMessageEntry[] = [];
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ const messageId = message.info?.id;
+ if (typeof messageId === 'string') {
+ if (seenIdsFromTail.has(messageId)) {
+ continue;
+ }
+ seenIdsFromTail.add(messageId);
+ }
+ dedupedMessages.push(message);
+ }
+ dedupedMessages.reverse();
+
+ const normalizedMessages = dedupedMessages
+ .map((message) => {
+ const filteredParts = filterSyntheticParts(message.parts);
+ const normalized = filteredParts === message.parts
+ ? message
+ : {
+ ...message,
+ parts: filteredParts,
+ };
+ return normalized;
+ });
+
+ const output: ChatMessageEntry[] = [];
+
+ for (let index = 0; index < normalizedMessages.length; index += 1) {
+ const current = normalizedMessages[index];
+ const previous = output.length > 0 ? output[output.length - 1] : undefined;
+
+ if (isUserSubtaskMessage(previous)) {
+ const bridge = isSyntheticSubtaskBridgeAssistant(current);
+ if (bridge.hide) {
+ output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId);
+ continue;
+ }
+ }
+
+ if (isUserShellMarkerMessage(previous)) {
+ const bridge = getShellBridgeAssistantDetails(current, getMessageId(previous));
+ if (bridge.hide) {
+ output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details);
+ continue;
+ }
+ }
+
+ output.push(current);
+ }
+
+ return output;
+ }, [messages]);
+
+ const activeRetryStatus = useSessionStore(
+ useShallow((state) => {
+ const sessionId = state.currentSessionId;
+ if (!sessionId) return null;
+ const status = state.sessionStatus?.get(sessionId);
+ if (!status || status.type !== 'retry') return null;
+ const rawMessage = typeof status.message === 'string' ? status.message.trim() : '';
+ return {
+ sessionId,
+ message: rawMessage || 'Quota limit reached. Retrying automatically.',
+ confirmedAt: status.confirmedAt,
+ };
+ })
+ );
+
+ const activeRetrySessionId = activeRetryStatus?.sessionId ?? null;
+ const activeRetryMessage = activeRetryStatus?.message
+ ?? 'Quota limit reached. Retrying automatically.';
+ const activeRetryConfirmedAt = activeRetryStatus?.confirmedAt;
+
+ const [fallbackRetryTimestamp, setFallbackRetryTimestamp] = React.useState(0);
+ const fallbackRetrySessionRef = React.useRef(null);
+ const [scrollContainer, setScrollContainer] = React.useState(null);
+
+ React.useLayoutEffect(() => {
+ setScrollContainer(scrollRef?.current ?? null);
+ }, [scrollRef]);
+
+ React.useEffect(() => {
+ if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
+ fallbackRetrySessionRef.current = null;
+ setFallbackRetryTimestamp(0);
+ return;
+ }
+
+ if (fallbackRetrySessionRef.current !== activeRetryStatus.sessionId) {
+ fallbackRetrySessionRef.current = activeRetryStatus.sessionId;
+ setFallbackRetryTimestamp(Date.now());
+ }
+ }, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]);
+
+ const displayMessages = React.useMemo(() => {
+ if (!activeRetrySessionId) {
+ return baseDisplayMessages;
+ }
+
+ const retryError = {
+ name: 'SessionRetry',
+ message: activeRetryMessage,
+ data: { message: activeRetryMessage },
+ };
+
+ let lastUserIndex = -1;
+ for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
+ if (resolveMessageRole(baseDisplayMessages[index]) === 'user') {
+ lastUserIndex = index;
+ break;
+ }
+ }
+
+ if (lastUserIndex < 0) {
+ return baseDisplayMessages;
+ }
+
+ // Prefer attaching retry error to the assistant message in the current turn (if one exists)
+ // to avoid rendering a separate header-only placeholder + error block.
+ let targetAssistantIndex = -1;
+ for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
+ if (resolveMessageRole(baseDisplayMessages[index]) === 'assistant') {
+ targetAssistantIndex = index;
+ break;
+ }
+ }
+
+ if (targetAssistantIndex >= 0) {
+ const existing = baseDisplayMessages[targetAssistantIndex];
+ const existingInfo = existing.info as unknown as { error?: unknown };
+ if (existingInfo.error) {
+ return baseDisplayMessages;
+ }
+
+ return baseDisplayMessages.map((message, index) => {
+ if (index !== targetAssistantIndex) {
+ return message;
+ }
+ return {
+ ...message,
+ info: {
+ ...(message.info as unknown as Record),
+ error: retryError,
+ } as unknown as Message,
+ };
+ });
+ }
+
+ const eventTime = typeof activeRetryConfirmedAt === 'number' ? activeRetryConfirmedAt : fallbackRetryTimestamp;
+ const syntheticId = `synthetic_retry_notice_${activeRetrySessionId}`;
+ const synthetic: ChatMessageEntry = {
+ info: {
+ id: syntheticId,
+ sessionID: activeRetrySessionId,
+ role: 'assistant',
+ time: { created: eventTime, completed: eventTime },
+ finish: 'stop',
+ error: retryError,
+ } as unknown as Message,
+ parts: [],
+ };
+
+ const next = baseDisplayMessages.slice();
+ next.splice(lastUserIndex + 1, 0, synthetic);
+ return next;
+ }, [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]);
+
+ const turns = React.useMemo(() => detectTurns(displayMessages), [displayMessages]);
+
+ const renderEntries = React.useMemo(() => {
+ const entries: RenderEntry[] = [];
+ const turnByUserId = new Map();
+ const groupedAssistantIds = new Set();
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
+ const lastTurnId = lastTurn?.turnId ?? null;
+ const lastTurnMessageIds = new Set();
+ if (lastTurn) {
+ lastTurnMessageIds.add(lastTurn.userMessage.info.id);
+ lastTurn.assistantMessages.forEach((assistantMessage: ChatMessageEntry) => {
+ lastTurnMessageIds.add(assistantMessage.info.id);
+ });
+ }
+
+ turns.forEach((turn: Turn) => {
+ turnByUserId.set(turn.userMessage.info.id, turn);
+ turn.assistantMessages.forEach((assistantMessage: ChatMessageEntry) => {
+ groupedAssistantIds.add(assistantMessage.info.id);
+ });
+ });
+
+ displayMessages.forEach((message: ChatMessageEntry) => {
+ const turn = turnByUserId.get(message.info.id);
+ if (turn) {
+ entries.push({
+ kind: 'turn',
+ key: `turn:${turn.turnId}`,
+ turn,
+ isLastTurn: turn.turnId === lastTurnId,
+ });
+ return;
+ }
+
+ if (groupedAssistantIds.has(message.info.id)) {
+ return;
+ }
+
+ entries.push({
+ kind: 'ungrouped',
+ key: `msg:${message.info.id}`,
+ message,
+ isInLastTurn: lastTurnMessageIds.has(message.info.id),
+ });
+ });
+
+ return entries;
+ }, [displayMessages, turns]);
+
+ const shouldVirtualize = Boolean(scrollContainer) && renderEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD;
+
+ const estimateEntrySize = React.useCallback(
+ (index: number): number => {
+ const entry = renderEntries[index];
+ if (!entry) {
+ return 300;
+ }
+ if (entry.kind === 'turn') {
+ const assistantCount = entry.turn.assistantMessages.length;
+ return Math.min(3600, 140 + assistantCount * 260);
+ }
+ const role = resolveMessageRole(entry.message);
+ return role === 'user' ? 120 : 280;
+ },
+ [renderEntries]
+ );
+
+ const virtualizer = useMessageListVirtualizer({
+ count: renderEntries.length,
+ getScrollElement: () => scrollContainer,
+ estimateSize: estimateEntrySize,
+ overscan: isMobile ? MESSAGE_VIRTUAL_OVERSCAN_MOBILE : MESSAGE_VIRTUAL_OVERSCAN_DESKTOP,
+ getItemKey: (index: number) => renderEntries[index]?.key ?? index,
+ enabled: shouldVirtualize,
+ useFlushSync: false,
+ });
+
+ const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems() : [];
+
+ const scrollVirtualizerToIndex = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
+ if (!virtualizer) {
+ return;
+ }
+ const normalizedBehavior: 'auto' | 'smooth' = behavior === 'instant' ? 'auto' : behavior;
+ virtualizer.scrollToIndex(index, { align: 'start', behavior: normalizedBehavior });
+ }, [virtualizer]);
+
+ const messageIndexMap = React.useMemo(() => {
+ const indexMap = new Map();
+
+ renderEntries.forEach((entry, index) => {
+ if (entry.kind === 'ungrouped') {
+ indexMap.set(entry.message.info.id, index);
+ return;
+ }
+ indexMap.set(entry.turn.userMessage.info.id, index);
+ entry.turn.assistantMessages.forEach((message) => {
+ indexMap.set(message.info.id, index);
+ });
+ });
+
+ return indexMap;
+ }, [renderEntries]);
+
+ const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => {
+ const container = scrollContainer;
+ if (!container) {
+ return null;
+ }
+ return container.querySelector(`[data-message-id="${messageId}"]`);
+ }, [scrollContainer]);
+
+ const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
+ const container = scrollContainer;
+ if (!container) {
+ return false;
+ }
+ const messageElement = findMessageElement(messageId);
+ if (!messageElement) {
+ return false;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const messageRect = messageElement.getBoundingClientRect();
+ const offset = 50;
+ const top = messageRect.top - containerRect.top + container.scrollTop - offset;
+ container.scrollTo({ top, behavior });
+ return true;
+ }, [findMessageElement, scrollContainer]);
+
+ React.useLayoutEffect(() => {
+ if (!ref) {
+ return;
+ }
+
+ const handle: MessageListHandle = {
+ scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
+ const behavior = options?.behavior ?? 'auto';
+ const index = messageIndexMap.get(messageId);
+ if (index === undefined) {
+ return false;
+ }
+
+ if (shouldVirtualize) {
+ scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior);
+ if (typeof window !== 'undefined') {
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
+ scrollMessageElementIntoView(messageId, behavior);
+ });
+ });
+ }
+ return true;
+ }
+
+ return scrollMessageElementIntoView(messageId, behavior);
+ },
+
+ captureViewportAnchor: () => {
+ const container = scrollContainer;
+ if (!container) {
+ return null;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const nodes = Array.from(container.querySelectorAll('[data-message-id]'));
+ const firstVisible = nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
+ if (!firstVisible) {
+ return null;
+ }
+
+ const messageId = firstVisible.dataset.messageId;
+ if (!messageId) {
+ return null;
+ }
+
+ return {
+ messageId,
+ offsetTop: firstVisible.getBoundingClientRect().top - containerRect.top,
+ };
+ },
+
+ restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => {
+ const container = scrollContainer;
+ if (!container) {
+ return false;
+ }
+
+ const index = messageIndexMap.get(anchor.messageId);
+ if (index === undefined) {
+ return false;
+ }
+
+ if (shouldVirtualize) {
+ scrollVirtualizerToIndex(index, 'auto');
+ }
+
+ if (typeof window !== 'undefined') {
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
+ const element = findMessageElement(anchor.messageId);
+ if (!element) {
+ return;
+ }
+ const containerRect = container.getBoundingClientRect();
+ const targetTop = element.getBoundingClientRect().top - containerRect.top;
+ const delta = targetTop - anchor.offsetTop;
+ if (delta !== 0) {
+ container.scrollTop += delta;
+ }
+ });
+ });
+ }
+
+ return true;
+ },
+ };
+
+ if (typeof ref === 'function') {
+ ref(handle);
+ return () => {
+ ref(null);
+ };
+ }
+
+ const objectRef = ref;
+ objectRef.current = handle;
+ return () => {
+ objectRef.current = null;
+ };
+ }, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, scrollContainer, scrollVirtualizerToIndex, shouldVirtualize, ref]);
+
+ const disableFadeIn = shouldVirtualize && virtualizer.isScrolling;
+
+ return (
+
+
+ {hasRenderEarlier && (
+
+
+ Render earlier messages
+
+
+ )}
+
+ {hasMoreAbove && (
+
+ {isLoadingOlder ? (
+
+ Loading…
+
+ ) : (
+
+ Load older messages
+
+ )}
+
+ )}
+
+
+ {shouldVirtualize ? (
+
+ {virtualRows.map((virtualRow: VirtualItem) => {
+ const entry = renderEntries[virtualRow.index];
+ if (!entry) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+ {(questions.length > 0 || permissions.length > 0) && (
+
+ {questions.map((question) => (
+
+ ))}
+ {permissions.map((permission) => (
+
+ ))}
+
+ )}
+
+ {/* Bottom spacer */}
+
+
+
+ );
+});
+
+MessageList.displayName = 'MessageList';
+
+export default React.memo(MessageList);
diff --git a/ui/src/components/chat/MobileAgentButton.tsx b/ui/src/components/chat/MobileAgentButton.tsx
new file mode 100644
index 0000000..944a6f3
--- /dev/null
+++ b/ui/src/components/chat/MobileAgentButton.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { getAgentDisplayName } from './mobileControlsUtils';
+import { getAgentColor } from '@/lib/agentColors';
+
+interface MobileAgentButtonProps {
+ onCycleAgent: () => void;
+ onOpenAgentPanel: () => void;
+ className?: string;
+}
+
+const LONG_PRESS_MS = 500;
+
+// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
+export const MobileAgentButton: React.FC = ({ onCycleAgent, onOpenAgentPanel, className }) => {
+ const { currentAgentName, getVisibleAgents } = useConfigStore();
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const sessionAgentName = useSessionStore((state) =>
+ currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
+ );
+
+ const agents = getVisibleAgents();
+ const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
+ const agentLabel = getAgentDisplayName(agents, uiAgentName);
+ const agentColor = getAgentColor(uiAgentName);
+
+ const longPressTimerRef = React.useRef | null>(null);
+ const isLongPressRef = React.useRef(false);
+
+ const handlePointerDown = () => {
+ isLongPressRef.current = false;
+ longPressTimerRef.current = setTimeout(() => {
+ isLongPressRef.current = true;
+ onOpenAgentPanel();
+ }, LONG_PRESS_MS);
+ };
+
+ // Use onPointerUp (not onClick) to prevent focus transfer that closes mobile keyboard
+ const handlePointerUp = () => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ longPressTimerRef.current = null;
+ }
+ if (!isLongPressRef.current) {
+ onCycleAgent();
+ }
+ };
+
+ const handlePointerLeave = () => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ longPressTimerRef.current = null;
+ }
+ };
+
+ React.useEffect(() => {
+ return () => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ }
+ };
+ }, []);
+
+ return (
+ e.preventDefault()}
+ className={cn(
+ 'inline-flex min-w-0 items-center select-none',
+ 'rounded-lg border border-border/50 px-1.5',
+ 'typography-micro font-medium',
+ 'focus:outline-none hover:bg-[var(--interactive-hover)]',
+ 'touch-none',
+ className
+ )}
+ style={{
+ height: '26px',
+ maxHeight: '26px',
+ minHeight: '26px',
+ color: `var(${agentColor.var})`,
+ }}
+ title={agentLabel}
+ >
+ {agentLabel}
+
+ );
+};
+
+export default MobileAgentButton;
diff --git a/ui/src/components/chat/MobileModelButton.tsx b/ui/src/components/chat/MobileModelButton.tsx
new file mode 100644
index 0000000..04b316e
--- /dev/null
+++ b/ui/src/components/chat/MobileModelButton.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { getModelDisplayName } from './mobileControlsUtils';
+
+interface MobileModelButtonProps {
+ onOpenModel: () => void;
+ className?: string;
+}
+
+export const MobileModelButton: React.FC = ({ onOpenModel, className }) => {
+ const { currentModelId, getCurrentProvider } = useConfigStore();
+ const currentProvider = getCurrentProvider();
+ const modelLabel = getModelDisplayName(currentProvider, currentModelId);
+
+ return (
+
+
+ {modelLabel}
+
+
+ );
+};
+
+export default MobileModelButton;
diff --git a/ui/src/components/chat/MobileSessionStatusBar.tsx b/ui/src/components/chat/MobileSessionStatusBar.tsx
new file mode 100644
index 0000000..b3b3360
--- /dev/null
+++ b/ui/src/components/chat/MobileSessionStatusBar.tsx
@@ -0,0 +1,1598 @@
+import React from 'react';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import type { Session } from '@opencode-ai/sdk/v2';
+import type { ProjectEntry } from '@/lib/api/types';
+import { cn, formatDirectoryName } from '@/lib/utils';
+import { getAgentColor } from '@/lib/agentColors';
+import {
+ RiLoader4Line,
+ RiAddLine,
+ RiDragMove2Line,
+ RiDeleteBinLine,
+ RiEditLine,
+ RiArrowUpLine,
+ RiArrowDownLine,
+} from '@remixicon/react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import type { SessionContextUsage } from '@/stores/types/sessionTypes';
+import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
+import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { toast } from '@/components/ui';
+import { isTauriShell, isDesktopLocalOriginActive, requestDirectoryAccess } from '@/lib/desktop';
+import { sessionEvents } from '@/lib/sessionEvents';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
+import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
+import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+
+interface MobileSessionStatusBarProps {
+ onSessionSwitch?: (sessionId: string) => void;
+ cornerRadius?: number;
+}
+
+interface SessionWithStatus extends Session {
+ _statusType?: 'busy' | 'retry' | 'idle';
+ _hasRunningChildren?: boolean;
+ _runningChildrenCount?: number;
+ _childIndicators?: Array<{ session: Session; isRunning: boolean }>;
+}
+
+// Normalize path for comparison
+const normalize = (value: string): string => {
+ if (!value) return '';
+ const replaced = value.replace(/\\/g, '/');
+ return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
+};
+
+function useSessionGrouping(
+ sessions: Session[],
+ sessionStatus: Map | undefined,
+ sessionAttentionStates: Map | undefined
+) {
+ const parentChildMap = React.useMemo(() => {
+ const map = new Map();
+ const allIds = new Set(sessions.map((s) => s.id));
+
+ sessions.forEach((session) => {
+ const parentID = (session as { parentID?: string }).parentID;
+ if (parentID && allIds.has(parentID)) {
+ map.set(parentID, [...(map.get(parentID) || []), session]);
+ }
+ });
+ return map;
+ }, [sessions]);
+
+ const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
+ const status = sessionStatus?.get(sessionId);
+ if (status?.type === 'busy' || status?.type === 'retry') return status.type;
+ return 'idle';
+ }, [sessionStatus]);
+
+ const hasRunningChildren = React.useCallback((sessionId: string): boolean => {
+ const children = parentChildMap.get(sessionId) || [];
+ return children.some((child) => getStatusType(child.id) !== 'idle');
+ }, [parentChildMap, getStatusType]);
+
+ const getRunningChildrenCount = React.useCallback((sessionId: string): number => {
+ const children = parentChildMap.get(sessionId) || [];
+ return children.filter((child) => getStatusType(child.id) !== 'idle').length;
+ }, [parentChildMap, getStatusType]);
+
+ const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => {
+ const children = parentChildMap.get(sessionId) || [];
+ return children
+ .filter((child) => getStatusType(child.id) !== 'idle')
+ .map((child) => ({ session: child, isRunning: true }))
+ .slice(0, 3);
+ }, [parentChildMap, getStatusType]);
+
+ const processedSessions = React.useMemo(() => {
+ const topLevel = sessions.filter((session) => {
+ const parentID = (session as { parentID?: string }).parentID;
+ return !parentID || !new Set(sessions.map((s) => s.id)).has(parentID);
+ });
+
+ const running: SessionWithStatus[] = [];
+ const viewed: SessionWithStatus[] = [];
+
+ topLevel.forEach((session) => {
+ const statusType = getStatusType(session.id);
+ const hasRunning = hasRunningChildren(session.id);
+ const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false;
+
+ const enriched: SessionWithStatus = {
+ ...session,
+ _statusType: statusType,
+ _hasRunningChildren: hasRunning,
+ _runningChildrenCount: getRunningChildrenCount(session.id),
+ _childIndicators: getChildIndicators(session.id),
+ };
+
+ if (statusType !== 'idle' || hasRunning) {
+ running.push(enriched);
+ } else if (attention) {
+ running.push(enriched);
+ } else {
+ viewed.push(enriched);
+ }
+ });
+
+ const sortByUpdated = (a: Session, b: Session) => {
+ const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
+ const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
+ return bTime - aTime;
+ };
+
+ running.sort(sortByUpdated);
+ viewed.sort(sortByUpdated);
+
+ return [...running, ...viewed];
+ }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]);
+
+ const totalRunning = processedSessions.reduce((sum, s) => {
+ const selfRunning = s._statusType !== 'idle' ? 1 : 0;
+ return sum + selfRunning + (s._runningChildrenCount ?? 0);
+ }, 0);
+
+ const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length;
+
+ return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
+}
+
+function useSessionHelpers(
+ agents: Array<{ name: string }>,
+ sessionStatus: Map | undefined,
+ sessionAttentionStates: Map | undefined
+) {
+ const getSessionAgentName = React.useCallback((session: Session): string => {
+ const agent = (session as { agent?: string }).agent;
+ if (agent) return agent;
+
+ const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id);
+ if (sessionAgentSelection) return sessionAgentSelection;
+
+ return agents[0]?.name ?? 'agent';
+ }, [agents]);
+
+ const getSessionTitle = React.useCallback((session: Session): string => {
+ const title = session.title;
+ if (title && title.trim()) return title;
+ return 'New session';
+ }, []);
+
+ const isRunning = React.useCallback((sessionId: string): boolean => {
+ const status = sessionStatus?.get(sessionId);
+ return status?.type === 'busy' || status?.type === 'retry';
+ }, [sessionStatus]);
+
+ // Use server-authoritative attention state instead of local activity state
+ const needsAttention = React.useCallback((sessionId: string): boolean => {
+ return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false;
+ }, [sessionAttentionStates]);
+
+ return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
+}
+
+// Hook to calculate project status indicators
+function useProjectStatus(
+ sessions: Session[],
+ sessionStatus: Map | undefined,
+ sessionAttentionStates: Map | undefined,
+ currentSessionId: string | null
+) {
+ const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
+ const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
+ const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
+
+ const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
+ const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
+ const status = sessionStatus?.get(sessionId);
+ if (status?.type === 'busy' || status?.type === 'retry') return status.type;
+ return 'idle';
+ };
+
+ const projectRoot = normalize(projectPath);
+ if (!projectRoot) {
+ return { hasRunning: false, hasUnread: false };
+ }
+
+ const dirs: string[] = [projectRoot];
+ const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
+ for (const meta of worktrees) {
+ const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
+ if (typeof p === 'string' && p.trim()) {
+ const normalized = normalize(p);
+ if (normalized && normalized !== projectRoot) {
+ dirs.push(normalized);
+ }
+ }
+ }
+
+ const seen = new Set();
+ let hasRunning = false;
+ let hasUnread = false;
+
+ for (const dir of dirs) {
+ const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
+ for (const session of list) {
+ if (!session?.id || seen.has(session.id)) {
+ continue;
+ }
+ seen.add(session.id);
+
+ const statusType = getStatusType(session.id);
+ if (statusType === 'busy' || statusType === 'retry') {
+ hasRunning = true;
+ }
+
+ if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
+ hasUnread = true;
+ }
+
+ if (hasRunning && hasUnread) {
+ break;
+ }
+ }
+ if (hasRunning && hasUnread) {
+ break;
+ }
+ }
+
+ return { hasRunning, hasUnread };
+ }, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
+
+ return projectStatusMap;
+}
+
+function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) {
+ if (isRunning) {
+ return ;
+ }
+ if (needsAttention) {
+ return
;
+ }
+ return
;
+}
+
+function RunningIndicator({ count }: { count: number }) {
+ if (count === 0) return null;
+ return (
+
+
+ {count}
+
+ );
+}
+
+function UnreadIndicator({ count }: { count: number }) {
+ if (count === 0) return null;
+ return (
+
+
+ {count}
+
+ );
+}
+
+function SessionItem({
+ session,
+ isCurrent,
+ getSessionAgentName,
+ getSessionTitle,
+ onClick,
+ onDoubleClick,
+ needsAttention
+}: {
+ session: SessionWithStatus;
+ isCurrent: boolean;
+ getSessionAgentName: (s: Session) => string;
+ getSessionTitle: (s: Session) => string;
+ onClick: () => void;
+ onDoubleClick?: () => void;
+ needsAttention: (sessionId: string) => boolean;
+}) {
+ const agentName = getSessionAgentName(session);
+ const agentColor = getAgentColor(agentName);
+ const extraCount = (session._runningChildrenCount || 0) + (session._statusType !== 'idle' ? 1 : 0) - 1 - (session._childIndicators?.length || 0);
+
+ return (
+ {
+ e.stopPropagation();
+ onDoubleClick?.();
+ }}
+ className={cn(
+ "flex items-center gap-0.5 px-1.5 py-px text-left transition-colors",
+ "hover:bg-[var(--interactive-hover)] active:bg-[var(--interactive-selection)]",
+ isCurrent && "bg-[var(--interactive-selection)]/30"
+ )}
+ >
+
+
+
+
+
+
+
+ {getSessionTitle(session)}
+
+
+ {(session._childIndicators?.length || 0) > 0 && (
+
+
[
+
+ {session._childIndicators!.map(({ session: child }) => {
+ const childColor = getAgentColor(getSessionAgentName(child));
+ return (
+
+
+
+ );
+ })}
+ {extraCount > 0 && (
+
+ +{extraCount}
+
+ )}
+
+
]
+
+ )}
+
+ );
+}
+
+function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) {
+ if (!contextUsage || contextUsage.totalTokens === 0) return null;
+
+ const percentage = Math.min(contextUsage.percentage, 999);
+ const colorClass =
+ percentage >= 90 ? 'text-[var(--status-error)]' :
+ percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]';
+
+ return (
+
+ {percentage.toFixed(1)}%
+
+ );
+}
+
+interface SessionStatusHeaderProps {
+ currentSessionTitle: string;
+ currentProjectLabel?: string;
+ currentProjectIcon?: string | null;
+ currentProjectIconImageUrl?: string | null;
+ currentProjectIconBackground?: string | null;
+ currentProjectColor?: string | null;
+ onToggle: () => void;
+ isExpanded?: boolean;
+ childIndicators?: Array<{ session: Session; isRunning: boolean }>;
+}
+
+function SessionStatusHeader({
+ currentSessionTitle,
+ currentProjectLabel,
+ currentProjectIcon,
+ currentProjectIconImageUrl,
+ currentProjectIconBackground,
+ currentProjectColor,
+ onToggle,
+ isExpanded = false,
+ childIndicators = []
+}: SessionStatusHeaderProps) {
+ const [imageFailed, setImageFailed] = React.useState(false);
+ const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
+ const imageUrl = !imageFailed ? currentProjectIconImageUrl : null;
+ const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
+ const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0;
+
+ React.useEffect(() => {
+ setImageFailed(false);
+ }, [currentProjectIconImageUrl]);
+
+ return (
+
+ {!isExpanded && currentProjectLabel && (
+
+
+ {imageUrl ? (
+
+ setImageFailed(true)}
+ />
+
+ ) : ProjectIcon && (
+
+ )}
+
+ {currentProjectLabel}
+
+
+
+
+ )}
+
+
+ {currentSessionTitle}
+
+ {childIndicators.length > 0 && (
+
+
[
+
+ {childIndicators.slice(0, 3).map((child) => {
+ const childAgent = (child.session as { agent?: string }).agent || 'agent';
+ const childColor = getAgentColor(childAgent);
+ return (
+
+
+
+ );
+ })}
+ {extraCount > 0 && (
+
+ +{extraCount}
+
+ )}
+
+
]
+
+ )}
+
+
+ );
+}
+
+
+
+// Hook for long press with movement detection
+function useLongPress(
+ onLongPress: () => void,
+ onClick: () => void,
+ ms = 500
+) {
+ const timerRef = React.useRef(null);
+ const isLongPress = React.useRef(false);
+ const startPosRef = React.useRef<{ x: number; y: number } | null>(null);
+ const hasMovedRef = React.useRef(false);
+ const MOVE_THRESHOLD = 10; // pixels
+
+ const start = React.useCallback((clientX: number, clientY: number) => {
+ isLongPress.current = false;
+ hasMovedRef.current = false;
+ startPosRef.current = { x: clientX, y: clientY };
+ timerRef.current = setTimeout(() => {
+ if (!hasMovedRef.current) {
+ isLongPress.current = true;
+ onLongPress();
+ }
+ }, ms);
+ }, [onLongPress, ms]);
+
+ const move = React.useCallback((clientX: number, clientY: number) => {
+ if (!startPosRef.current) return;
+
+ const dx = Math.abs(clientX - startPosRef.current.x);
+ const dy = Math.abs(clientY - startPosRef.current.y);
+
+ if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
+ hasMovedRef.current = true;
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }
+ }, []);
+
+ const end = React.useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ startPosRef.current = null;
+ }, []);
+
+ const handleClick = React.useCallback(() => {
+ if (!isLongPress.current) {
+ onClick();
+ }
+ }, [onClick]);
+
+ return {
+ onMouseDown: (e: React.MouseEvent) => start(e.clientX, e.clientY),
+ onMouseUp: end,
+ onMouseLeave: end,
+ onMouseMove: (e: React.MouseEvent) => move(e.clientX, e.clientY),
+ onTouchStart: (e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ start(touch.clientX, touch.clientY);
+ },
+ onTouchMove: (e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ move(touch.clientX, touch.clientY);
+ },
+ onTouchEnd: end,
+ onClick: handleClick,
+ };
+}
+
+// Sortable project item for edit panel
+interface SortableProjectItemProps {
+ project: ProjectEntry;
+ isFirst: boolean;
+ isLast: boolean;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ formatProjectLabel: (project: ProjectEntry) => string;
+}
+
+function SortableProjectItem({
+ project,
+ isFirst,
+ isLast,
+ onMoveUp,
+ onMoveDown,
+ onEdit,
+ onDelete,
+ formatProjectLabel,
+}: SortableProjectItemProps) {
+ const { currentTheme } = useThemeSystem();
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: project.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: isDragging ? 10 : 1,
+ };
+
+ const [imageFailed, setImageFailed] = React.useState(false);
+ const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
+ const projectIconImageUrl = !imageFailed
+ ? getProjectIconImageUrl(project, {
+ themeVariant: currentTheme.metadata.variant,
+ iconColor: currentTheme.colors.surface.foreground,
+ })
+ : null;
+ const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
+
+ return (
+
+ {/* Drag handle */}
+
+
+
+
+ {/* Project info */}
+
+ {projectIconImageUrl ? (
+
+ setImageFailed(true)}
+ />
+
+ ) : ProjectIcon ? (
+
+ ) : (
+
+ )}
+
+ {formatProjectLabel(project)}
+
+
+
+ {/* Actions */}
+
+ {/* Move up/down buttons (for non-drag sorting) */}
+
+
+
+
+
+
+
+
+
+ {/* Edit button */}
+
+
+
+
+ {/* Delete button */}
+
+
+
+
+
+ );
+}
+
+// Project edit panel for mobile
+interface ProjectEditPanelProps {
+ isOpen: boolean;
+ onClose: () => void;
+ projects: ProjectEntry[];
+ onReorder: (fromIndex: number, toIndex: number) => void;
+ onEdit: (project: ProjectEntry) => void;
+ onDelete: (project: ProjectEntry) => void;
+ homeDirectory: string | null;
+}
+
+function ProjectEditPanel({
+ isOpen,
+ onClose,
+ projects,
+ onReorder,
+ onEdit,
+ onDelete,
+ homeDirectory,
+}: ProjectEditPanelProps) {
+ const [localProjects, setLocalProjects] = React.useState(projects);
+
+ React.useEffect(() => {
+ setLocalProjects(projects);
+ }, [projects, isOpen]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (over && active.id !== over.id) {
+ const oldIndex = localProjects.findIndex((p) => p.id === active.id);
+ const newIndex = localProjects.findIndex((p) => p.id === over.id);
+
+ setLocalProjects((items) => arrayMove(items, oldIndex, newIndex));
+ onReorder(oldIndex, newIndex);
+ }
+ };
+
+ const handleMoveUp = (index: number) => {
+ if (index > 0) {
+ setLocalProjects((items) => arrayMove(items, index, index - 1));
+ onReorder(index, index - 1);
+ }
+ };
+
+ const handleMoveDown = (index: number) => {
+ if (index < localProjects.length - 1) {
+ setLocalProjects((items) => arrayMove(items, index, index + 1));
+ onReorder(index, index + 1);
+ }
+ };
+
+ const formatProjectLabel = (project: ProjectEntry): string => {
+ return project.label?.trim()
+ || formatDirectoryName(project.path, homeDirectory)
+ || project.path;
+ };
+
+ return (
+
+ Drag items to reorder, or use arrows to move. Tap edit to change details.
+
+ }
+ >
+
+
+ p.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {localProjects.map((project, index) => (
+ handleMoveUp(index)}
+ onMoveDown={() => handleMoveDown(index)}
+ onEdit={() => onEdit(project)}
+ onDelete={() => onDelete(project)}
+ formatProjectLabel={formatProjectLabel}
+ />
+ ))}
+
+
+
+ {localProjects.length === 0 && (
+
+ No projects to edit
+
+ )}
+
+
+ );
+}
+
+// Project button component with long press support
+interface ProjectButtonProps {
+ project: ProjectEntry;
+ isActive: boolean;
+ status: { hasRunning: boolean; hasUnread: boolean };
+ projectColorVar: string | null;
+ onProjectSwitch: () => void;
+ onOpenEditPanel?: () => void;
+ formatProjectLabel: (project: ProjectEntry) => string;
+}
+
+function ProjectButton({
+ project,
+ isActive,
+ status,
+ projectColorVar,
+ onProjectSwitch,
+ onOpenEditPanel,
+ formatProjectLabel,
+}: ProjectButtonProps) {
+ const { currentTheme } = useThemeSystem();
+ const [imageFailed, setImageFailed] = React.useState(false);
+ const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
+ const projectIconImageUrl = !imageFailed
+ ? getProjectIconImageUrl(project, {
+ themeVariant: currentTheme.metadata.variant,
+ iconColor: currentTheme.colors.surface.foreground,
+ })
+ : null;
+
+ React.useEffect(() => {
+ setImageFailed(false);
+ }, [project.id, project.iconImage?.updatedAt]);
+
+ const longPressHandlers = useLongPress(
+ () => {
+ if (onOpenEditPanel) {
+ onOpenEditPanel();
+ }
+ },
+ onProjectSwitch,
+ 600
+ );
+
+ return (
+
+ {/* Status indicators */}
+
+ {status.hasRunning && (
+
+ )}
+ {!status.hasRunning && status.hasUnread && (
+
+ )}
+
+
+ {/* Icon */}
+ {projectIconImageUrl ? (
+
+ setImageFailed(true)}
+ />
+
+ ) : ProjectIcon && (
+
+ )}
+
+ {/* Label */}
+
+ {formatProjectLabel(project)}
+
+
+ );
+}
+
+// Project bar component for expanded view
+interface ProjectBarProps {
+ projects: ProjectEntry[];
+ activeProjectId: string | null;
+ getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean };
+ onProjectSwitch: (projectId: string) => void;
+ onAddProject: () => void;
+ onRemoveProject?: (projectId: string) => void;
+ homeDirectory: string | null;
+}
+
+function ProjectBar({
+ projects,
+ activeProjectId,
+ getProjectStatus,
+ onProjectSwitch,
+ onAddProject,
+ onRemoveProject,
+ homeDirectory
+}: ProjectBarProps) {
+ const scrollRef = React.useRef(null);
+ const [editPanelOpen, setEditPanelOpen] = React.useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
+ const [projectToDelete, setProjectToDelete] = React.useState(null);
+ const reorderProjects = useProjectsStore((state) => state.reorderProjects);
+
+ // Scroll active project into view
+ React.useEffect(() => {
+ if (scrollRef.current && activeProjectId) {
+ const activeElement = scrollRef.current.querySelector(`[data-project-id="${activeProjectId}"]`);
+ if (activeElement) {
+ activeElement.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
+ }
+ }
+ }, [activeProjectId]);
+
+ const handleOpenEditPanel = () => {
+ setEditPanelOpen(true);
+ };
+
+ const handleReorder = (fromIndex: number, toIndex: number) => {
+ reorderProjects(fromIndex, toIndex);
+ };
+
+ const [editingProject, setEditingProject] = React.useState(null);
+ const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
+
+ const handleEditProject = (project: ProjectEntry) => {
+ setEditingProject(project);
+ };
+
+ const handleSaveProjectEdit = (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
+ if (editingProject) {
+ updateProjectMeta(editingProject.id, data);
+ }
+ setEditingProject(null);
+ };
+
+ const handleDeleteProject = (project: ProjectEntry) => {
+ setProjectToDelete(project);
+ setDeleteDialogOpen(true);
+ };
+
+ const handleConfirmDelete = () => {
+ if (projectToDelete && onRemoveProject) {
+ onRemoveProject(projectToDelete.id);
+ }
+ setDeleteDialogOpen(false);
+ setProjectToDelete(null);
+ };
+
+ if (projects.length === 0) {
+ return (
+
+ No projects
+
+
+
+
+ );
+ }
+
+ const formatProjectLabel = (project: ProjectEntry): string => {
+ return project.label?.trim()
+ || formatDirectoryName(project.path, homeDirectory)
+ || project.path;
+ };
+
+ // Handle touch events to prevent drawer swipe when scrolling project bar
+ const handleTouchStart = (e: React.TouchEvent) => {
+ // Store initial touch position for this component
+ (e.currentTarget as HTMLElement).dataset.touchStartX = String(e.touches[0].clientX);
+ (e.currentTarget as HTMLElement).dataset.touchStartY = String(e.touches[0].clientY);
+ };
+
+ const handleTouchMove = (e: React.TouchEvent) => {
+ const target = e.currentTarget as HTMLElement;
+ const startX = Number(target.dataset.touchStartX || 0);
+ const startY = Number(target.dataset.touchStartY || 0);
+ const deltaX = e.touches[0].clientX - startX;
+ const deltaY = e.touches[0].clientY - startY;
+
+ // If horizontal scroll dominates, prevent default to stop drawer gesture
+ if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 5) {
+ e.stopPropagation();
+ }
+ };
+
+ const handleTouchEnd = (e: React.TouchEvent) => {
+ // Clean up
+ const target = e.currentTarget as HTMLElement;
+ delete target.dataset.touchStartX;
+ delete target.dataset.touchStartY;
+ };
+
+ return (
+
+
+ {projects.map((project) => {
+ const isActive = project.id === activeProjectId;
+ const status = getProjectStatus(project.path);
+ const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
+
+ return (
+
onProjectSwitch(project.id)}
+ onOpenEditPanel={handleOpenEditPanel}
+ formatProjectLabel={formatProjectLabel}
+ />
+ );
+ })}
+
+
+ {/* Add project button */}
+
+
+
+
+ {/* Delete confirmation dialog */}
+
+
+
+ Remove Project
+
+ Are you sure you want to remove {projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)} ?
+
+
+
+ setDeleteDialogOpen(false)}>
+ Cancel
+
+
+ Remove
+
+
+
+
+
+ {/* Project edit panel */}
+
setEditPanelOpen(false)}
+ projects={projects}
+ onReorder={handleReorder}
+ onEdit={handleEditProject}
+ onDelete={handleDeleteProject}
+ homeDirectory={homeDirectory}
+ />
+
+ {/* Project edit dialog */}
+ {editingProject && (
+ {
+ if (!open) setEditingProject(null);
+ }}
+ projectId={editingProject.id}
+ projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
+ projectPath={editingProject.path}
+ initialIcon={editingProject.icon}
+ initialColor={editingProject.color}
+ initialIconBackground={editingProject.iconBackground}
+ onSave={handleSaveProjectEdit}
+ />
+ )}
+
+ );
+}
+
+function CollapsedView({
+ runningCount,
+ unreadCount,
+ currentSessionTitle,
+ currentProjectLabel,
+ currentProjectIcon,
+ currentProjectIconImageUrl,
+ currentProjectIconBackground,
+ currentProjectColor,
+ onToggle,
+ onNewSession,
+ cornerRadius,
+ contextUsage,
+ childIndicators = [],
+}: {
+ runningCount: number;
+ unreadCount: number;
+ currentSessionTitle: string;
+ currentProjectLabel?: string;
+ currentProjectIcon?: string | null;
+ currentProjectIconImageUrl?: string | null;
+ currentProjectIconBackground?: string | null;
+ currentProjectColor?: string | null;
+ onToggle: () => void;
+ onNewSession: () => void;
+ cornerRadius?: number;
+ contextUsage: SessionContextUsage | null;
+ childIndicators?: Array<{ session: Session; isRunning: boolean }>;
+}) {
+ const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onNewSession();
+ }}
+ className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-center"
+ >
+ New
+
+
+
+ );
+}
+
+function ExpandedView({
+ sessions,
+ currentSessionId,
+ runningCount,
+ unreadCount,
+ currentSessionTitle,
+ currentProjectLabel,
+ currentProjectIcon,
+ currentProjectIconImageUrl,
+ currentProjectIconBackground,
+ currentProjectColor,
+ isExpanded,
+ onToggleCollapse,
+ onNewSession,
+ onSessionClick,
+ onSessionDoubleClick,
+ onProjectSwitch,
+ onAddProject,
+ onRemoveProject,
+ getSessionAgentName,
+ getSessionTitle,
+ needsAttention,
+ cornerRadius,
+ contextUsage,
+ projects,
+ activeProjectId,
+ getProjectStatus,
+ homeDirectory,
+ childIndicators = [],
+}: {
+ sessions: SessionWithStatus[];
+ currentSessionId: string;
+ runningCount: number;
+ unreadCount: number;
+ currentSessionTitle: string;
+ currentProjectLabel?: string;
+ currentProjectIcon?: string | null;
+ currentProjectIconImageUrl?: string | null;
+ currentProjectIconBackground?: string | null;
+ currentProjectColor?: string | null;
+ isExpanded: boolean;
+ onToggleCollapse: () => void;
+ onNewSession: () => void;
+ onSessionClick: (id: string) => void;
+ onSessionDoubleClick?: () => void;
+ onProjectSwitch: (projectId: string) => void;
+ onAddProject: () => void;
+ onRemoveProject?: (projectId: string) => void;
+ getSessionAgentName: (s: Session) => string;
+ getSessionTitle: (s: Session) => string;
+ needsAttention: (sessionId: string) => boolean;
+ cornerRadius?: number;
+ contextUsage: SessionContextUsage | null;
+ projects: ProjectEntry[];
+ activeProjectId: string | null;
+ getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean };
+ homeDirectory: string | null;
+ childIndicators?: Array<{ session: Session; isRunning: boolean }>;
+}) {
+ const containerRef = React.useRef(null);
+ const [collapsedHeight, setCollapsedHeight] = React.useState(null);
+ const [hasMeasured, setHasMeasured] = React.useState(false);
+ const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
+ const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
+
+ React.useEffect(() => {
+ if (containerRef.current && !hasMeasured && !isExpanded) {
+ setCollapsedHeight(containerRef.current.offsetHeight);
+ setHasMeasured(true);
+ }
+ }, [hasMeasured, isExpanded]);
+
+ // Filter sessions by active project
+ const filteredSessions = React.useMemo(() => {
+ if (!activeProjectId) return sessions;
+
+ const activeProject = projects.find(p => p.id === activeProjectId);
+ if (!activeProject) return sessions;
+
+ const projectRoot = normalize(activeProject.path);
+ const projectDirs = new Set([projectRoot]);
+
+ // Add worktrees
+ const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
+ for (const meta of worktrees) {
+ const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
+ if (typeof p === 'string' && p.trim()) {
+ const normalized = normalize(p);
+ if (normalized) projectDirs.add(normalized);
+ }
+ }
+
+ return sessions.filter(session => {
+ const sessionDir = normalize((session as { directory?: string | null }).directory ?? '');
+ return projectDirs.has(sessionDir);
+ });
+ }, [sessions, activeProjectId, projects, availableWorktreesByProject]);
+
+ const previewHeight = collapsedHeight ?? undefined;
+ const displaySessions = hasMeasured || isExpanded
+ ? filteredSessions.filter(s => s.id !== currentSessionId)
+ : filteredSessions.slice(0, 3);
+
+ return (
+
+ {/* Header row */}
+
+
+
+
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onToggleCollapse();
+ }
+ }}
+ >
+
+
+
+ {
+ e.stopPropagation();
+ onNewSession();
+ }}
+ className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-start"
+ >
+ New
+
+
+
+
+ {/* Project switcher bar */}
+
+
+ {/* Sessions list */}
+
+ {displaySessions.length === 0 ? (
+
+ No sessions in this project
+
+ ) : (
+ displaySessions.map((session) => (
+
onSessionClick(session.id)}
+ onDoubleClick={onSessionDoubleClick}
+ needsAttention={needsAttention}
+ />
+ ))
+ )}
+
+
+ );
+}
+
+export const MobileSessionStatusBar: React.FC = ({
+ onSessionSwitch,
+ cornerRadius,
+}) => {
+ const { currentTheme } = useThemeSystem();
+ const sessions = useSessionStore((state) => state.sessions);
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const sessionStatus = useSessionStore((state) => state.sessionStatus);
+ const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
+ const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
+ const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
+ const getContextUsage = useSessionStore((state) => state.getContextUsage);
+ const agents = useConfigStore((state) => state.agents);
+ const { getCurrentModel } = useConfigStore();
+ const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
+ const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
+
+ // Project store
+ const projects = useProjectsStore((state) => state.projects);
+ const activeProjectId = useProjectsStore((state) => state.activeProjectId);
+ const setActiveProject = useProjectsStore((state) => state.setActiveProject);
+ const addProject = useProjectsStore((state) => state.addProject);
+ const removeProject = useProjectsStore((state) => state.removeProject);
+ const getActiveProject = useProjectsStore((state) => state.getActiveProject);
+
+ // Directory store
+ const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
+
+ const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
+ const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates);
+ const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId);
+
+ const currentSession = sessions.find((s) => s.id === currentSessionId);
+ const currentSessionTitle = currentSession
+ ? getSessionTitle(currentSession)
+ : '← Swipe here to open sidebars →';
+
+ // Calculate current session's child indicators
+ const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
+ const currentSessionChildIndicators = currentSessionWithStatus?._childIndicators ?? [];
+
+ const activeProject = getActiveProject();
+ const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
+ const currentProjectIcon = activeProject?.icon;
+ const currentProjectIconImageUrl = activeProject
+ ? getProjectIconImageUrl(activeProject, {
+ themeVariant: currentTheme.metadata.variant,
+ iconColor: currentTheme.colors.surface.foreground,
+ })
+ : null;
+ const currentProjectIconBackground = activeProject?.iconBackground ?? null;
+ const currentProjectColor = activeProject?.color;
+
+ // Calculate token usage for current session
+ const currentModel = getCurrentModel();
+ const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
+ ? (currentModel.limit as Record)
+ : null;
+ const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
+ const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
+ const contextUsage = getContextUsage(contextLimit, outputLimit);
+
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
+
+ if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) {
+ return null;
+ }
+
+ const handleSessionClick = (sessionId: string) => {
+ setCurrentSession(sessionId);
+ onSessionSwitch?.(sessionId);
+ setIsExpanded(false);
+ };
+
+ const handleSessionDoubleClick = () => {
+ // On double-tap, switch to the Chat tab
+ setActiveMainTab('chat');
+ };
+
+ const handleCreateSession = () => {
+ openNewSessionDraft();
+ };
+
+ const handleProjectSwitch = (projectId: string) => {
+ if (projectId !== activeProjectId) {
+ setActiveProject(projectId);
+ }
+ };
+
+ const handleAddProject = () => {
+ if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
+ sessionEvents.requestDirectoryDialog();
+ return;
+ }
+ requestDirectoryAccess('')
+ .then((result) => {
+ if (result.success && result.path) {
+ const added = addProject(result.path, { id: result.projectId });
+ if (!added) {
+ toast.error('Failed to add project', {
+ description: 'Please select a valid directory.',
+ });
+ }
+ } else if (result.error && result.error !== 'Directory selection cancelled') {
+ toast.error('Failed to select directory', {
+ description: result.error,
+ });
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to select directory:', error);
+ toast.error('Failed to select directory');
+ });
+ };
+
+ if (isMobileSessionStatusBarCollapsed) {
+ return (
+ setIsMobileSessionStatusBarCollapsed(false)}
+ onNewSession={handleCreateSession}
+ cornerRadius={cornerRadius}
+ contextUsage={contextUsage}
+ childIndicators={currentSessionChildIndicators}
+ />
+ );
+ }
+
+ return (
+ {
+ setIsMobileSessionStatusBarCollapsed(true);
+ setIsExpanded(false);
+ }}
+ onNewSession={handleCreateSession}
+ onSessionClick={handleSessionClick}
+ onSessionDoubleClick={handleSessionDoubleClick}
+ onProjectSwitch={handleProjectSwitch}
+ onAddProject={handleAddProject}
+ onRemoveProject={removeProject}
+ getSessionAgentName={getSessionAgentName}
+ getSessionTitle={getSessionTitle}
+ needsAttention={needsAttention}
+ cornerRadius={cornerRadius}
+ contextUsage={contextUsage}
+ projects={projects}
+ activeProjectId={activeProjectId}
+ getProjectStatus={getProjectStatus}
+ homeDirectory={homeDirectory}
+ childIndicators={currentSessionChildIndicators}
+ />
+ );
+};
diff --git a/ui/src/components/chat/ModelControls.tsx b/ui/src/components/chat/ModelControls.tsx
new file mode 100644
index 0000000..e09c0e3
--- /dev/null
+++ b/ui/src/components/chat/ModelControls.tsx
@@ -0,0 +1,2852 @@
+import React from 'react';
+import type { ComponentType } from 'react';
+import {
+ RiAddLine,
+ RiAiAgentLine,
+ RiArrowDownSLine,
+ RiArrowGoBackLine,
+ RiArrowRightSLine,
+ RiBrainAi3Line,
+ RiCheckLine,
+ RiCheckboxCircleLine,
+ RiCloseCircleLine,
+ RiFileImageLine,
+ RiFileMusicLine,
+ RiFilePdfLine,
+ RiFileVideoLine,
+ RiPencilAiLine,
+ RiQuestionLine,
+ RiSearchLine,
+ RiStarFill,
+ RiStarLine,
+ RiText,
+ RiTimeLine,
+ RiToolsLine,
+} from '@remixicon/react';
+import type { EditPermissionMode } from '@/stores/types/sessionTypes';
+import type { ModelMetadata } from '@/types';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Input } from '@/components/ui/input';
+import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
+import { ProviderLogo } from '@/components/ui/ProviderLogo';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { Switch } from '@/components/ui/switch';
+import { TextLoop } from '@/components/ui/TextLoop';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
+import { isDesktopShell } from '@/lib/desktop';
+import { getAgentColor } from '@/lib/agentColors';
+import { useDeviceInfo } from '@/lib/device';
+import { getEditModeColors } from '@/lib/permissions/editModeColors';
+import { cn, fuzzyMatch } from '@/lib/utils';
+import { useContextStore } from '@/stores/contextStore';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useModelLists } from '@/hooks/useModelLists';
+import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
+import type { MobileControlsPanel } from './mobileControlsUtils';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type IconComponent = ComponentType;
+
+type ProviderModel = Record & { id?: string; name?: string };
+
+type PermissionAction = 'allow' | 'ask' | 'deny';
+type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
+
+const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+ const rules: PermissionRule[] = [];
+ for (const entry of value) {
+ if (!entry || typeof entry !== 'object') {
+ continue;
+ }
+ const candidate = entry as Partial;
+ if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
+ continue;
+ }
+ if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
+ continue;
+ }
+ rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
+ }
+ return rules;
+};
+
+const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
+ const rules = asPermissionRuleset(ruleset);
+ if (!rules || rules.length === 0) {
+ return undefined;
+ }
+
+ for (let i = rules.length - 1; i >= 0; i -= 1) {
+ const rule = rules[i];
+ if (rule.permission === permission && rule.pattern === '*') {
+ return rule.action;
+ }
+ }
+
+ for (let i = rules.length - 1; i >= 0; i -= 1) {
+ const rule = rules[i];
+ if (rule.permission === '*' && rule.pattern === '*') {
+ return rule.action;
+ }
+ }
+
+ return undefined;
+};
+
+interface CapabilityDefinition {
+ key: 'tool_call' | 'reasoning';
+ icon: IconComponent;
+ label: string;
+ isActive: (metadata?: ModelMetadata) => boolean;
+}
+
+const CAPABILITY_DEFINITIONS: CapabilityDefinition[] = [
+ {
+ key: 'tool_call',
+ icon: RiToolsLine,
+ label: 'Tool calling',
+ isActive: (metadata) => metadata?.tool_call === true,
+ },
+ {
+ key: 'reasoning',
+ icon: RiBrainAi3Line,
+ label: 'Reasoning',
+ isActive: (metadata) => metadata?.reasoning === true,
+ },
+];
+
+interface ModalityIconDefinition {
+ icon: IconComponent;
+ label: string;
+}
+
+type ModalityIcon = {
+ key: string;
+ icon: IconComponent;
+ label: string;
+};
+
+type ModelApplyResult = 'applied' | 'provider-missing' | 'model-missing';
+
+const MODALITY_ICON_MAP: Record = {
+ text: { icon: RiText, label: 'Text' },
+ image: { icon: RiFileImageLine, label: 'Image' },
+ video: { icon: RiFileVideoLine, label: 'Video' },
+ audio: { icon: RiFileMusicLine, label: 'Audio' },
+ pdf: { icon: RiFilePdfLine, label: 'PDF' },
+};
+
+const normalizeModality = (value: string) => value.trim().toLowerCase();
+
+const getModalityIcons = (metadata: ModelMetadata | undefined, direction: 'input' | 'output'): ModalityIcon[] => {
+ const modalityList = direction === 'input' ? metadata?.modalities?.input : metadata?.modalities?.output;
+ if (!Array.isArray(modalityList) || modalityList.length === 0) {
+ return [];
+ }
+
+ const uniqueValues = Array.from(new Set(modalityList.map((item) => normalizeModality(item))));
+
+ return uniqueValues
+ .map((modality) => {
+ const definition = MODALITY_ICON_MAP[modality];
+ if (!definition) {
+ return null;
+ }
+ return {
+ key: modality,
+ icon: definition.icon,
+ label: definition.label,
+ } satisfies ModalityIcon;
+ })
+ .filter((entry): entry is ModalityIcon => Boolean(entry));
+};
+
+const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ compactDisplay: 'short',
+ maximumFractionDigits: 1,
+ minimumFractionDigits: 0,
+});
+
+const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 4,
+ minimumFractionDigits: 2,
+});
+
+const ADD_PROVIDER_ID = '__add_provider__';
+
+const formatTokens = (value?: number | null) => {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return '—';
+ }
+
+ if (value === 0) {
+ return '0';
+ }
+
+ const formatted = COMPACT_NUMBER_FORMATTER.format(value);
+ return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
+};
+
+const formatCost = (value?: number | null) => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return '—';
+ }
+
+ return CURRENCY_FORMATTER.format(value);
+};
+
+const formatCompactPrice = (metadata?: ModelMetadata): string | null => {
+ if (!metadata?.cost) {
+ return null;
+ }
+
+ const inputCost = metadata.cost.input;
+ const outputCost = metadata.cost.output;
+ const hasInput = typeof inputCost === 'number' && Number.isFinite(inputCost);
+ const hasOutput = typeof outputCost === 'number' && Number.isFinite(outputCost);
+
+ if (hasInput && hasOutput) {
+ return `In ${formatCost(inputCost)} · Out ${formatCost(outputCost)}`;
+ }
+ if (hasInput) {
+ return `In ${formatCost(inputCost)}`;
+ }
+ if (hasOutput) {
+ return `Out ${formatCost(outputCost)}`;
+ }
+ return null;
+};
+
+const getCapabilityIcons = (metadata?: ModelMetadata) => {
+ return CAPABILITY_DEFINITIONS.filter((definition) => definition.isActive(metadata)).map((definition) => ({
+ key: definition.key,
+ icon: definition.icon,
+ label: definition.label,
+ }));
+};
+
+const formatKnowledge = (knowledge?: string) => {
+ if (!knowledge) {
+ return '—';
+ }
+
+ const match = knowledge.match(/^(\d{4})-(\d{2})$/);
+ if (match) {
+ const year = Number.parseInt(match[1], 10);
+ const monthIndex = Number.parseInt(match[2], 10) - 1;
+ const knowledgeDate = new Date(Date.UTC(year, monthIndex, 1));
+ if (!Number.isNaN(knowledgeDate.getTime())) {
+ return new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }).format(knowledgeDate);
+ }
+ }
+
+ return knowledge;
+};
+
+const formatDate = (value?: string) => {
+ if (!value) {
+ return '—';
+ }
+
+ const parsedDate = new Date(value);
+ if (Number.isNaN(parsedDate.getTime())) {
+ return value;
+ }
+
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(parsedDate);
+};
+
+interface ModelControlsProps {
+ className?: string;
+ mobilePanel?: MobileControlsPanel;
+ onMobilePanelChange?: (panel: MobileControlsPanel) => void;
+ onMobilePanelSelection?: () => void;
+ onAgentPanelSelection?: () => void;
+}
+
+export const ModelControls: React.FC = ({
+ className,
+ mobilePanel,
+ onMobilePanelChange,
+ onMobilePanelSelection,
+ onAgentPanelSelection,
+}) => {
+ const {
+ providers,
+ currentProviderId,
+ currentModelId,
+ currentVariant,
+ currentAgentName,
+ settingsDefaultVariant,
+ settingsDefaultAgent,
+ setProvider,
+ setSelectedProvider,
+ setModel,
+ setCurrentVariant,
+ getCurrentModelVariants,
+ setAgent,
+ getCurrentProvider,
+ getModelMetadata,
+ getCurrentAgent,
+ getVisibleAgents,
+ } = useConfigStore();
+
+ // Use visible agents (excludes hidden internal agents)
+ const agents = getVisibleAgents();
+ const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
+
+ const {
+ currentSessionId,
+ messages,
+ saveSessionAgentSelection,
+ saveAgentModelForSession,
+ getAgentModelForSession,
+ saveAgentModelVariantForSession,
+ getAgentModelVariantForSession,
+ analyzeAndSaveExternalSessionChoices,
+ } = useSessionStore();
+
+ const contextHydrated = useContextStore((state) => state.hasHydrated);
+
+ const sessionSavedAgentName = useContextStore((state) =>
+ currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null
+ );
+
+ const stickySessionAgentRef = React.useRef(null);
+ React.useEffect(() => {
+ if (!currentSessionId) {
+ stickySessionAgentRef.current = null;
+ return;
+ }
+ if (sessionSavedAgentName) {
+ stickySessionAgentRef.current = sessionSavedAgentName;
+ }
+ }, [currentSessionId, sessionSavedAgentName]);
+
+ const stickySessionAgentName = currentSessionId ? stickySessionAgentRef.current : null;
+
+ // Prefer per-session selection over global config to avoid flicker during server-driven mode switches.
+ const uiAgentName = currentSessionId
+ ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
+ : currentAgentName;
+
+ const sessionIdForEditMode = currentSessionId ?? '__global__';
+ const sessionEditMode = useContextStore((state) => {
+ if (!uiAgentName) {
+ return undefined;
+ }
+ return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask');
+ });
+ const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode);
+ const {
+ toggleFavoriteModel,
+ isFavoriteModel,
+ collapsedModelProviders,
+ toggleModelProviderCollapsed,
+ addRecentModel,
+ addRecentAgent,
+ addRecentEffort,
+ isModelSelectorOpen,
+ setModelSelectorOpen,
+ setSettingsDialogOpen,
+ setSettingsPage,
+ } = useUIStore();
+ const hiddenModels = useUIStore((state) => state.hiddenModels);
+ const collapsedProviderSet = React.useMemo(
+ () => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)),
+ [collapsedModelProviders]
+ );
+
+ // Separate state for agent selector to avoid conflict with model selector
+ const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
+ const { favoriteModelsList, recentModelsList } = useModelLists();
+
+ const { isMobile } = useDeviceInfo();
+ const isDesktop = React.useMemo(() => isDesktopShell(), []);
+ const isVSCodeRuntime = useIsVSCodeRuntime();
+ // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
+ const isCompact = isMobile;
+ const [localMobilePanel, setLocalMobilePanel] = React.useState(null);
+ const usingExternalMobilePanel = mobilePanel !== undefined && typeof onMobilePanelChange === 'function';
+ const activeMobilePanel = usingExternalMobilePanel ? mobilePanel : localMobilePanel;
+ const setActiveMobilePanel = usingExternalMobilePanel ? onMobilePanelChange : setLocalMobilePanel;
+ const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
+ const [mobileModelQuery, setMobileModelQuery] = React.useState('');
+ const manualVariantSelectionRef = React.useRef(false);
+ const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), [setActiveMobilePanel]);
+ const closeMobileTooltip = React.useCallback(() => setMobileTooltipOpen(null), []);
+ const longPressTimerRef = React.useRef(undefined);
+ const [expandedMobileProviders, setExpandedMobileProviders] = React.useState>(() => {
+ const initial = new Set();
+ if (currentProviderId) {
+ initial.add(currentProviderId);
+ }
+ return initial;
+ });
+ // Use global state for model selector (allows Ctrl+M shortcut)
+ const agentMenuOpen = isModelSelectorOpen;
+ const setAgentMenuOpen = setModelSelectorOpen;
+ const openAddProviderSettings = React.useCallback(() => {
+ setSelectedProvider(ADD_PROVIDER_ID);
+ setSettingsPage('providers');
+ setSettingsDialogOpen(true);
+ setAgentMenuOpen(false);
+ closeMobilePanel();
+ }, [setSelectedProvider, setSettingsPage, setSettingsDialogOpen, setAgentMenuOpen, closeMobilePanel]);
+ const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
+ const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
+ const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+
+ React.useEffect(() => {
+ if (activeMobilePanel === 'model') {
+ setExpandedMobileProviders(() => {
+ const initial = new Set();
+ if (currentProviderId) {
+ initial.add(currentProviderId);
+ }
+ return initial;
+ });
+ }
+ }, [activeMobilePanel, currentProviderId]);
+
+ React.useEffect(() => {
+ if (activeMobilePanel !== 'model') {
+ setMobileModelQuery('');
+ }
+ }, [activeMobilePanel]);
+
+ // Handle model selector close behavior (separate from agent selector)
+ const prevModelSelectorOpenRef = React.useRef(isModelSelectorOpen);
+ React.useEffect(() => {
+ const wasOpen = prevModelSelectorOpenRef.current;
+ prevModelSelectorOpenRef.current = isModelSelectorOpen;
+
+ if (!isModelSelectorOpen) {
+ setDesktopModelQuery('');
+ setModelSelectedIndex(0);
+
+ // Restore focus to chat input when model selector closes
+ if (wasOpen && !isCompact) {
+ requestAnimationFrame(() => {
+ const textarea = document.querySelector('textarea[data-chat-input="true"]');
+ textarea?.focus();
+ });
+ }
+ }
+ }, [isModelSelectorOpen, isCompact]);
+
+ // Handle agent selector close behavior
+ const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
+ React.useEffect(() => {
+ if (!isAgentSelectorOpen) {
+ setAgentSearchQuery('');
+ if (!isCompact) {
+ requestAnimationFrame(() => {
+ const textarea = document.querySelector('textarea[data-chat-input="true"]');
+ textarea?.focus();
+ });
+ }
+ }
+ }, [isAgentSelectorOpen, isCompact]);
+
+ // Reset selected index when search query changes
+ React.useEffect(() => {
+ setModelSelectedIndex(0);
+ }, [desktopModelQuery]);
+
+ const selectableDesktopAgents = React.useMemo(() => {
+ return agents.filter((agent) => agent.mode !== 'subagent');
+ }, [agents]);
+
+ const sortedAndFilteredAgents = React.useMemo(() => {
+ const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
+ if (!agentSearchQuery.trim()) {
+ return sorted;
+ }
+ return sorted.filter((agent) =>
+ fuzzyMatch(agent.name, agentSearchQuery) ||
+ (agent.description && fuzzyMatch(agent.description, agentSearchQuery))
+ );
+ }, [selectableDesktopAgents, agentSearchQuery]);
+
+ const defaultAgentName = React.useMemo(() => {
+ if (settingsDefaultAgent) {
+ const found = selectableDesktopAgents.find(a => a.name === settingsDefaultAgent);
+ if (found) return found.name;
+ }
+ const buildAgent = selectableDesktopAgents.find(a => a.name === 'build');
+ if (buildAgent) return buildAgent.name;
+ return selectableDesktopAgents[0]?.name;
+ }, [settingsDefaultAgent, selectableDesktopAgents]);
+
+ const currentAgent = React.useMemo(() => {
+ if (uiAgentName) {
+ return agents.find((agent) => agent.name === uiAgentName);
+ }
+ return getCurrentAgent?.();
+ }, [agents, getCurrentAgent, uiAgentName]);
+
+ const agentEditAction = React.useMemo(() => {
+ if (!currentAgent) {
+ return 'deny';
+ }
+ return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
+ }, [currentAgent]);
+
+ const selectionContextReady = Boolean(uiAgentName);
+
+ const approveEditsAvailable = agentEditAction === 'ask';
+ const approveEditsChecked = approveEditsAvailable
+ ? sessionEditMode === 'allow' || sessionEditMode === 'full'
+ : agentEditAction === 'allow';
+ const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable;
+
+ const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
+ const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
+ const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
+ const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
+ const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
+ const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-2' : 'gap-x-3';
+ const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
+ const combinedClassName = cn(iconClass, 'flex-shrink-0');
+ const modeColors = getEditModeColors(mode);
+ const iconColor = modeColors ? modeColors.text : 'var(--foreground)';
+ const iconStyle = { color: iconColor };
+
+ if (mode === 'full') {
+ return ;
+ }
+ if (mode === 'allow') {
+ return ;
+ }
+ if (mode === 'deny') {
+ return ;
+ }
+ return ;
+ }, [editToggleIconClass]);
+
+ const handleApproveEditsToggle = React.useCallback((checked: boolean) => {
+ if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) {
+ return;
+ }
+ setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask');
+ }, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]);
+
+ const currentProvider = getCurrentProvider();
+ const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
+
+ const visibleProviders = React.useMemo(() => {
+ return providers
+ .map((provider) => {
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
+ const visibleModels = providerModels.filter((model: ProviderModel) => {
+ const modelId = typeof model?.id === 'string' ? model.id : '';
+ return !hiddenModels.some(
+ (item) => item.providerID === String(provider.id) && item.modelID === modelId
+ );
+ });
+ return { ...provider, models: visibleModels };
+ })
+ .filter((provider) => provider.models.length > 0);
+ }, [providers, hiddenModels]);
+
+ const currentMetadata =
+ currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined;
+ const currentCapabilityIcons = getCapabilityIcons(currentMetadata);
+ const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
+ const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
+
+ // Compute from current model each render to avoid stale variants
+ // in draft/session transitions.
+ const availableVariants = getCurrentModelVariants();
+ const hasVariants = availableVariants.length > 0;
+
+ const costRows = [
+ { label: 'Input', value: formatCost(currentMetadata?.cost?.input) },
+ { label: 'Output', value: formatCost(currentMetadata?.cost?.output) },
+ { label: 'Cache read', value: formatCost(currentMetadata?.cost?.cache_read) },
+ { label: 'Cache write', value: formatCost(currentMetadata?.cost?.cache_write) },
+ ];
+
+ const limitRows = [
+ { label: 'Context', value: formatTokens(currentMetadata?.limit?.context) },
+ { label: 'Output', value: formatTokens(currentMetadata?.limit?.output) },
+ ];
+
+ const prevAgentNameRef = React.useRef(undefined);
+
+ const currentSessionMessageCount = currentSessionId ? (messages.get(currentSessionId)?.length ?? -1) : -1;
+
+ const sessionInitializationRef = React.useRef<{
+ sessionId: string;
+ resolved: boolean;
+ inFlight: boolean;
+ } | null>(null);
+
+ // If we have an explicit per-session agent selection (eg. server-injected mode switch),
+ // treat the session as resolved and don't run inference/fallback that could cause flicker.
+ React.useEffect(() => {
+ if (!currentSessionId) {
+ return;
+ }
+ const refState = sessionInitializationRef.current;
+ if (!refState || refState.sessionId !== currentSessionId) {
+ return;
+ }
+
+ if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) {
+ refState.resolved = true;
+ refState.inFlight = false;
+ }
+ }, [agents, currentSessionId, sessionSavedAgentName]);
+
+ const tryApplyModelSelection = React.useCallback(
+ (providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
+ if (!providerId || !modelId) {
+ return 'model-missing';
+ }
+
+ const provider = providers.find(p => p.id === providerId);
+ if (!provider) {
+ return 'provider-missing';
+ }
+
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
+ const modelExists = providerModels.find((m: ProviderModel) => m.id === modelId);
+ if (!modelExists) {
+ return 'model-missing';
+ }
+
+ setProvider(providerId);
+ setModel(modelId);
+
+ if (currentSessionId && agentName) {
+ saveAgentModelForSession(currentSessionId, agentName, providerId, modelId);
+ }
+
+ return 'applied';
+ },
+ [providers, setProvider, setModel, currentSessionId, saveAgentModelForSession],
+ );
+
+ React.useEffect(() => {
+ if (!currentSessionId) {
+ sessionInitializationRef.current = null;
+ return;
+ }
+
+ if (!contextHydrated || providers.length === 0 || agents.length === 0) {
+ return;
+ }
+
+ if (!sessionInitializationRef.current || sessionInitializationRef.current.sessionId !== currentSessionId) {
+ sessionInitializationRef.current = { sessionId: currentSessionId, resolved: false, inFlight: false };
+ }
+
+ const state = sessionInitializationRef.current;
+ if (!state || state.resolved || state.inFlight) {
+ return;
+ }
+
+ let isCancelled = false;
+
+ const finalize = () => {
+ if (isCancelled) {
+ return;
+ }
+ const refState = sessionInitializationRef.current;
+ if (refState && refState.sessionId === currentSessionId) {
+ refState.resolved = true;
+ refState.inFlight = false;
+ }
+ };
+
+ const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => {
+ const savedAgentName = currentSessionId
+ ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
+ : null;
+ if (savedAgentName) {
+ if (currentAgentName !== savedAgentName) {
+ setAgent(savedAgentName);
+ }
+
+ const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
+ if (savedModel) {
+ const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
+ if (result === 'applied') {
+ return 'resolved';
+ }
+ if (result === 'provider-missing') {
+ return 'waiting';
+ }
+ } else {
+ return 'resolved';
+ }
+ }
+
+ for (const agent of agents) {
+ const selection = getAgentModelForSession(currentSessionId, agent.name);
+ if (!selection) {
+ continue;
+ }
+
+ if (currentAgentName !== agent.name) {
+ setAgent(agent.name);
+ }
+
+ const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
+ if (!existingSelection) {
+ saveSessionAgentSelection(currentSessionId, agent.name);
+ }
+ const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
+ if (result === 'applied') {
+ return 'resolved';
+ }
+ if (result === 'provider-missing') {
+ return 'waiting';
+ }
+ }
+
+ return 'continue';
+ };
+
+ const applyFallbackAgent = () => {
+ if (agents.length === 0) {
+ return;
+ }
+
+ const existingSelection = currentSessionId
+ ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
+ : null;
+
+ // If we already have a valid agent selected (often from server-injected mode switch),
+ // don't override it with a fallback.
+ const preferred =
+ (currentSessionId
+ ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
+ : null) ||
+ currentAgentName;
+ if (preferred && agents.some((agent) => agent.name === preferred)) {
+ if (currentAgentName !== preferred) {
+ setAgent(preferred);
+ }
+ return;
+ }
+
+ const fallbackAgent = agents.find(agent => agent.name === 'build') || primaryAgents[0] || agents[0];
+ if (!fallbackAgent) {
+ return;
+ }
+
+ if (!existingSelection) {
+ saveSessionAgentSelection(currentSessionId, fallbackAgent.name);
+ }
+
+ if (currentAgentName !== fallbackAgent.name) {
+ setAgent(fallbackAgent.name);
+ }
+
+ if (fallbackAgent.model?.providerID && fallbackAgent.model?.modelID) {
+ tryApplyModelSelection(fallbackAgent.model.providerID, fallbackAgent.model.modelID, fallbackAgent.name);
+ }
+ };
+
+ const resolveSessionPreferences = async () => {
+ try {
+ const savedOutcome = applySavedSelections();
+ if (savedOutcome === 'resolved') {
+ finalize();
+ return;
+ }
+ if (savedOutcome === 'waiting') {
+ return;
+ }
+
+ if (currentSessionMessageCount === -1) {
+ return;
+ }
+
+ if (currentSessionMessageCount > 0) {
+ state.inFlight = true;
+ try {
+ const discoveredChoices = await analyzeAndSaveExternalSessionChoices(currentSessionId, agents);
+ if (isCancelled) {
+ return;
+ }
+
+ if (discoveredChoices.size > 0) {
+ let latestAgent: string | null = null;
+ let latestTimestamp = -Infinity;
+
+ for (const [agentName, choice] of discoveredChoices) {
+ if (choice.timestamp > latestTimestamp) {
+ latestTimestamp = choice.timestamp;
+ latestAgent = agentName;
+ }
+ }
+
+ if (latestAgent) {
+ // If server/user already selected an agent for this session, don't override
+ // with heuristic inference mid-stream.
+ const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
+ if (latestSaved && latestSaved !== latestAgent) {
+ finalize();
+ return;
+ }
+
+ if (!latestSaved) {
+ saveSessionAgentSelection(currentSessionId, latestAgent);
+ }
+ if (currentAgentName !== latestAgent) {
+ setAgent(latestAgent);
+ }
+
+ const latestChoice = discoveredChoices.get(latestAgent);
+ if (latestChoice) {
+ const applyResult = tryApplyModelSelection(
+ latestChoice.providerId,
+ latestChoice.modelId,
+ latestAgent,
+ );
+
+ if (applyResult === 'applied') {
+ finalize();
+ return;
+ }
+
+ if (applyResult === 'provider-missing') {
+ return;
+ }
+ } else {
+ finalize();
+ return;
+ }
+ }
+ }
+ } catch (error) {
+ if (!isCancelled) {
+ console.error('[ModelControls] Error resolving session from messages:', error);
+ }
+ } finally {
+ const refState = sessionInitializationRef.current;
+ if (!isCancelled && refState && refState.sessionId === currentSessionId) {
+ refState.inFlight = false;
+ }
+ }
+ }
+
+ if (isCancelled) {
+ return;
+ }
+
+ applyFallbackAgent();
+ finalize();
+ } catch (error) {
+ if (!isCancelled) {
+ console.error('[ModelControls] Error in session switch:', error);
+ }
+ }
+ };
+
+ resolveSessionPreferences();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [
+ currentSessionId,
+ currentSessionMessageCount,
+ agents,
+ primaryAgents,
+ currentAgentName,
+ getAgentModelForSession,
+ setAgent,
+ tryApplyModelSelection,
+ analyzeAndSaveExternalSessionChoices,
+ saveSessionAgentSelection,
+ contextHydrated,
+ providers,
+ sessionSavedAgentName,
+ ]);
+
+ React.useEffect(() => {
+ if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) {
+ return;
+ }
+
+ const preferredAgent = sessionSavedAgentName || currentAgentName;
+ if (!preferredAgent) {
+ return;
+ }
+
+ const preferredSelection = getAgentModelForSession(currentSessionId, preferredAgent);
+ if (!preferredSelection) {
+ return;
+ }
+
+ const provider = providers.find(p => p.id === preferredSelection.providerId);
+ if (!provider) {
+ return;
+ }
+
+ const modelExists = Array.isArray(provider.models)
+ ? provider.models.some((m: ProviderModel) => m.id === preferredSelection.modelId)
+ : false;
+ if (!modelExists) {
+ return;
+ }
+
+ const providerMatches = currentProviderId === preferredSelection.providerId;
+ const modelMatches = currentModelId === preferredSelection.modelId;
+ if (providerMatches && modelMatches) {
+ return;
+ }
+
+ if (preferredAgent !== currentAgentName) {
+ setAgent(preferredAgent);
+ }
+
+ tryApplyModelSelection(preferredSelection.providerId, preferredSelection.modelId, preferredAgent);
+ }, [
+ contextHydrated,
+ currentSessionId,
+ currentAgentName,
+ currentProviderId,
+ currentModelId,
+ providers,
+ agents,
+ getAgentModelForSession,
+ tryApplyModelSelection,
+ setAgent,
+ sessionSavedAgentName,
+ ]);
+
+ React.useEffect(() => {
+ if (!contextHydrated) {
+ return;
+ }
+
+ const handleAgentSwitch = async () => {
+ try {
+ if (currentAgentName !== prevAgentNameRef.current) {
+ prevAgentNameRef.current = currentAgentName;
+
+ if (currentAgentName && currentSessionId) {
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
+
+ if (persistedChoice) {
+ const result = tryApplyModelSelection(
+ persistedChoice.providerId,
+ persistedChoice.modelId,
+ currentAgentName,
+ );
+ if (result === 'applied' || result === 'provider-missing') {
+ return;
+ }
+ }
+
+ const agent = agents.find(a => a.name === currentAgentName);
+ if (agent?.model?.providerID && agent?.model?.modelID) {
+ const result = tryApplyModelSelection(
+ agent.model.providerID,
+ agent.model.modelID,
+ currentAgentName,
+ );
+ if (result === 'provider-missing') {
+ return;
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error('[ModelControls] Agent change error:', error);
+ }
+ };
+
+ handleAgentSwitch();
+ }, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, agents, contextHydrated]);
+
+ React.useEffect(() => {
+ if (!contextHydrated || !currentAgentName) {
+ manualVariantSelectionRef.current = false;
+ setCurrentVariant(undefined);
+ return;
+ }
+
+ if (!currentProviderId || !currentModelId) {
+ manualVariantSelectionRef.current = false;
+ setCurrentVariant(undefined);
+ return;
+ }
+
+ if (availableVariants.length === 0) {
+ manualVariantSelectionRef.current = false;
+ setCurrentVariant(undefined);
+ return;
+ }
+
+ if (currentVariant && !availableVariants.includes(currentVariant)) {
+ setCurrentVariant(undefined);
+ return;
+ }
+
+ // Draft state (no session yet): seed from settings default, but don't override
+ // user selection while drafting.
+ if (!currentSessionId) {
+ if (!currentVariant && !manualVariantSelectionRef.current) {
+ const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
+ ? settingsDefaultVariant
+ : undefined;
+ setCurrentVariant(desired);
+ }
+ return;
+ }
+
+ const savedVariant = getAgentModelVariantForSession(
+ currentSessionId,
+ currentAgentName,
+ currentProviderId,
+ currentModelId,
+ );
+
+ const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
+ ? savedVariant
+ : undefined;
+
+ setCurrentVariant(resolvedSaved);
+ manualVariantSelectionRef.current = false;
+ }, [
+ availableVariants,
+ contextHydrated,
+ currentSessionId,
+ currentAgentName,
+ currentProviderId,
+ currentModelId,
+ currentVariant,
+ getAgentModelVariantForSession,
+ setCurrentVariant,
+ settingsDefaultVariant,
+ ]);
+
+ React.useEffect(() => {
+ manualVariantSelectionRef.current = false;
+ }, [currentProviderId, currentModelId]);
+
+ const handleVariantSelect = React.useCallback((variant: string | undefined) => {
+ manualVariantSelectionRef.current = true;
+ setCurrentVariant(variant);
+
+ if (currentProviderId && currentModelId) {
+ addRecentEffort(currentProviderId, currentModelId, variant);
+ }
+
+ if (currentSessionId && currentAgentName && currentProviderId && currentModelId) {
+ saveAgentModelVariantForSession(
+ currentSessionId,
+ currentAgentName,
+ currentProviderId,
+ currentModelId,
+ variant,
+ );
+ }
+ }, [
+ addRecentEffort,
+ currentAgentName,
+ currentModelId,
+ currentProviderId,
+ currentSessionId,
+ saveAgentModelVariantForSession,
+ setCurrentVariant,
+ ]);
+
+ const handleAgentChange = (agentName: string) => {
+ try {
+ setAgent(agentName);
+ addRecentAgent(agentName);
+ setAgentMenuOpen(false);
+
+ if (currentSessionId) {
+ saveSessionAgentSelection(currentSessionId, agentName);
+ }
+ if (isCompact) {
+ closeMobilePanel();
+ const callback = onAgentPanelSelection || onMobilePanelSelection;
+ if (callback) {
+ requestAnimationFrame(() => {
+ callback();
+ });
+ }
+ }
+ } catch (error) {
+ console.error('[ModelControls] Handle agent change error:', error);
+ }
+ };
+
+ const handleProviderAndModelChange = (providerId: string, modelId: string) => {
+ try {
+ const result = tryApplyModelSelection(providerId, modelId, currentAgentName || undefined);
+ if (result !== 'applied') {
+ if (result === 'provider-missing') {
+ console.error('[ModelControls] Provider not available for selection:', providerId);
+ } else if (result === 'model-missing') {
+ console.error('[ModelControls] Model not available for selection:', { providerId, modelId });
+ }
+ return;
+ }
+ // Add to recent models on successful selection
+ addRecentModel(providerId, modelId);
+ setAgentMenuOpen(false);
+ if (isCompact) {
+ closeMobilePanel();
+ if (onMobilePanelSelection) {
+ requestAnimationFrame(() => {
+ onMobilePanelSelection();
+ });
+ }
+ }
+ if (!isCompact || !onMobilePanelSelection) {
+ // Restore focus to chat input after model selection
+ requestAnimationFrame(() => {
+ const textarea = document.querySelector('textarea[data-chat-input="true"]');
+ textarea?.focus();
+ });
+ }
+ } catch (error) {
+ console.error('[ModelControls] Handle model change error:', error);
+ }
+ };
+
+ const getModelDisplayName = (model: ProviderModel | undefined) => {
+ const name = (typeof model?.name === 'string' ? model.name : (typeof model?.id === 'string' ? model.id : ''));
+ if (name.length > 40) {
+ return name.substring(0, 37) + '...';
+ }
+ return name;
+ };
+
+ const getProviderDisplayName = () => {
+ const provider = providers.find(p => p.id === currentProviderId);
+ return provider?.name || currentProviderId;
+ };
+
+ const getCurrentModelDisplayName = () => {
+ if (!currentProviderId || !currentModelId) return 'Not selected';
+ if (models.length === 0) return 'Not selected';
+ const currentModel = models.find((m: ProviderModel) => m.id === currentModelId);
+ return getModelDisplayName(currentModel);
+ };
+
+ const currentModelDisplayName = getCurrentModelDisplayName();
+ const modelLabelRef = React.useRef(null);
+ const isModelLabelTruncated = useIsTextTruncated(modelLabelRef, [currentModelDisplayName, isCompact]);
+
+ const getAgentDisplayName = () => {
+ if (!uiAgentName) {
+ const buildAgent = primaryAgents.find(agent => agent.name === 'build');
+ const defaultAgent = buildAgent || primaryAgents[0];
+ return defaultAgent ? capitalizeAgentName(defaultAgent.name) : 'Select Agent';
+ }
+ const agent = agents.find(a => a.name === uiAgentName);
+ return agent ? capitalizeAgentName(agent.name) : capitalizeAgentName(uiAgentName);
+ };
+
+ const capitalizeAgentName = (name: string) => {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ };
+
+ const renderIconBadge = (IconComp: IconComponent, label: string, key: string) => (
+
+
+
+ );
+
+ const toggleMobileProviderExpansion = React.useCallback((providerId: string) => {
+ setExpandedMobileProviders((prev) => {
+ const next = new Set(prev);
+ if (next.has(providerId)) {
+ next.delete(providerId);
+ } else {
+ next.add(providerId);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleLongPressStart = React.useCallback((type: 'model' | 'agent') => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ }
+ longPressTimerRef.current = setTimeout(() => {
+ setMobileTooltipOpen(type);
+ }, 500);
+ }, []);
+
+ const handleLongPressEnd = React.useCallback(() => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ return () => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ }
+ };
+ }, []);
+
+ const renderMobileModelTooltip = () => {
+ if (!isCompact || mobileTooltipOpen !== 'model') return null;
+
+ return (
+
+
+ {}
+
+
Provider
+
{getProviderDisplayName()}
+
+
+ {}
+ {currentCapabilityIcons.length > 0 && (
+
+
Capabilities
+
+ {currentCapabilityIcons.map(({ key, icon, label }) => (
+
+ {renderIconBadge(icon, label, `cap-${key}`)}
+ {label}
+
+ ))}
+
+
+ )}
+
+ {}
+ {(inputModalityIcons.length > 0 || outputModalityIcons.length > 0) && (
+
+
Modalities
+
+ {inputModalityIcons.length > 0 && (
+
+
Input
+
+ {inputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} input`, `input-${key}`))}
+
+
+ )}
+ {outputModalityIcons.length > 0 && (
+
+
Output
+
+ {outputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} output`, `output-${key}`))}
+
+
+ )}
+
+
+ )}
+
+ {}
+
+
Limits
+
+
+ Context
+ {formatTokens(currentMetadata?.limit?.context)}
+
+
+ Output
+ {formatTokens(currentMetadata?.limit?.output)}
+
+
+
+
+ {}
+
+
Metadata
+
+
+ Knowledge
+ {formatKnowledge(currentMetadata?.knowledge)}
+
+
+ Release
+ {formatDate(currentMetadata?.release_date)}
+
+
+
+
+
+ );
+ };
+
+ const renderMobileAgentTooltip = () => {
+ if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
+
+ const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
+ const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
+ const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
+
+ const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
+ const rules = asPermissionRuleset(currentAgent.permission) ?? [];
+ const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
+ const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
+
+ if (hasCustom) {
+ return { mode: 'ask', label: 'Custom' };
+ }
+
+ if (action === 'allow') return { mode: 'allow', label: 'Allow' };
+ if (action === 'deny') return { mode: 'deny', label: 'Deny' };
+ return { mode: 'ask', label: 'Ask' };
+ };
+
+ const editPermissionSummary = summarizePermission('edit');
+ const bashPermissionSummary = summarizePermission('bash');
+ const webfetchPermissionSummary = summarizePermission('webfetch');
+
+ return (
+
+
+ {}
+ {currentAgent.description && (
+
+
{currentAgent.description}
+
+ )}
+
+ {}
+
+
Mode
+
+ {currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
+
+
+
+ {}
+ {(hasModelConfig || hasTemperatureOrTopP) && (
+
+
Model
+ {hasModelConfig && (
+
+ {currentAgent.model!.providerID} / {currentAgent.model!.modelID}
+
+ )}
+ {hasTemperatureOrTopP && (
+
+ {currentAgent.temperature !== undefined && (
+
+ Temperature
+ {currentAgent.temperature}
+
+ )}
+ {currentAgent.topP !== undefined && (
+
+ Top P
+ {currentAgent.topP}
+
+ )}
+
+ )}
+
+ )}
+
+
+ {}
+
+
Permissions
+
+
+
Edit
+
+ {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
+
+ {editPermissionSummary.label}
+
+
+
+
+
Bash
+
+ {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
+
+ {bashPermissionSummary.label}
+
+
+
+
+
WebFetch
+
+ {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
+
+ {webfetchPermissionSummary.label}
+
+
+
+
+
+
+ {}
+ {hasCustomPrompt && (
+
+ )}
+
+
+ );
+ };
+
+ const normalizeModelSearchValue = React.useCallback((value: string) => {
+ const lower = value.toLowerCase().trim();
+ const compact = lower.replace(/[^a-z0-9]/g, '');
+ const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
+ return { lower, compact, tokens };
+ }, []);
+
+ const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
+ const normalizedQuery = normalizeModelSearchValue(query);
+ if (!normalizedQuery.lower) {
+ return true;
+ }
+
+ const normalizedCandidate = normalizeModelSearchValue(candidate);
+ if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
+ return true;
+ }
+
+ if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
+ return true;
+ }
+
+ if (normalizedQuery.tokens.length === 0) {
+ return false;
+ }
+
+ return normalizedQuery.tokens.every((queryToken) =>
+ normalizedCandidate.tokens.some((candidateToken) =>
+ candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
+ )
+ );
+ }, [normalizeModelSearchValue]);
+
+ const renderMobileModelPanel = () => {
+ if (!isCompact) return null;
+
+ const normalizedQuery = mobileModelQuery.trim();
+ const filteredProviders = visibleProviders
+ .map((provider) => {
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
+ const matchesProvider = normalizedQuery.length === 0
+ ? true
+ : matchesModelSearch(provider.name, normalizedQuery) || matchesModelSearch(provider.id, normalizedQuery);
+ const matchingModels = normalizedQuery.length === 0
+ ? providerModels
+ : providerModels.filter((model: ProviderModel) => {
+ const name = getModelDisplayName(model);
+ const id = typeof model.id === 'string' ? model.id : '';
+ return matchesModelSearch(name, normalizedQuery) || matchesModelSearch(id, normalizedQuery);
+ });
+ return { provider, providerModels: matchingModels, matchesProvider };
+ })
+ .filter(({ matchesProvider, providerModels }) => matchesProvider || providerModels.length > 0);
+
+ return (
+
+
+
+
+
+ setMobileModelQuery(event.target.value)}
+ placeholder="Search providers or models"
+ className="pl-7 h-9 rounded-xl border-border/40 bg-[var(--surface-elevated)] typography-meta"
+ />
+ {mobileModelQuery && (
+ setMobileModelQuery('')}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+ {filteredProviders.length === 0 && (
+
+ No providers or models match your search.
+
+ )}
+
+ {/* Favorites Section for Mobile */}
+ {!mobileModelQuery && favoriteModelsList.length > 0 && (
+
+
+
+ Favorites
+
+
+ {favoriteModelsList.map(({ model, providerID, modelID }) => {
+ const isSelected = providerID === currentProviderId && modelID === currentModelId;
+ const metadata = getModelMetadata(providerID, modelID);
+
+ return (
+
handleProviderAndModelChange(providerID, modelID)}
+ className={cn(
+ 'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
+ 'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
+ 'first:rounded-t-xl last:rounded-b-xl transition-colors',
+ isSelected ? 'bg-interactive-selection/15 text-interactive-selection-foreground' : 'hover:bg-interactive-hover'
+ )}
+ >
+
+
+
+ {getModelDisplayName(model)}
+
+
+
+ {(metadata?.limit?.context || metadata?.limit?.output) && (
+
+ {metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
+ {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
+ {metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Recent Section for Mobile */}
+ {!mobileModelQuery && recentModelsList.length > 0 && (
+
+
+
+ Recent
+
+
+ {recentModelsList.map(({ model, providerID, modelID }) => {
+ const isSelected = providerID === currentProviderId && modelID === currentModelId;
+ const metadata = getModelMetadata(providerID, modelID);
+
+ return (
+
handleProviderAndModelChange(providerID, modelID)}
+ className={cn(
+ 'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
+ 'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
+ 'first:rounded-t-xl last:rounded-b-xl transition-colors',
+ isSelected ? 'bg-interactive-selection/15 text-interactive-selection-foreground' : 'hover:bg-interactive-hover'
+ )}
+ >
+
+
+
+ {getModelDisplayName(model)}
+
+
+
+ {(metadata?.limit?.context || metadata?.limit?.output) && (
+
+ {metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
+ {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
+ {metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {filteredProviders.map(({ provider, providerModels }) => {
+ if (providerModels.length === 0 && !normalizedQuery.length) {
+ return null;
+ }
+
+ const isActiveProvider = provider.id === currentProviderId;
+ const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0;
+
+ return (
+
+
toggleMobileProviderExpansion(provider.id)}
+ className="flex w-full items-center justify-between gap-1.5 px-2 py-1.5 text-left"
+ aria-expanded={isExpanded}
+ >
+
+
+
+ {provider.name}
+
+ {isActiveProvider && (
+
Current
+ )}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {isExpanded && providerModels.length > 0 && (
+
+ {providerModels.map((model: ProviderModel) => {
+ const isSelected = isActiveProvider && model.id === currentModelId;
+ const metadata = getModelMetadata(provider.id, model.id!);
+ const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3);
+ const inputIcons = getModalityIcons(metadata, 'input');
+
+ return (
+
+
handleProviderAndModelChange(provider.id as string, model.id as string)}
+ className={cn(
+ 'flex flex-1 min-w-0 items-start gap-2 text-left',
+ 'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary'
+ )}
+ >
+
+
+ {getModelDisplayName(model)}
+
+
+
+ {(metadata?.limit?.context || metadata?.limit?.output) && (
+
+ {metadata?.limit?.context ? {formatTokens(metadata?.limit?.context)} ctx : null}
+ {metadata?.limit?.context && metadata?.limit?.output ? • : null}
+ {metadata?.limit?.output ? {formatTokens(metadata?.limit?.output)} out : null}
+
+ )}
+ {(capabilityIcons.length > 0 || inputIcons.length > 0) && (
+
+ {[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
+
+
+
+ ))}
+
+ )}
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ toggleFavoriteModel(provider.id as string, model.id as string);
+ }}
+ className={cn(
+ "model-favorite-button flex h-5 w-5 items-center justify-center hover:text-primary/80 flex-shrink-0",
+ isFavoriteModel(provider.id as string, model.id as string)
+ ? "text-primary"
+ : "text-muted-foreground"
+ )}
+ aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
+ title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"}
+ >
+ {isFavoriteModel(provider.id as string, model.id as string) ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+ );
+ };
+
+ const renderMobileVariantPanel = () => {
+ if (!isCompact || !hasVariants) return null;
+
+ const isDefault = !currentVariant;
+
+ const handleSelect = (variant: string | undefined) => {
+ handleVariantSelect(variant);
+ closeMobilePanel();
+ if (onMobilePanelSelection) {
+ requestAnimationFrame(() => {
+ onMobilePanelSelection();
+ });
+ return;
+ }
+ requestAnimationFrame(() => {
+ const textarea = document.querySelector('textarea[data-chat-input="true"]');
+ textarea?.focus();
+ });
+ };
+
+ return (
+
+
+ handleSelect(undefined)}
+ >
+ Default
+ {isDefault && }
+
+
+ {availableVariants.map((variant) => {
+ const selected = currentVariant === variant;
+ const label = variant.charAt(0).toUpperCase() + variant.slice(1);
+
+ return (
+ handleSelect(variant)}
+ >
+ {label}
+ {selected && }
+
+ );
+ })}
+
+
+ );
+ };
+
+ const renderMobileAgentPanel = () => {
+ if (!isCompact) return null;
+
+ return (
+
+
+ Auto-approve edits
+
+
+
+ )}
+ >
+
+ {selectableDesktopAgents.map((agent) => {
+ const isSelected = agent.name === uiAgentName;
+ const agentColor = getAgentColor(agent.name);
+ return (
+
handleAgentChange(agent.name)}
+ >
+
+
+
+ {capitalizeAgentName(agent.name)}
+
+ {isSelected && (
+
+ )}
+
+ {agent.description && (
+
+ {agent.description}
+
+ )}
+
+ );
+ })}
+
+
+ );
+ };
+
+ const renderModelTooltipContent = () => (
+
+ {currentMetadata ? (
+
+
+
+ {currentMetadata.name || getCurrentModelDisplayName()}
+
+ {getProviderDisplayName()}
+
+
+
Capabilities
+
+ {currentCapabilityIcons.length > 0 ? (
+ currentCapabilityIcons.map(({ key, icon, label }) =>
+ renderIconBadge(icon, label, `cap-${key}`)
+ )
+ ) : (
+ —
+ )}
+
+
+
+
Modalities
+
+
+
Input
+
+ {inputModalityIcons.length > 0
+ ? inputModalityIcons.map(({ key, icon, label }) =>
+ renderIconBadge(icon, `${label} input`, `input-${key}`)
+ )
+ : — }
+
+
+
+
Output
+
+ {outputModalityIcons.length > 0
+ ? outputModalityIcons.map(({ key, icon, label }) =>
+ renderIconBadge(icon, `${label} output`, `output-${key}`)
+ )
+ : — }
+
+
+
+
+
+
Cost ($/1M tokens)
+ {costRows.map((row) => (
+
+ {row.label}
+ {row.value}
+
+ ))}
+
+
+
Limits
+ {limitRows.map((row) => (
+
+ {row.label}
+ {row.value}
+
+ ))}
+
+
+
Metadata
+
+ Knowledge
+ {formatKnowledge(currentMetadata.knowledge)}
+
+
+ Release
+ {formatDate(currentMetadata.release_date)}
+
+
+
+ ) : (
+ Model metadata unavailable.
+ )}
+
+ );
+
+ // Helper to render a single model row in the flat dropdown
+ const renderModelRow = (
+ model: ProviderModel,
+ providerID: string,
+ modelID: string,
+ keyPrefix: string,
+ flatIndex: number,
+ isHighlighted: boolean
+ ) => {
+ const metadata = getModelMetadata(providerID, modelID);
+ const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({
+ ...icon,
+ id: `cap-${icon.key}`,
+ }));
+ const modalityIcons = [
+ ...getModalityIcons(metadata, 'input'),
+ ...getModalityIcons(metadata, 'output'),
+ ];
+ const uniqueModalityIcons = Array.from(
+ new Map(modalityIcons.map((icon) => [icon.key, icon])).values()
+ ).map((icon) => ({ ...icon, id: `mod-${icon.key}` }));
+ const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons];
+ const contextTokens = formatTokens(metadata?.limit?.context);
+ const isSelected = currentProviderId === providerID && currentModelId === modelID;
+ const isFavorite = isFavoriteModel(providerID, modelID);
+
+ const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
+
+ // Build animated metadata slides for desktop
+ const priceText = formatCompactPrice(metadata);
+ const hasPrice = priceText !== null;
+ const hasCapabilities = indicatorIcons.length > 0;
+
+ // Build slides array: price first, then capabilities
+ const slides: React.ReactNode[] = [];
+ if (hasPrice) {
+ slides.push(
+
+ {priceText}
+
+ );
+ }
+ if (hasCapabilities) {
+ slides.push(
+
+ {indicatorIcons.map(({ id, icon: Icon, label }) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ // Rotate metadata in interactive desktop-style pickers (web/desktop), keep VS Code static.
+ const supportsRotatingMetadata = !isVSCodeRuntime;
+ const shouldAnimate = supportsRotatingMetadata && slides.length > 1 && (isHighlighted || isSelected);
+ const staticSlideIndex = !supportsRotatingMetadata && hasCapabilities && hasPrice ? 1 : 0;
+ const staticMetadataSlide = slides[staticSlideIndex];
+
+ return (
+ { modelItemRefs.current[flatIndex] = el; }}
+ className={cn(
+ "typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer",
+ isHighlighted ? "bg-interactive-selection" : "hover:bg-interactive-hover/50"
+ )}
+ onClick={() => handleProviderAndModelChange(providerID, modelID)}
+ onMouseEnter={() => setModelSelectedIndex(flatIndex)}
+ >
+
+ {showProviderLogo && (
+
+ )}
+
+ {getModelDisplayName(model)}
+
+ {metadata?.limit?.context ? (
+
+ {contextTokens}
+
+ ) : null}
+
+
+ {/* Metadata slot: animated TextLoop for desktop highlighted/selected rows, static otherwise */}
+ {slides.length > 0 && (
+
+ {shouldAnimate ? (
+
+ {slides}
+
+ ) : (
+ <>
+ {/* In static runtimes (VS Code), prefer capabilities over price when both exist. */}
+ {staticMetadataSlide}
+ >
+ )}
+
+ )}
+ {isSelected && (
+
+ )}
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ toggleFavoriteModel(providerID, modelID);
+ }}
+ className={cn(
+ "model-favorite-button flex h-4 w-4 items-center justify-center hover:text-primary/80",
+ isFavorite ? "text-primary" : "text-muted-foreground"
+ )}
+ aria-label={isFavorite ? "Unfavorite" : "Favorite"}
+ title={isFavorite ? "Remove from favorites" : "Add to favorites"}
+ >
+ {isFavorite ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+ };
+
+ // Filter models based on search query
+ const filterByQuery = (modelName: string, providerName: string, query: string) => {
+ if (!query.trim()) return true;
+ return (
+ matchesModelSearch(modelName, query) ||
+ matchesModelSearch(providerName, query)
+ );
+ };
+
+ const renderModelSelector = () => {
+ const normalizedDesktopQuery = desktopModelQuery.trim();
+ const forceExpandProviders = normalizedDesktopQuery.length > 0;
+
+ // Filter favorites
+ const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
+ const provider = providers.find(p => p.id === providerID);
+ const providerName = provider?.name || providerID;
+ const modelName = getModelDisplayName(model);
+ return filterByQuery(modelName, providerName, desktopModelQuery);
+ });
+
+ // Filter recents
+ const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
+ const provider = providers.find(p => p.id === providerID);
+ const providerName = provider?.name || providerID;
+ const modelName = getModelDisplayName(model);
+ return filterByQuery(modelName, providerName, desktopModelQuery);
+ });
+
+ // Filter providers and their models
+ const filteredProviders = visibleProviders
+ .map((provider) => {
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
+ const filteredModels = providerModels.filter((model: ProviderModel) => {
+ const modelName = getModelDisplayName(model);
+ return filterByQuery(modelName, provider.name || provider.id || '', desktopModelQuery);
+ });
+ return { ...provider, models: filteredModels };
+ })
+ .filter((provider) => provider.models.length > 0);
+
+ const providerSections = filteredProviders.map((provider) => {
+ const providerId = typeof provider.id === 'string' ? provider.id : '';
+ const isExpanded = forceExpandProviders || !collapsedProviderSet.has(providerId);
+ const models = Array.isArray(provider.models) ? (provider.models as ProviderModel[]) : [];
+ return {
+ provider,
+ isExpanded,
+ models,
+ visibleModels: isExpanded ? models : [],
+ };
+ });
+
+ const hasResults =
+ filteredFavorites.length > 0 ||
+ filteredRecents.length > 0 ||
+ filteredProviders.length > 0;
+
+ // Build flat list for keyboard navigation
+ type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
+ const flatModelList: FlatModelItem[] = [];
+
+ filteredFavorites.forEach(({ model, providerID, modelID }) => {
+ flatModelList.push({ model, providerID, modelID, section: 'fav' });
+ });
+ filteredRecents.forEach(({ model, providerID, modelID }) => {
+ flatModelList.push({ model, providerID, modelID, section: 'recent' });
+ });
+ providerSections.forEach(({ provider, visibleModels }) => {
+ visibleModels.forEach((model) => {
+ flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' });
+ });
+ });
+
+ const totalItems = flatModelList.length;
+
+ // Handle keyboard navigation
+ const handleModelKeyDown = (e: React.KeyboardEvent) => {
+ e.stopPropagation();
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setModelSelectedIndex((prev) => (prev + 1) % Math.max(1, totalItems));
+ // Scroll into view
+ setTimeout(() => {
+ const nextIndex = (modelSelectedIndex + 1) % Math.max(1, totalItems);
+ modelItemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, 0);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setModelSelectedIndex((prev) => (prev - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems));
+ // Scroll into view
+ setTimeout(() => {
+ const prevIndex = (modelSelectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems);
+ modelItemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, 0);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ const selectedItem = flatModelList[modelSelectedIndex];
+ if (selectedItem) {
+ handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID);
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ setAgentMenuOpen(false);
+ }
+ };
+
+ // Build index mapping for rendering
+ let currentFlatIndex = 0;
+
+ return (
+
+ {!isCompact ? (
+
+
+
+
+ {currentProviderId ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {currentModelDisplayName}
+
+
+
+
+
+
+ {/* Search Input */}
+
+
+
+ setDesktopModelQuery(e.target.value)}
+ onKeyDown={handleModelKeyDown}
+ className="pl-8 h-8 typography-meta"
+ autoFocus
+ />
+
+
+
+ {/* Scrollable content */}
+
+
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ openAddProviderSettings();
+ }
+ }}
+ className="typography-meta group flex items-center gap-1 rounded-md px-2 py-1.5 cursor-pointer hover:bg-interactive-hover/50"
+ >
+
+
+
+ Add new provider
+
+
+
+
+ {!hasResults && (
+
+ No models found
+
+ )}
+
+ {/* Favorites Section */}
+ {filteredFavorites.length > 0 && (
+ <>
+
+
+ Favorites
+
+ {filteredFavorites.map(({ model, providerID, modelID }) => {
+ const idx = currentFlatIndex++;
+ return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx);
+ })}
+ >
+ )}
+
+ {/* Recents Section */}
+ {filteredRecents.length > 0 && (
+ <>
+ {filteredFavorites.length > 0 &&
}
+
+
+ Recent
+
+ {filteredRecents.map(({ model, providerID, modelID }) => {
+ const idx = currentFlatIndex++;
+ return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx);
+ })}
+ >
+ )}
+
+ {/* Separator before providers */}
+ {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && (
+
+ )}
+
+ {/* All Providers - Flat List */}
+ {providerSections.map(({ provider, isExpanded, visibleModels }, index) => (
+
+ {index > 0 && }
+ {
+ if (forceExpandProviders) {
+ return;
+ }
+ toggleModelProviderCollapsed(String(provider.id));
+ setModelSelectedIndex(0);
+ }}
+ onKeyDown={(event) => {
+ if (forceExpandProviders) {
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ toggleModelProviderCollapsed(String(provider.id));
+ setModelSelectedIndex(0);
+ }
+ }}
+ className={cn(
+ 'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30',
+ 'bg-[var(--surface-elevated)] text-left transition-colors',
+ forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
+ )}
+ aria-expanded={isExpanded}
+ title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')}
+ >
+
+
+
{provider.name}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isExpanded && visibleModels.map((model: ProviderModel) => {
+ const idx = currentFlatIndex++;
+ return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx);
+ })}
+
+ ))}
+
+
+
+ {/* Keyboard hints footer */}
+
+ ↑↓ navigate • Enter select • Esc close
+
+
+
+ ) : (
+ setActiveMobilePanel('model')}
+ onTouchStart={() => handleLongPressStart('model')}
+ onTouchEnd={handleLongPressEnd}
+ onTouchCancel={handleLongPressEnd}
+ className={cn(
+ 'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none',
+ 'cursor-pointer hover:bg-transparent hover:opacity-70',
+ buttonHeight
+ )}
+ >
+ {currentProviderId ? (
+
+ ) : (
+
+ )}
+
+
+ {currentModelDisplayName}
+
+
+
+ )}
+ {renderModelTooltipContent()}
+
+ );
+ };
+
+ const renderAgentTooltipContent = () => {
+ if (!currentAgent) {
+ return (
+
+ No agent selected.
+
+ );
+ }
+
+ const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
+ const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
+ const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
+
+ const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
+ const rules = asPermissionRuleset(currentAgent.permission) ?? [];
+ const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
+ const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
+
+ if (hasCustom) {
+ return { mode: 'ask', label: 'Custom' };
+ }
+
+ if (action === 'allow') return { mode: 'allow', label: 'Allow' };
+ if (action === 'deny') return { mode: 'deny', label: 'Deny' };
+ return { mode: 'ask', label: 'Ask' };
+ };
+
+ const editPermissionSummary = summarizePermission('edit');
+ const bashPermissionSummary = summarizePermission('bash');
+ const webfetchPermissionSummary = summarizePermission('webfetch');
+
+ return (
+
+
+
+
+ {capitalizeAgentName(currentAgent.name)}
+
+ {currentAgent.description && (
+ {currentAgent.description}
+ )}
+
+
+
+ Mode
+
+ {currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
+
+
+
+ {(hasModelConfig || hasTemperatureOrTopP) && (
+
+
Model
+ {hasModelConfig ? (
+
+ {currentAgent.model!.providerID} / {currentAgent.model!.modelID}
+
+ ) : (
+
—
+ )}
+ {hasTemperatureOrTopP && (
+
+ {currentAgent.temperature !== undefined && (
+
+ Temperature
+ {currentAgent.temperature}
+
+ )}
+ {currentAgent.topP !== undefined && (
+
+ Top P
+ {currentAgent.topP}
+
+ )}
+
+ )}
+
+ )}
+
+
+
+
Permissions
+
+
Edit
+
+ {renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
+
+ {editPermissionSummary.label}
+
+
+
+
+
Bash
+
+ {renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
+
+ {bashPermissionSummary.label}
+
+
+
+
+
WebFetch
+
+ {renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
+
+ {webfetchPermissionSummary.label}
+
+
+
+
+
+ {hasCustomPrompt && (
+
+ Custom Prompt
+
+
+ )}
+
+
+ );
+ };
+
+ const renderVariantSelector = () => {
+ if (!hasVariants) {
+ return null;
+ }
+
+ const displayVariant = currentVariant ?? 'Default';
+ const isDefault = !currentVariant;
+ const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]';
+
+ if (isCompact) {
+ return (
+ setActiveMobilePanel('variant')}
+ className={cn(
+ 'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
+ buttonHeight,
+ 'cursor-pointer hover:bg-transparent hover:opacity-70',
+ )}
+ >
+
+
+ {displayVariant}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {displayVariant}
+
+
+
+
+
+ Thinking
+ handleVariantSelect(undefined)}>
+
+ Default
+ {isDefault && }
+
+
+ {availableVariants.length > 0 && }
+ {availableVariants.map((variant) => {
+ const selected = currentVariant === variant;
+ const label = variant.charAt(0).toUpperCase() + variant.slice(1);
+ return (
+ handleVariantSelect(variant)}
+ >
+
+ {label}
+ {selected && }
+
+
+ );
+ })}
+
+
+
+ Thinking: {displayVariant}
+
+
+ );
+ };
+
+ const renderAgentSelector = () => {
+ if (!isCompact) {
+ return (
+
+
+
+
+
+
+
+
+ {getAgentDisplayName()}
+
+
+
+
+
+
+
+
+ setAgentSearchQuery(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ }}
+ className="pl-8 h-8 typography-meta"
+ autoFocus
+ />
+
+
+
+
+ {!agentSearchQuery.trim() && defaultAgentName && (
+ <>
+
handleAgentChange(defaultAgentName)}
+ >
+
+
+ Reset to default
+
+
+
+ >
+ )}
+ {sortedAndFilteredAgents.length === 0 ? (
+
+ No agents found
+
+ ) : (
+ sortedAndFilteredAgents.map((agent) => (
+
handleAgentChange(agent.name)}
+ >
+
+
+
+
{capitalizeAgentName(agent.name)}
+
+ {agent.description && (
+
+ {agent.description}
+
+ )}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ Auto-approve edits
+
+
+
+
+
+
+
+ {renderAgentTooltipContent()}
+
+
+ );
+ }
+
+ return (
+ setActiveMobilePanel('agent')}
+ onTouchStart={() => handleLongPressStart('agent')}
+ onTouchEnd={handleLongPressEnd}
+ onTouchCancel={handleLongPressEnd}
+ className={cn(
+ 'model-controls__agent-trigger flex items-center gap-1.5 transition-colors min-w-0 focus:outline-none',
+ buttonHeight,
+ 'cursor-pointer hover:bg-transparent hover:opacity-70',
+ )}
+ >
+
+
+ {getAgentDisplayName()}
+
+
+ );
+ };
+
+ const inlineClassName = cn(
+ '@container/model-controls flex items-center min-w-0',
+ // Only force full-width + truncation behaviors on true mobile layouts.
+ // VS Code also uses "compact" mode, but should keep its right-aligned inline sizing.
+ isMobile && 'w-full',
+ className,
+ );
+
+ return (
+ <>
+
+
+ {renderVariantSelector()}
+ {renderModelSelector()}
+ {renderAgentSelector()}
+
+
+
+ {renderMobileModelPanel()}
+ {renderMobileVariantPanel()}
+ {renderMobileAgentPanel()}
+ {renderMobileModelTooltip()}
+ {renderMobileAgentTooltip()}
+ >
+ );
+
+};
diff --git a/ui/src/components/chat/PermissionCard.tsx b/ui/src/components/chat/PermissionCard.tsx
new file mode 100644
index 0000000..2fee9c5
--- /dev/null
+++ b/ui/src/components/chat/PermissionCard.tsx
@@ -0,0 +1,468 @@
+import React from 'react';
+import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import type { PermissionRequest, PermissionResponse } from '@/types/permission';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { DiffPreview, WritePreview } from './DiffPreview';
+
+interface PermissionCardProps {
+ permission: PermissionRequest;
+ onResponse?: (response: 'once' | 'always' | 'reject') => void;
+}
+
+const getToolIcon = (toolName: string) => {
+ const iconClass = "h-3 w-3";
+ const tool = toolName.toLowerCase();
+
+ if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
+ return ;
+ }
+
+ if (tool === 'write' || tool === 'create' || tool === 'file_write') {
+ return ;
+ }
+
+ if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
+ return ;
+ }
+
+ if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
+ return ;
+ }
+
+ return ;
+};
+
+const getToolDisplayName = (toolName: string): string => {
+ const tool = toolName.toLowerCase();
+
+ if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
+ return 'edit';
+ }
+ if (tool === 'write' || tool === 'create' || tool === 'file_write') {
+ return 'write';
+ }
+ if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
+ return 'bash';
+ }
+ if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
+ return 'webfetch';
+ }
+
+ return toolName;
+};
+
+export const PermissionCard: React.FC = ({
+ permission,
+ onResponse
+}) => {
+ const [isResponding, setIsResponding] = React.useState(false);
+ const [hasResponded, setHasResponded] = React.useState(false);
+ const { respondToPermission } = useSessionStore();
+ const isFromSubagent = useSessionStore(
+ React.useCallback((state) => {
+ const currentSessionId = state.currentSessionId;
+ if (!currentSessionId || permission.sessionID === currentSessionId) return false;
+ const sourceSession = state.sessions.find((session) => session.id === permission.sessionID);
+ return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
+ }, [permission.sessionID])
+ );
+ const { currentTheme } = useThemeSystem();
+ const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
+
+ const handleResponse = async (response: PermissionResponse) => {
+ setIsResponding(true);
+
+ try {
+ await respondToPermission(permission.sessionID, permission.id, response);
+ setHasResponded(true);
+ onResponse?.(response);
+ } catch { /* ignored */ } finally {
+ setIsResponding(false);
+ }
+ };
+
+ if (hasResponded) {
+ return null;
+ }
+
+ const toolName = permission.permission || 'unknown';
+ const tool = toolName.toLowerCase();
+
+ const getMeta = (key: string, fallback: string = ''): string => {
+ const val = permission.metadata[key];
+ return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : fallback);
+ };
+ const getMetaNum = (key: string): number | undefined => {
+ const val = permission.metadata[key];
+ return typeof val === 'number' ? val : undefined;
+ };
+ const getMetaBool = (key: string): boolean => {
+ const val = permission.metadata[key];
+ return Boolean(val);
+ };
+ const displayToolName = getToolDisplayName(toolName);
+
+ const renderToolContent = () => {
+
+ if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
+ const command = getMeta('command') || getMeta('cmd') || getMeta('script');
+ const description = getMeta('description');
+ const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
+ const timeout = getMetaNum('timeout');
+
+ return (
+ <>
+ {description && (
+ {description}
+ )}
+ {workingDir && (
+
+ Working Directory: {workingDir}
+
+ )}
+ {timeout && (
+
+ Timeout: {timeout}ms
+
+ )}
+ {}
+ {command && (
+
+
+ {command}
+
+
+ )}
+ >
+ );
+ }
+
+ if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
+ const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
+ const changes = getMeta('changes') || getMeta('diff');
+ const replaceAll = getMetaBool('replace_all') || getMetaBool('replaceAll');
+
+ return (
+ <>
+ {replaceAll && (
+
+ ⚠️ Replace All Occurrences
+
+ )}
+ {changes && (
+
+
+
+ )}
+ >
+ );
+ }
+
+ if (tool === 'write' || tool === 'create' || tool === 'file_write') {
+ const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
+ const content = getMeta('content') || getMeta('text') || getMeta('data');
+
+ if (content) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ }
+
+ if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
+ const url = getMeta('url') || getMeta('uri') || getMeta('endpoint');
+ const method = getMeta('method') || 'GET';
+ const headers = permission.metadata.headers && typeof permission.metadata.headers === 'object' ? (permission.metadata.headers as Record) : undefined;
+ const body = getMeta('body') || getMeta('data') || getMeta('payload');
+ const timeout = getMetaNum('timeout');
+ const format = getMeta('format') || getMeta('responseType');
+
+ return (
+ <>
+ {url && (
+
+
Request:
+
+
+ {method}
+
+
+ {url}
+
+
+
+ )}
+ {headers && Object.keys(headers).length > 0 && (
+
+
Headers:
+
+
+ {JSON.stringify(headers, null, 2)}
+
+
+
+ )}
+ {body && (
+
+
Body:
+
+
+ {typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
+
+
+
+ )}
+ {(timeout || format) && (
+
+ {timeout && Timeout: {timeout}ms }
+ {timeout && format && • }
+ {format && Response format: {format} }
+
+ )}
+ >
+ );
+ }
+
+ const genericContent = getMeta('command') || getMeta('content') || getMeta('action') || getMeta('operation');
+ const description = getMeta('description');
+
+ return (
+ <>
+ {description && (
+ {description}
+ )}
+ {genericContent && (
+
+
Action:
+
+
+ {String(genericContent)}
+
+
+
+ )}
+ {}
+ {Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
+
+
Details:
+
+
+ {JSON.stringify(permission.metadata, null, 2)}
+
+
+
+ )}
+ >
+ );
+ };
+
+ return (
+
+
+
+ {}
+
+
+
+
+
+ Permission Required
+
+ {isFromSubagent ? (
+
+ From subagent
+
+ ) : null}
+
+
+ {getToolIcon(toolName)}
+ {displayToolName}
+
+
+
+
+ {}
+
+ {permission.patterns.length > 0 && (
+
+
Patterns:
+
+ {permission.patterns.join(", ")}
+
+
+ )}
+
+ {renderToolContent()}
+
+
+ {}
+
+
handleResponse('once')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1.5 sm:gap-1 px-3 sm:px-2 py-1.5 sm:py-1 typography-meta font-medium rounded transition-all min-h-[32px] sm:min-h-0 w-full sm:w-auto",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--status-success) / 0.1)',
+ color: 'var(--status-success)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
+ }}
+ >
+
+ Allow Once
+
+
+ {permission.always.length > 0 ? (
+
handleResponse('always')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1.5 sm:gap-1 px-3 sm:px-2 py-1.5 sm:py-1 typography-meta font-medium rounded transition-all min-h-[32px] sm:min-h-0 w-full sm:w-auto",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--muted) / 0.5)',
+ color: 'var(--muted-foreground)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
+ }}
+ >
+
+ {(() => {
+ const always = (permission.always as string[]) || (permission.metadata.always as string[]) || [];
+ if (always.length === 0) return "Always Allow";
+ const displayPatterns = always.slice(0, 2);
+ const text = displayPatterns.join(", ");
+ const hasMore = always.length > 2;
+ return (
+
+ {hasMore ? `Always: ${text}...` : `Always: ${text}`}
+
+ );
+ })()}
+
+ ) : (
+
handleResponse('always')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1.5 sm:gap-1 px-3 sm:px-2 py-1.5 sm:py-1 typography-meta font-medium rounded transition-all min-h-[32px] sm:min-h-0 w-full sm:w-auto",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--muted) / 0.5)',
+ color: 'var(--muted-foreground)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
+ }}
+ >
+
+ Always Allow
+
+ )}
+
+
handleResponse('reject')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1.5 sm:gap-1 px-3 sm:px-2 py-1.5 sm:py-1 typography-meta font-medium rounded transition-all min-h-[32px] sm:min-h-0 w-full sm:w-auto",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--status-error) / 0.1)',
+ color: 'var(--status-error)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
+ }}
+ >
+
+ Deny
+
+
+ {isResponding && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/ui/src/components/chat/PermissionRequest.tsx b/ui/src/components/chat/PermissionRequest.tsx
new file mode 100644
index 0000000..ab4fad3
--- /dev/null
+++ b/ui/src/components/chat/PermissionRequest.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
+import { useSessionStore } from '@/stores/useSessionStore';
+
+interface PermissionRequestProps {
+ permission: PermissionRequestPayload;
+ onResponse?: (response: 'once' | 'always' | 'reject') => void;
+}
+
+export const PermissionRequest: React.FC = ({
+ permission,
+ onResponse
+}) => {
+ const [isResponding, setIsResponding] = React.useState(false);
+ const [hasResponded, setHasResponded] = React.useState(false);
+ const { respondToPermission } = useSessionStore();
+
+ const handleResponse = async (response: PermissionResponse) => {
+ setIsResponding(true);
+
+ try {
+ await respondToPermission(permission.sessionID, permission.id, response);
+ setHasResponded(true);
+ onResponse?.(response);
+ } catch { /* ignored */ } finally {
+ setIsResponding(false);
+ }
+ };
+
+ if (hasResponded) {
+ return null;
+ }
+
+ const command = typeof permission.metadata.command === 'string'
+ ? permission.metadata.command
+ : (permission.patterns?.[0] ?? permission.permission);
+
+ return (
+
+
+
+
+ Permission required:
+
+
+ {command}
+
+
+
+
+
+
handleResponse('once')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ borderColor: 'var(--status-success)',
+ color: 'var(--status-success)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'var(--status-success-background)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+ Once
+
+
+
handleResponse('always')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ borderColor: 'var(--status-info)',
+ color: 'var(--status-info)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'var(--status-info-background)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+ Always
+
+
+
handleResponse('reject')}
+ disabled={isResponding}
+ className={cn(
+ "flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ borderColor: 'var(--status-error)',
+ color: 'var(--status-error)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'var(--status-error-background)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+ Reject
+
+
+ {isResponding && (
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/ui/src/components/chat/PermissionToastActions.tsx b/ui/src/components/chat/PermissionToastActions.tsx
new file mode 100644
index 0000000..af61d17
--- /dev/null
+++ b/ui/src/components/chat/PermissionToastActions.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+
+interface PermissionToastActionsProps {
+ sessionTitle: string;
+ permissionBody: string;
+ disabled?: boolean;
+ onOnce: () => Promise | void;
+ onAlways: () => Promise | void;
+ onDeny: () => Promise | void;
+}
+
+const truncateToastText = (value: string, maxLength: number): string => {
+ const normalized = value.trim();
+ if (normalized.length <= maxLength) {
+ return normalized;
+ }
+
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
+};
+
+export const PermissionToastActions: React.FC = ({
+ sessionTitle,
+ permissionBody,
+ disabled = false,
+ onOnce,
+ onAlways,
+ onDeny,
+}) => {
+ const [isBusy, setIsBusy] = React.useState(false);
+ const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : '';
+ const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session';
+ const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable';
+
+ const handleAction = async (action: () => Promise | void) => {
+ if (isBusy || disabled) return;
+ setIsBusy(true);
+ try {
+ await action();
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ Session:{' '}
+
+ {sessionPreview}
+
+
+
+ Permission:{' '}
+
+ {permissionPreview}
+
+
+
+
+
+ handleAction(onOnce)}
+ disabled={disabled || isBusy}
+ aria-label={`Approve once${actionContext}`}
+ className={cn(
+ "px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--status-success) / 0.1)',
+ color: 'var(--status-success)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
+ }}
+ >
+ Once
+
+
+ handleAction(onAlways)}
+ disabled={disabled || isBusy}
+ aria-label={`Approve always${actionContext}`}
+ className={cn(
+ "px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--muted) / 0.5)',
+ color: 'var(--muted-foreground)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
+ }}
+ >
+ Always
+
+
+ handleAction(onDeny)}
+ disabled={disabled || isBusy}
+ aria-label={`Deny permission${actionContext}`}
+ className={cn(
+ "px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
+ "disabled:opacity-50 disabled:cursor-not-allowed"
+ )}
+ style={{
+ backgroundColor: 'rgb(var(--status-error) / 0.1)',
+ color: 'var(--status-error)'
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
+ }}
+ >
+ Deny
+
+
+
+ );
+};
diff --git a/ui/src/components/chat/QuestionCard.tsx b/ui/src/components/chat/QuestionCard.tsx
new file mode 100644
index 0000000..fa6a670
--- /dev/null
+++ b/ui/src/components/chat/QuestionCard.tsx
@@ -0,0 +1,425 @@
+import React from 'react';
+import { RiArrowRightSLine, RiCheckLine, RiCloseLine, RiEditLine, RiListCheck3, RiQuestionLine } from '@remixicon/react';
+import { Checkbox } from '@/components/ui/checkbox';
+
+import { cn } from '@/lib/utils';
+import type { QuestionRequest } from '@/types/question';
+import { useSessionStore } from '@/stores/useSessionStore';
+
+interface QuestionCardProps {
+ question: QuestionRequest;
+}
+
+type TabKey = string;
+const SUMMARY_TAB = 'summary';
+
+export const QuestionCard: React.FC = ({ question }) => {
+ const { respondToQuestion, rejectQuestion } = useSessionStore();
+ const isFromSubagent = useSessionStore(
+ React.useCallback((state) => {
+ const currentSessionId = state.currentSessionId;
+ if (!currentSessionId || question.sessionID === currentSessionId) return false;
+ const sourceSession = state.sessions.find((session) => session.id === question.sessionID);
+ return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
+ }, [question.sessionID])
+ );
+ const [activeTab, setActiveTab] = React.useState('0');
+ const [isResponding, setIsResponding] = React.useState(false);
+ const [hasResponded, setHasResponded] = React.useState(false);
+
+ const [selectedOptions, setSelectedOptions] = React.useState>({});
+ const [customMode, setCustomMode] = React.useState>({});
+ const [customText, setCustomText] = React.useState>({});
+
+ const questions = React.useMemo(() => question.questions ?? [], [question.questions]);
+ const isSummaryTab = activeTab === SUMMARY_TAB;
+ const activeIndex = isSummaryTab ? -1 : Math.max(0, Math.min(questions.length - 1, Number(activeTab) || 0));
+ const activeQuestion = isSummaryTab ? null : questions[activeIndex];
+ const activeHeader = React.useMemo(() => {
+ if (isSummaryTab) return null;
+ const header = activeQuestion?.header?.trim();
+ return header && header.length > 0 ? header : null;
+ }, [activeQuestion?.header, isSummaryTab]);
+
+ React.useEffect(() => {
+ setActiveTab('0');
+ setSelectedOptions({});
+ setCustomMode({});
+ setCustomText({});
+ setHasResponded(false);
+ }, [question.id]);
+
+ const tabs = React.useMemo(() => {
+ const questionTabs = questions.map((q, index) => ({
+ value: String(index),
+ label: q.header?.trim() || `Q${index + 1}`,
+ }));
+ // Add summary tab when multiple questions
+ if (questions.length > 1) {
+ questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
+ }
+ return questionTabs;
+ }, [questions]);
+
+ // Helper to get answer display for a question index
+ const getAnswerDisplay = React.useCallback((index: number): string => {
+ const isCustom = Boolean(customMode[index]);
+ if (isCustom) {
+ const value = (customText[index] ?? '').trim();
+ return value || '(no answer)';
+ }
+ const answers = selectedOptions[index] ?? [];
+ return answers.length > 0 ? answers.join(', ') : '(no answer)';
+ }, [customMode, customText, selectedOptions]);
+
+ const isMultiple = Boolean(activeQuestion?.multiple);
+ const selectedForActive = selectedOptions[activeIndex] ?? [];
+ const isCustomActive = Boolean(customMode[activeIndex]);
+
+ const unansweredIndexes = React.useMemo(() => {
+ const pending: number[] = [];
+ for (let index = 0; index < questions.length; index += 1) {
+ const isCustom = Boolean(customMode[index]);
+ if (isCustom) {
+ const value = (customText[index] ?? '').trim();
+ if (!value) pending.push(index);
+ continue;
+ }
+
+ const answers = selectedOptions[index] ?? [];
+ if (answers.length === 0) {
+ pending.push(index);
+ }
+ }
+ return pending;
+ }, [customMode, customText, questions.length, selectedOptions]);
+
+ const requiredSatisfied = React.useMemo(() => {
+ if (questions.length === 0) return false;
+ return unansweredIndexes.length === 0;
+ }, [questions.length, unansweredIndexes.length]);
+
+ const handleNextUnanswered = React.useCallback(() => {
+ if (questions.length === 0 || unansweredIndexes.length === 0) return;
+
+ const start = isSummaryTab ? -1 : activeIndex;
+ for (let offset = 1; offset <= questions.length; offset += 1) {
+ const candidate = (start + offset + questions.length) % questions.length;
+ if (unansweredIndexes.includes(candidate)) {
+ setActiveTab(String(candidate));
+ return;
+ }
+ }
+
+ setActiveTab(String(unansweredIndexes[0]));
+ }, [activeIndex, isSummaryTab, questions.length, unansweredIndexes]);
+
+ const buildAnswersPayload = React.useCallback((): string[][] => {
+ const answers: string[][] = [];
+
+ for (let index = 0; index < questions.length; index += 1) {
+ const isCustom = Boolean(customMode[index]);
+ if (isCustom) {
+ const value = (customText[index] ?? '').trim();
+ answers.push(value ? [value] : []);
+ continue;
+ }
+
+ answers.push(selectedOptions[index] ?? []);
+ }
+
+ return answers;
+ }, [customMode, customText, questions.length, selectedOptions]);
+
+ const handleToggleOption = React.useCallback(
+ (label: string) => {
+ if (!activeQuestion) return;
+
+ setCustomMode((prev) => ({ ...prev, [activeIndex]: false }));
+
+ setSelectedOptions((prev) => {
+ const current = prev[activeIndex] ?? [];
+ if (isMultiple) {
+ const exists = current.includes(label);
+ const next = exists ? current.filter((item) => item !== label) : [...current, label];
+ return { ...prev, [activeIndex]: next };
+ }
+ return { ...prev, [activeIndex]: [label] };
+ });
+ },
+ [activeIndex, activeQuestion, isMultiple]
+ );
+
+ const handleSelectCustom = React.useCallback(() => {
+ setCustomMode((prev) => ({ ...prev, [activeIndex]: true }));
+ setSelectedOptions((prev) => ({ ...prev, [activeIndex]: [] }));
+ }, [activeIndex]);
+
+ const handleConfirm = React.useCallback(async () => {
+ if (!requiredSatisfied) return;
+
+ setIsResponding(true);
+ try {
+ const answers = buildAnswersPayload();
+ await respondToQuestion(question.sessionID, question.id, answers);
+ setHasResponded(true);
+ } catch {
+ // ignored
+ } finally {
+ setIsResponding(false);
+ }
+ }, [buildAnswersPayload, question.id, question.sessionID, requiredSatisfied, respondToQuestion]);
+
+ const handleDismiss = React.useCallback(async () => {
+ setIsResponding(true);
+ try {
+ await rejectQuestion(question.sessionID, question.id);
+ setHasResponded(true);
+ } catch {
+ // ignored
+ } finally {
+ setIsResponding(false);
+ }
+ }, [question.id, question.sessionID, rejectQuestion]);
+
+ if (hasResponded || questions.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+ Input needed
+ {isFromSubagent ? (
+
+ From subagent
+
+ ) : null}
+ {activeHeader ? (
+
+ {activeHeader}
+
+ ) : null}
+
+
+
+
+ {/* Minimal inline tabs for multiple questions */}
+ {tabs.length > 1 ? (
+
+ {tabs.map((tab) => {
+ const isActive = activeTab === tab.value;
+ const isSummary = tab.value === SUMMARY_TAB;
+ const tabIndex = isSummary ? -1 : Number(tab.value);
+ const isAnswered = !isSummary && Number.isFinite(tabIndex) && !unansweredIndexes.includes(tabIndex);
+ return (
+ setActiveTab(tab.value)}
+ className={cn(
+ 'px-2 py-0.5 typography-meta font-medium rounded transition-colors flex items-center gap-1',
+ isActive
+ ? 'bg-interactive-selection/40 text-foreground'
+ : isSummary
+ ? 'text-muted-foreground hover:text-foreground hover:bg-interactive-hover/20'
+ : isAnswered
+ ? 'text-muted-foreground/60 hover:text-muted-foreground hover:bg-interactive-hover/20'
+ : 'text-foreground/85 hover:text-foreground hover:bg-interactive-hover/20'
+ )}
+ >
+ {isSummary ? : null}
+ {tab.label}
+
+ );
+ })}
+
+ ) : null}
+
+ {/* Summary view */}
+ {isSummaryTab ? (
+
+ {questions.map((q, index) => {
+ const answer = getAnswerDisplay(index);
+ const hasAnswer = answer !== '(no answer)';
+ return (
+
setActiveTab(String(index))}
+ className="w-full text-left rounded px-1.5 py-1 hover:bg-interactive-hover/20 transition-colors"
+ >
+ {q.header || `Question ${index + 1}`}
+
+ {answer}
+
+
+ );
+ })}
+
+ ) : activeQuestion ? (
+ <>
+
{activeQuestion.question}
+
+ {isMultiple ? (
+
Select multiple
+ ) : null}
+
+
+ {activeQuestion.options.map((option, index) => {
+ const selected = selectedForActive.includes(option.label);
+ const recommended = /\(recommended\)/i.test(option.label);
+
+ return (
+
handleToggleOption(option.label)}
+ disabled={isResponding}
+ className={cn(
+ 'w-full px-1.5 py-1 text-left rounded transition-colors',
+ 'hover:bg-interactive-hover/30',
+ selected ? 'bg-interactive-selection/20' : null,
+ isResponding ? 'opacity-60 cursor-not-allowed' : null
+ )}
+ >
+
+
+ handleToggleOption(option.label)}
+ disabled={isResponding}
+ />
+
+
+
+
+
+ {option.label}
+
+ {recommended ? (
+ recommended
+ ) : null}
+
+ {option.description ? (
+
{option.description}
+ ) : null}
+
+
+
+ );
+ })}
+
+ {/* Custom answer option */}
+
+
+
+
+ Other…
+
+
+
+
+ {isCustomActive ? (
+
+ {
+ if (el) {
+ el.style.height = 'auto';
+ const lineHeight = 20; // approx typography-meta line height
+ const minHeight = lineHeight * 2;
+ const maxHeight = lineHeight * 4;
+ el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
+ }
+ }}
+ value={customText[activeIndex] ?? ''}
+ onChange={(event: React.ChangeEvent) => {
+ const el = event.target;
+ el.style.height = 'auto';
+ const lineHeight = 20;
+ const minHeight = lineHeight * 2;
+ const maxHeight = lineHeight * 4;
+ el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
+ setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
+ }}
+ placeholder="Your answer"
+ disabled={isResponding}
+ rows={2}
+ className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden"
+ autoFocus
+ />
+
+ ) : null}
+
+ >
+ ) : null}
+
+
+ {/* Footer actions */}
+
+
+ {requiredSatisfied ? : }
+ {requiredSatisfied ? 'Submit' : 'Next'}
+
+
+
+
+ Dismiss
+
+
+ {isResponding ? (
+
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/ui/src/components/chat/QueuedMessageChips.tsx b/ui/src/components/chat/QueuedMessageChips.tsx
new file mode 100644
index 0000000..b36ca38
--- /dev/null
+++ b/ui/src/components/chat/QueuedMessageChips.tsx
@@ -0,0 +1,116 @@
+import React, { memo } from 'react';
+import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
+import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useFileStore } from '@/stores/fileStore';
+
+interface QueuedMessageChipProps {
+ message: QueuedMessage;
+ sessionId: string;
+ onEdit: (message: QueuedMessage) => void;
+}
+
+const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
+ const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
+
+ // Get first line of message, truncated
+ const firstLine = React.useMemo(() => {
+ const lines = message.content.split('\n');
+ const first = lines[0] || '';
+ const maxLength = 100;
+ if (first.length > maxLength) {
+ return first.substring(0, maxLength) + '...';
+ }
+ return first + (lines.length > 1 ? '...' : '');
+ }, [message.content]);
+
+ const attachmentCount = message.attachments?.length ?? 0;
+
+ return (
+ onEdit(message)}
+ className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
+ >
+
+
+ Queued
+ {attachmentCount > 0 && (
+ +{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
+ )}
+
+
+ {firstLine || '(empty)'}
+
+ {
+ e.stopPropagation();
+ removeFromQueue(sessionId, message.id);
+ }}
+ className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
+ aria-label="Remove from queue"
+ >
+
+
+
+ );
+});
+
+QueuedMessageChip.displayName = 'QueuedMessageChip';
+
+interface QueuedMessageChipsProps {
+ onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
+}
+
+const EMPTY_QUEUE: QueuedMessage[] = [];
+
+export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const queuedMessages = useMessageQueueStore(
+ React.useCallback(
+ (state) => {
+ if (!currentSessionId) return EMPTY_QUEUE;
+ return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
+ },
+ [currentSessionId]
+ )
+ );
+ const popToInput = useMessageQueueStore((state) => state.popToInput);
+
+ const handleEdit = React.useCallback((message: QueuedMessage) => {
+ if (!currentSessionId) return;
+
+ const popped = popToInput(currentSessionId, message.id);
+ if (popped) {
+ // Restore attachments to file store if any
+ if (popped.attachments && popped.attachments.length > 0) {
+ const currentAttachments = useFileStore.getState().attachedFiles;
+ useFileStore.setState({
+ attachedFiles: [...currentAttachments, ...popped.attachments]
+ });
+ }
+ onEditMessage(popped.content, popped.attachments);
+ }
+ }, [currentSessionId, popToInput, onEditMessage]);
+
+ if (queuedMessages.length === 0 || !currentSessionId) {
+ return null;
+ }
+
+ return (
+
+ {queuedMessages.map((message) => (
+
+ ))}
+
+ );
+});
+
+QueuedMessageChips.displayName = 'QueuedMessageChips';
diff --git a/ui/src/components/chat/SkillAutocomplete.tsx b/ui/src/components/chat/SkillAutocomplete.tsx
new file mode 100644
index 0000000..f5f0c4e
--- /dev/null
+++ b/ui/src/components/chat/SkillAutocomplete.tsx
@@ -0,0 +1,172 @@
+import React from 'react';
+import { cn, fuzzyMatch } from '@/lib/utils';
+import { useSkillsStore } from '@/stores/useSkillsStore';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+
+interface SkillInfo {
+ name: string;
+ scope: string;
+ description?: string;
+}
+
+export interface SkillAutocompleteHandle {
+ handleKeyDown: (key: string) => void;
+}
+
+interface SkillAutocompleteProps {
+ searchQuery: string;
+ onSkillSelect: (skillName: string) => void;
+ onClose: () => void;
+ style?: React.CSSProperties;
+}
+
+export const SkillAutocomplete = React.forwardRef(({
+ searchQuery,
+ onSkillSelect,
+ onClose,
+ style,
+}, ref) => {
+ const containerRef = React.useRef(null);
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
+ const [filteredSkills, setFilteredSkills] = React.useState([]);
+ const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const { skills, loadSkills } = useSkillsStore();
+
+ React.useEffect(() => {
+ // Always trigger loadSkills when autocomplete opens to ensure project context is fresh
+ void loadSkills();
+ }, [loadSkills]);
+
+ React.useEffect(() => {
+ const normalizedQuery = searchQuery.trim();
+ const matches = normalizedQuery.length
+ ? skills.filter((skill) => fuzzyMatch(skill.name, normalizedQuery))
+ : skills;
+
+ const sorted = [...matches].sort((a, b) => {
+ // Sort by project scope first, then name
+ if (a.scope === 'project' && b.scope !== 'project') return -1;
+ if (a.scope !== 'project' && b.scope === 'project') return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ setFilteredSkills(sorted);
+ setSelectedIndex(0);
+ }, [skills, searchQuery]);
+
+ React.useEffect(() => {
+ itemRefs.current[selectedIndex]?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ });
+ }, [selectedIndex]);
+
+ React.useEffect(() => {
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (!target || !containerRef.current) {
+ return;
+ }
+ if (!containerRef.current.contains(target)) {
+ onClose();
+ }
+ };
+
+ document.addEventListener('pointerdown', handlePointerDown, true);
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown, true);
+ };
+ }, [onClose]);
+
+ React.useImperativeHandle(ref, () => ({
+ handleKeyDown: (key: string) => {
+ if (key === 'Escape') {
+ onClose();
+ return;
+ }
+
+ if (!filteredSkills.length) {
+ return;
+ }
+
+ if (key === 'ArrowDown') {
+ setSelectedIndex((prev) => (prev + 1) % filteredSkills.length);
+ return;
+ }
+
+ if (key === 'ArrowUp') {
+ setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length);
+ return;
+ }
+
+ if (key === 'Enter' || key === 'Tab') {
+ const skill = filteredSkills[selectedIndex];
+ if (skill) {
+ onSkillSelect(skill.name);
+ }
+ }
+ },
+ }), [filteredSkills, onSkillSelect, onClose, selectedIndex]);
+
+ const renderSkill = (skill: SkillInfo, index: number) => {
+ const isProject = skill.scope === 'project';
+ return (
+ {
+ itemRefs.current[index] = el;
+ }}
+ className={cn(
+ 'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
+ index === selectedIndex && 'bg-interactive-selection'
+ )}
+ onClick={() => onSkillSelect(skill.name)}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
+
+
+ {skill.name}
+
+ {skill.scope}
+
+
+ {skill.description && (
+
+ {skill.description}
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+
+ {filteredSkills.length ? (
+
+ {filteredSkills.map((skill, index) => renderSkill(skill, index))}
+
+ ) : (
+
+ No skills found
+
+ )}
+
+
+ ↑↓ navigate • Enter select • Esc close
+
+
+ );
+});
+
+SkillAutocomplete.displayName = 'SkillAutocomplete';
diff --git a/ui/src/components/chat/StatusChip.tsx b/ui/src/components/chat/StatusChip.tsx
new file mode 100644
index 0000000..e1d26f2
--- /dev/null
+++ b/ui/src/components/chat/StatusChip.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useContextStore } from '@/stores/contextStore';
+import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
+
+interface StatusChipProps {
+ onClick: () => void;
+ className?: string;
+}
+
+export const StatusChip: React.FC = ({ onClick, className }) => {
+ const {
+ currentModelId,
+ currentVariant,
+ currentAgentName,
+ getCurrentProvider,
+ getCurrentModelVariants,
+ getVisibleAgents,
+ } = useConfigStore();
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const sessionAgentName = useContextStore((state) =>
+ currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
+ );
+
+ const agents = getVisibleAgents();
+ const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
+ const agentLabel = getAgentDisplayName(agents, uiAgentName);
+ const currentProvider = getCurrentProvider();
+ const modelLabel = getModelDisplayName(currentProvider, currentModelId);
+ const hasEffort = getCurrentModelVariants().length > 0;
+ const effortLabel = hasEffort ? formatEffortLabel(currentVariant) : null;
+ const fullLabel = [agentLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
+
+ return (
+
+ {agentLabel}
+ ·
+ {modelLabel}
+ {effortLabel && (
+ <>
+ ·
+ {effortLabel}
+ >
+ )}
+
+ );
+};
+
+export default StatusChip;
diff --git a/ui/src/components/chat/StatusRow.tsx b/ui/src/components/chat/StatusRow.tsx
new file mode 100644
index 0000000..60d130c
--- /dev/null
+++ b/ui/src/components/chat/StatusRow.tsx
@@ -0,0 +1,294 @@
+import React from "react";
+import {
+ RiArrowDownSLine,
+ RiArrowUpDoubleLine,
+ RiArrowUpSLine,
+ RiCheckboxCircleLine,
+ RiCloseCircleLine,
+ RiRecordCircleLine,
+ RiTimeLine,
+} from "@remixicon/react";
+import { cn } from "@/lib/utils";
+import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
+import { useSessionStore } from "@/stores/useSessionStore";
+import { useUIStore } from "@/stores/useUIStore";
+import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
+import { isVSCodeRuntime } from "@/lib/desktop";
+
+const statusConfig: Record = {
+ in_progress: {
+ textClassName: "text-foreground",
+ },
+ pending: {
+ textClassName: "text-foreground",
+ },
+ completed: {
+ textClassName: "text-muted-foreground line-through",
+ },
+ cancelled: {
+ textClassName: "text-muted-foreground line-through",
+ },
+};
+
+const priorityClassName: Record = {
+ high: "text-[var(--status-warning)]",
+ medium: "text-muted-foreground",
+ low: "text-muted-foreground/70",
+};
+
+const priorityIcon: Record = {
+ high: ,
+ medium: ,
+ low: ,
+};
+
+interface TodoItemRowProps {
+ todo: TodoItem;
+}
+
+const TodoItemRow: React.FC = ({ todo }) => {
+ const config = statusConfig[todo.status] || statusConfig.pending;
+
+ const statusIcon =
+ todo.status === "in_progress" ? (
+
+ ) : todo.status === "completed" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {statusIcon}
+
+ {todo.content}
+
+
+ {priorityIcon[todo.priority] ?? priorityIcon.medium}
+
+
+ );
+};
+
+const EMPTY_TODOS: TodoItem[] = [];
+
+interface StatusRowProps {
+ // Working state
+ isWorking: boolean;
+ statusText: string | null;
+ isGenericStatus?: boolean;
+ isWaitingForPermission?: boolean;
+ wasAborted?: boolean;
+ abortActive?: boolean;
+ retryInfo?: { attempt?: number; next?: number } | null;
+ // Abort state (for mobile/vscode)
+ showAbort?: boolean;
+ onAbort?: () => void;
+ // Abort status display
+ showAbortStatus?: boolean;
+}
+
+export const StatusRow: React.FC = ({
+ isWorking,
+ statusText,
+ isGenericStatus,
+ isWaitingForPermission,
+ wasAborted,
+ abortActive,
+ retryInfo,
+ showAbort,
+ onAbort,
+ showAbortStatus,
+}) => {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const todos = useTodoStore((state) =>
+ currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS
+ );
+ const loadTodos = useTodoStore((state) => state.loadTodos);
+ const { isMobile } = useUIStore();
+ const isCompact = isMobile || isVSCodeRuntime();
+
+ // Load todos when session changes
+ React.useEffect(() => {
+ if (currentSessionId) {
+ void loadTodos(currentSessionId);
+ }
+ }, [currentSessionId, loadTodos]);
+
+ // Filter out cancelled todos for display and keep original order.
+ // This prevents items from jumping around when status changes.
+ const visibleTodos = React.useMemo(() => {
+ return todos.filter((todo) => todo.status !== "cancelled");
+ }, [todos]);
+
+ // Find the current active todo (first in_progress, or first pending)
+ const activeTodo = React.useMemo(() => {
+ return (
+ visibleTodos.find((t) => t.status === "in_progress") ||
+ visibleTodos.find((t) => t.status === "pending") ||
+ null
+ );
+ }, [visibleTodos]);
+
+ // Calculate progress
+ const progress = React.useMemo(() => {
+ const total = todos.filter((t) => t.status !== "cancelled").length;
+ const completed = todos.filter((t) => t.status === "completed").length;
+ return { completed, total };
+ }, [todos]);
+
+ const statusSummary = React.useMemo(() => {
+ const active = visibleTodos.filter((t) => t.status === "in_progress").length;
+ const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
+ return { active, left };
+ }, [visibleTodos]);
+
+ const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
+ // Original logic from ChatInput
+ const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
+
+ // Keep StatusRow rendered while:
+ // - isWorking (active session)
+ // - wasAborted / showAbortStatus
+ // - hasActiveTodos
+ const hasContent =
+ isWorking ||
+ Boolean(wasAborted) ||
+ Boolean(showAbortStatus) ||
+ hasActiveTodos;
+
+ // Close popover when clicking outside
+ const popoverRef = React.useRef(null);
+ React.useEffect(() => {
+ if (!isExpanded) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
+ setIsExpanded(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [isExpanded]);
+
+ const toggleExpanded = () => setIsExpanded((prev) => !prev);
+
+ // Abort button for mobile/vscode
+ const abortButton = showAbort && onAbort ? (
+
+
+
+ ) : null;
+
+ // Todo trigger button
+ const todoTrigger = hasActiveTodos ? (
+
+ {/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
+ {!isCompact && activeTodo ? (
+
+ {activeTodo.content}
+
+ ) : (
+ Tasks
+ )}
+
+ {statusSummary.active} active · {statusSummary.left} left
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ ) : null;
+
+ // Don't render if nothing to show
+ if (!hasContent) {
+ return null;
+ }
+
+ return (
+
+
+ {/* Left: Abort status or Working placeholder */}
+
+ {showAbortStatus ? (
+
+
+
+ Aborted
+
+
+ ) : shouldRenderPlaceholder ? (
+
+ ) : null}
+
+
+ {/* Right: Abort (mobile only) + Todo */}
+
+ {abortButton}
+ {todoTrigger}
+
+ {/* Popover dropdown */}
+ {isExpanded && hasActiveTodos && (
+
+ {/* Header */}
+
+ Tasks
+
+ {progress.completed}/{progress.total}
+
+
+
+ {/* Todo list */}
+
+ {visibleTodos.map((todo) => (
+
+ ))}
+
+
+ )}
+
+
+
+ );
+};
diff --git a/ui/src/components/chat/StreamingTextDiff.tsx b/ui/src/components/chat/StreamingTextDiff.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/ui/src/components/chat/TimelineDialog.tsx b/ui/src/components/chat/TimelineDialog.tsx
new file mode 100644
index 0000000..08daddc
--- /dev/null
+++ b/ui/src/components/chat/TimelineDialog.tsx
@@ -0,0 +1,210 @@
+import React from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useMessageStore } from '@/stores/messageStore';
+import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import type { Part } from '@opencode-ai/sdk/v2';
+
+interface TimelineDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onScrollToMessage?: (messageId: string) => void;
+}
+
+// Helper: format relative time (e.g., "2 hours ago")
+function formatRelativeTime(timestamp: number): string {
+ const now = Date.now();
+ const diffMs = now - timestamp;
+ const diffSecs = Math.floor(diffMs / 1000);
+ const diffMins = Math.floor(diffSecs / 60);
+ const diffHours = Math.floor(diffMins / 60);
+ const diffDays = Math.floor(diffHours / 24);
+
+ if (diffSecs < 60) return 'just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return new Date(timestamp).toLocaleDateString();
+}
+
+export const TimelineDialog: React.FC = ({ open, onOpenChange, onScrollToMessage }) => {
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const messages = useMessageStore((state) =>
+ currentSessionId ? state.messages.get(currentSessionId) || [] : []
+ );
+ const revertToMessage = useSessionStore((state) => state.revertToMessage);
+ const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
+ const loadSessions = useSessionStore((state) => state.loadSessions);
+
+ const [forkingMessageId, setForkingMessageId] = React.useState(null);
+ const [searchQuery, setSearchQuery] = React.useState('');
+
+ // Filter user messages (reversed for newest first)
+ const userMessages = React.useMemo(() => {
+ const filtered = messages.filter(m => m.info.role === 'user');
+ return filtered.reverse();
+ }, [messages]);
+
+ // Filter by search query
+ const filteredMessages = React.useMemo(() => {
+ if (!searchQuery.trim()) return userMessages;
+
+ const query = searchQuery.toLowerCase();
+ return userMessages.filter((message) => {
+ const preview = getMessagePreview(message.parts).toLowerCase();
+ return preview.includes(query);
+ });
+ }, [userMessages, searchQuery]);
+
+ // Handle fork with loading state and session refresh
+ const handleFork = async (messageId: string) => {
+ if (!currentSessionId) return;
+ setForkingMessageId(messageId);
+ try {
+ await forkFromMessage(currentSessionId, messageId);
+ await loadSessions();
+ onOpenChange(false);
+ } finally {
+ setForkingMessageId(null);
+ }
+ };
+
+ if (!currentSessionId) return null;
+
+ return (
+
+
+
+
+
+ Conversation Timeline
+
+
+ Navigate to any point in the conversation or fork a new session
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 w-full"
+ />
+
+
+
+ {filteredMessages.length === 0 ? (
+
+ {searchQuery ? 'No messages found' : 'No messages in this session yet'}
+
+ ) : (
+ filteredMessages.map((message) => {
+ const preview = getMessagePreview(message.parts);
+ const timestamp = message.info.time.created;
+ const relativeTime = formatRelativeTime(timestamp);
+ const messageNumber = userMessages.length - userMessages.indexOf(message);
+
+ return (
+
{
+ onScrollToMessage?.(message.info.id);
+ onOpenChange(false);
+ }}
+ >
+
+ {messageNumber}.
+
+
+ {preview || '[No text content]'}
+ {preview && preview.length >= 80 && '…'}
+
+
+
+
+ {relativeTime}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ await revertToMessage(currentSessionId, message.info.id);
+ onOpenChange(false);
+ }}
+ >
+
+
+
+ Revert from here
+
+
+
+
+ {
+ e.stopPropagation();
+ handleFork(message.info.id);
+ }}
+ disabled={forkingMessageId === message.info.id}
+ >
+ {forkingMessageId === message.info.id ? (
+
+ ) : (
+
+ )}
+
+
+ Fork from here
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
Actions
+
+
+ Click on a message to scroll to it in the conversation
+
+
+
+ Undo to this point (message text will populate input)
+
+
+
+ Create a new session starting from here
+
+
+
+
+
+ );
+};
+
+function getMessagePreview(parts: Part[]): string {
+ const textPart = parts.find(p => p.type === 'text');
+ if (!textPart || typeof textPart.text !== 'string') return '';
+ return textPart.text.replace(/\n/g, ' ').slice(0, 80);
+}
diff --git a/ui/src/components/chat/UnifiedControlsDrawer.tsx b/ui/src/components/chat/UnifiedControlsDrawer.tsx
new file mode 100644
index 0000000..1de252c
--- /dev/null
+++ b/ui/src/components/chat/UnifiedControlsDrawer.tsx
@@ -0,0 +1,258 @@
+import React from 'react';
+import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
+import { ProviderLogo } from '@/components/ui/ProviderLogo';
+import { cn } from '@/lib/utils';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useContextStore } from '@/stores/contextStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { useModelLists } from '@/hooks/useModelLists';
+import {
+ formatEffortLabel,
+ getQuickEffortOptions,
+ parseEffortVariant,
+} from './mobileControlsUtils';
+
+const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+});
+
+const formatTokens = (value?: number | null) => {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return null;
+ }
+ if (value === 0) {
+ return '0';
+ }
+ const formatted = COMPACT_NUMBER_FORMATTER.format(value);
+ return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
+};
+
+interface UnifiedControlsDrawerProps {
+ open: boolean;
+ onClose: () => void;
+ onOpenModel: () => void;
+ onOpenEffort: () => void;
+}
+
+export const UnifiedControlsDrawer: React.FC = ({
+ open,
+ onClose,
+ onOpenModel,
+ onOpenEffort,
+}) => {
+ const {
+ providers,
+ currentProviderId,
+ currentModelId,
+ currentVariant,
+ setProvider,
+ setModel,
+ setCurrentVariant,
+ getCurrentModelVariants,
+ getModelMetadata,
+ } = useConfigStore();
+ const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
+ const { recentModelsList } = useModelLists();
+ const {
+ currentSessionId,
+ saveAgentModelForSession,
+ saveAgentModelVariantForSession,
+ } = useSessionStore();
+ const sessionAgentName = useContextStore((state) =>
+ currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
+ );
+
+ const uiAgentName = currentSessionId ? (sessionAgentName || null) : null;
+
+ const recentModelsBase = recentModelsList.slice(0, 4);
+ const hasCurrentInRecents = recentModelsBase.some(
+ (entry) => entry.providerID === currentProviderId && entry.modelID === currentModelId
+ );
+ // If current model not in recents, prepend it so it's always visible
+ const recentModels = React.useMemo(() => {
+ if (hasCurrentInRecents || !currentProviderId || !currentModelId) {
+ return recentModelsBase;
+ }
+ const currentProvider = providers.find((p) => p.id === currentProviderId);
+ const currentModel = currentProvider?.models?.find((m) => m.id === currentModelId);
+ if (!currentModel) {
+ return recentModelsBase;
+ }
+ return [
+ { providerID: currentProviderId, modelID: currentModelId, provider: currentProvider, model: currentModel },
+ ...recentModelsBase.slice(0, 3),
+ ];
+ }, [recentModelsBase, hasCurrentInRecents, currentProviderId, currentModelId, providers]);
+
+ const variants = getCurrentModelVariants();
+ const hasEffort = variants.length > 0;
+ const effortKey = currentProviderId && currentModelId ? `${currentProviderId}/${currentModelId}` : null;
+ const recentEffortsForModel = effortKey ? (recentEfforts[effortKey] ?? []) : [];
+ const recentEffortOptions = recentEffortsForModel
+ .map((variant) => parseEffortVariant(variant))
+ .filter((variant) => !variant || variants.includes(variant));
+ const fallbackEfforts = getQuickEffortOptions(variants);
+ const baseEfforts = fallbackEfforts.length > 0 ? fallbackEfforts : recentEffortOptions;
+ const quickEfforts = React.useMemo(() => {
+ const base = baseEfforts.slice(0, 4);
+ const orderedRecents = recentEffortOptions.slice().reverse();
+ for (const recent of orderedRecents) {
+ if (base.some((entry) => entry === recent)) {
+ continue;
+ }
+ base.unshift(recent);
+ base.splice(4);
+ }
+ if (!base.some((entry) => entry === currentVariant)) {
+ if (base.length > 0) {
+ base[0] = currentVariant;
+ } else {
+ base.push(currentVariant);
+ }
+ }
+ if (!base.some((entry) => entry === undefined)) {
+ base.push(undefined);
+ base.splice(4);
+ }
+ return base;
+ }, [baseEfforts, currentVariant, recentEffortOptions]);
+ const effortHasMore = variants.length + 1 > quickEfforts.length;
+
+ const handleModelSelect = (providerId: string, modelId: string) => {
+ const provider = providers.find((entry) => entry.id === providerId);
+ if (!provider) {
+ return;
+ }
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
+ const modelExists = providerModels.some((model) => model.id === modelId);
+ if (!modelExists) {
+ return;
+ }
+
+ const isRecentAlready = recentModelsList.some(
+ (entry) => entry.providerID === providerId && entry.modelID === modelId
+ );
+
+ setProvider(providerId);
+ setModel(modelId);
+ if (!isRecentAlready) {
+ addRecentModel(providerId, modelId);
+ }
+
+ if (currentSessionId && uiAgentName) {
+ saveAgentModelForSession(currentSessionId, uiAgentName, providerId, modelId);
+ }
+ };
+
+ const handleEffortSelect = (variant: string | undefined) => {
+ setCurrentVariant(variant);
+ if (currentProviderId && currentModelId) {
+ addRecentEffort(currentProviderId, currentModelId, variant);
+ }
+ if (currentSessionId && uiAgentName && currentProviderId && currentModelId) {
+ saveAgentModelVariantForSession(currentSessionId, uiAgentName, currentProviderId, currentModelId, variant);
+ }
+ };
+
+ return (
+
+
+
+
+ Model
+
+
+ {recentModels.length === 0 && !hasCurrentInRecents && (
+
+ No recent models
+
+ )}
+ {recentModels.map(({ providerID, modelID, model }) => {
+ const isSelected = providerID === currentProviderId && modelID === currentModelId;
+ const modelName = typeof model?.name === 'string' && model.name.trim().length > 0
+ ? model.name
+ : modelID;
+ const metadata = getModelMetadata(providerID, modelID);
+ const ctxTokens = formatTokens(metadata?.limit?.context);
+ const outTokens = formatTokens(metadata?.limit?.output);
+ return (
+
handleModelSelect(providerID, modelID)}
+ className={cn(
+ 'flex min-h-[44px] w-full items-center gap-2 border-b border-border/30 px-3 py-2 text-left last:border-b-0',
+ isSelected ? 'bg-primary/10' : ''
+ )}
+ >
+
+
+ {modelName}
+
+ {(ctxTokens || outTokens) && (
+
+ {ctxTokens && `${ctxTokens} ctx`}
+ {ctxTokens && outTokens && ' • '}
+ {outTokens && `${outTokens} out`}
+
+ )}
+
+ );
+ })}
+
+ ...
+
+
+
+
+ {hasEffort && (
+
+
+ Effort
+
+
+ {quickEfforts.map((variant) => {
+ const isSelected = variant === currentVariant || (!variant && !currentVariant);
+ return (
+ handleEffortSelect(variant)}
+ className={cn(
+ 'inline-flex items-center rounded-full border px-2.5 py-1 typography-meta font-medium',
+ isSelected
+ ? 'border-primary/30 bg-primary/10 text-foreground'
+ : 'border-border/40 text-muted-foreground hover:bg-interactive-hover/50'
+ )}
+ aria-pressed={isSelected}
+ >
+ {formatEffortLabel(variant)}
+
+ );
+ })}
+ {effortHasMore && (
+
+ ...
+
+ )}
+
+
+ )}
+
+
+ );
+};
+
+export default UnifiedControlsDrawer;
diff --git a/ui/src/components/chat/contexts/TurnGroupingContext.tsx b/ui/src/components/chat/contexts/TurnGroupingContext.tsx
new file mode 100644
index 0000000..c9add4f
--- /dev/null
+++ b/ui/src/components/chat/contexts/TurnGroupingContext.tsx
@@ -0,0 +1,656 @@
+/* eslint-disable react-refresh/only-export-components */
+import React from 'react';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import type { TurnGroupingContext as TurnGroupingContextType } from '../hooks/useTurnGrouping';
+import { detectTurns, type Turn, type TurnActivityPart, type TurnActivityGroup } from '../hooks/useTurnGrouping';
+import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
+import { useUIStore } from '@/stores/useUIStore';
+
+interface ChatMessageEntry {
+ info: Message;
+ parts: Part[];
+}
+
+interface TurnDiffStats {
+ additions: number;
+ deletions: number;
+ files: number;
+}
+
+interface TurnActivityInfo {
+ activityParts: TurnActivityPart[];
+ activityGroupSegments: TurnActivityGroup[];
+ hasTools: boolean;
+ hasReasoning: boolean;
+ summaryBody?: string;
+ diffStats?: TurnDiffStats;
+}
+
+interface NeighborInfo {
+ previousMessage?: ChatMessageEntry;
+ nextMessage?: ChatMessageEntry;
+}
+
+// Static data that only changes when messages change
+interface TurnGroupingStaticData {
+ structureKey: string;
+ turns: Turn[];
+ messageToTurn: Map;
+ turnActivityInfo: Map;
+ lastTurnId: string | null;
+ lastTurnMessageIds: Set; // Messages belonging to the last turn
+ defaultActivityExpanded: boolean;
+ // Neighbor lookup - stable until messages change
+ messageNeighbors: Map;
+}
+
+// UI state that changes on user interaction (expand/collapse)
+interface TurnGroupingUiStateData {
+ turnUiStates: Map;
+ toggleGroup: (turnId: string) => void;
+}
+
+// Streaming state that changes frequently during assistant response
+interface TurnGroupingStreamingData {
+ sessionIsWorking: boolean;
+ lastTurnActivityInfo?: TurnActivityInfo;
+}
+
+// Separate contexts to prevent unnecessary re-renders
+const TurnGroupingStaticContext = React.createContext(null);
+const TurnGroupingUiStateContext = React.createContext(null);
+const TurnGroupingStreamingContext = React.createContext(null);
+
+const contextCache = new Map();
+
+export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupingContextType | undefined => {
+ const staticData = React.useContext(TurnGroupingStaticContext);
+ const uiStateData = React.useContext(TurnGroupingUiStateContext);
+ const streamingData = React.useContext(TurnGroupingStreamingContext);
+
+ return React.useMemo(() => {
+ if (!staticData || !uiStateData || !streamingData) return undefined;
+
+ const turn = staticData.messageToTurn.get(messageId);
+ if (!turn) return undefined;
+
+ const isAssistantMessage = turn.assistantMessages.some(
+ (msg) => msg.info.id === messageId
+ );
+ if (!isAssistantMessage) return undefined;
+
+ const isLastTurn = staticData.lastTurnId === turn.turnId;
+ const lastTurnActivityVersion = isLastTurn
+ ? `${streamingData.lastTurnActivityInfo?.activityParts.length ?? 0}:${streamingData.lastTurnActivityInfo?.activityGroupSegments.length ?? 0}:${streamingData.lastTurnActivityInfo?.hasTools ? 1 : 0}:${streamingData.lastTurnActivityInfo?.hasReasoning ? 1 : 0}`
+ : '';
+
+ // Get UI state early - needed for cache key to ensure expand/collapse updates propagate
+ const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded };
+ const isExpanded = uiState.isExpanded;
+
+ // Cache key must include:
+ // - messageId: identifies the specific message
+ // - isExpanded: UI state for this turn's activity group
+ // - sessionIsWorking (last turn only): streaming state affects "working" indicator
+ const cacheKey = isLastTurn
+ ? `${staticData.structureKey}:${messageId}-${isExpanded}-${streamingData.sessionIsWorking}-${lastTurnActivityVersion}`
+ : `${staticData.structureKey}:${messageId}-${isExpanded}`;
+
+ const cached = contextCache.get(cacheKey);
+ if (cached) return cached;
+
+ const activityInfo = isLastTurn
+ ? streamingData.lastTurnActivityInfo
+ : staticData.turnActivityInfo.get(turn.turnId);
+ const activityParts = activityInfo?.activityParts ?? [];
+ const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
+ const hasTools = Boolean(activityInfo?.hasTools);
+ const hasReasoning = Boolean(activityInfo?.hasReasoning);
+ const summaryBody = activityInfo?.summaryBody;
+ const diffStats = activityInfo?.diffStats;
+
+ const firstAssistantId = turn.assistantMessages[0]?.info.id;
+ const isFirstAssistantInTurn = messageId === firstAssistantId;
+ const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
+ const isLastAssistantInTurn = messageId === lastAssistantId;
+ const headerMessageId = firstAssistantId;
+ // Only the last turn can be "working"
+ const isTurnWorking = isLastTurn && streamingData.sessionIsWorking;
+
+ const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
+ const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
+
+ const context: TurnGroupingContextType = {
+ turnId: turn.turnId,
+ isFirstAssistantInTurn,
+ isLastAssistantInTurn,
+ summaryBody,
+ activityParts,
+ activityGroupSegments,
+ headerMessageId,
+ hasTools,
+ hasReasoning,
+ diffStats,
+ userMessageCreatedAt,
+ isWorking: isTurnWorking,
+ isGroupExpanded: isExpanded,
+ toggleGroup: () => uiStateData.toggleGroup(turn.turnId),
+ };
+
+ // Cache with size limit
+ if (contextCache.size > 500) {
+ const firstKey = contextCache.keys().next().value;
+ if (firstKey) contextCache.delete(firstKey);
+ }
+ contextCache.set(cacheKey, context);
+
+ return context;
+ }, [staticData, uiStateData, streamingData, messageId]);
+};
+
+// Hook to get neighbor messages - uses context instead of passed messages array
+export const useMessageNeighbors = (messageId: string): NeighborInfo => {
+ const staticData = React.useContext(TurnGroupingStaticContext);
+
+ // Return stable reference from context - no dependencies on messages array
+ return React.useMemo(() => {
+ if (!staticData) return {};
+ return staticData.messageNeighbors.get(messageId) ?? {};
+ }, [staticData, messageId]);
+};
+
+// Hook to get last turn message IDs - only reads static context
+export const useLastTurnMessageIds = (): Set => {
+ const staticData = React.useContext(TurnGroupingStaticContext);
+ return staticData?.lastTurnMessageIds ?? new Set();
+};
+
+// Static-only version of turn grouping context - does NOT subscribe to streaming context
+// Use this for messages NOT in the last turn to avoid re-renders during streaming
+// Still subscribes to UI state context for expand/collapse functionality
+export const useTurnGroupingContextStatic = (messageId: string): TurnGroupingContextType | undefined => {
+ const staticData = React.useContext(TurnGroupingStaticContext);
+ const uiStateData = React.useContext(TurnGroupingUiStateContext);
+
+ return React.useMemo(() => {
+ if (!staticData || !uiStateData) return undefined;
+
+ const turn = staticData.messageToTurn.get(messageId);
+ if (!turn) return undefined;
+
+ const isAssistantMessage = turn.assistantMessages.some(
+ (msg) => msg.info.id === messageId
+ );
+ if (!isAssistantMessage) return undefined;
+
+ const activityInfo = staticData.turnActivityInfo.get(turn.turnId);
+ const activityParts = activityInfo?.activityParts ?? [];
+ const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
+ const hasTools = Boolean(activityInfo?.hasTools);
+ const hasReasoning = Boolean(activityInfo?.hasReasoning);
+ const summaryBody = activityInfo?.summaryBody;
+ const diffStats = activityInfo?.diffStats;
+
+ const firstAssistantId = turn.assistantMessages[0]?.info.id;
+ const isFirstAssistantInTurn = messageId === firstAssistantId;
+ const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
+ const isLastAssistantInTurn = messageId === lastAssistantId;
+ const headerMessageId = firstAssistantId;
+
+ const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded };
+
+ const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
+ const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
+
+ // For static context, isWorking is always false (turn is completed)
+ const context: TurnGroupingContextType = {
+ turnId: turn.turnId,
+ isFirstAssistantInTurn,
+ isLastAssistantInTurn,
+ summaryBody,
+ activityParts,
+ activityGroupSegments,
+ headerMessageId,
+ hasTools,
+ hasReasoning,
+ diffStats,
+ userMessageCreatedAt,
+ isWorking: false,
+ isGroupExpanded: uiState.isExpanded,
+ toggleGroup: () => uiStateData.toggleGroup(turn.turnId),
+ };
+
+ return context;
+ }, [staticData, uiStateData, messageId]);
+};
+
+interface TurnGroupingProviderProps {
+ messages: ChatMessageEntry[];
+ children: React.ReactNode;
+}
+
+const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']);
+
+const isActivityStandaloneTool = (toolName: unknown): boolean => {
+ return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
+};
+
+const extractFinalAssistantText = (turn: Turn): string | undefined => {
+ for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
+ const assistantMsg = turn.assistantMessages[messageIndex];
+ if (!assistantMsg) continue;
+
+ const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
+ if (infoFinish !== 'stop') continue;
+
+ for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
+ const part = assistantMsg.parts[partIndex];
+ if (!part || part.type !== 'text') continue;
+
+ const textContent = (part as { text?: string | null | undefined }).text ??
+ (part as { content?: string | null | undefined }).content;
+ if (typeof textContent === 'string' && textContent.trim().length > 0) {
+ return textContent;
+ }
+ }
+ }
+
+ return undefined;
+};
+
+const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean): TurnActivityInfo => {
+ interface SummaryDiff {
+ additions?: number | null | undefined;
+ deletions?: number | null | undefined;
+ file?: string | null | undefined;
+ }
+ interface UserSummaryPayload {
+ body?: string | null | undefined;
+ diffs?: SummaryDiff[] | null | undefined;
+ }
+
+ const summaryBody = extractFinalAssistantText(turn);
+
+ let diffStats: TurnDiffStats | undefined;
+
+ const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary;
+ const diffs = summary?.diffs;
+ if (Array.isArray(diffs) && diffs.length > 0) {
+ let additions = 0;
+ let deletions = 0;
+ let files = 0;
+
+ diffs.forEach((diff) => {
+ if (!diff) return;
+ const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
+ const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
+ if (diffAdditions !== 0 || diffDeletions !== 0) {
+ files += 1;
+ }
+ additions += diffAdditions;
+ deletions += diffDeletions;
+ });
+
+ if (files > 0) {
+ diffStats = { additions, deletions, files };
+ }
+ }
+
+ let hasTools = false;
+ let hasReasoning = false;
+
+ turn.assistantMessages.forEach((msg) => {
+ msg.parts.forEach((part) => {
+ if (part.type === 'tool') hasTools = true;
+ else if (part.type === 'reasoning') hasReasoning = true;
+ });
+ });
+
+ // Find the LAST assistant message that has text content - this is the summary
+ // All other text messages are justification (yapping during work)
+ let lastTextMessageId: string | undefined;
+ for (let i = turn.assistantMessages.length - 1; i >= 0; i--) {
+ const msg = turn.assistantMessages[i];
+ if (!msg) continue;
+ const hasText = msg.parts.some((p) => {
+ if (p.type !== 'text') return false;
+ const text = (p as { text?: string; content?: string }).text ??
+ (p as { text?: string; content?: string }).content;
+ return typeof text === 'string' && text.trim().length > 0;
+ });
+ if (hasText) {
+ lastTextMessageId = msg.info.id;
+ break;
+ }
+ }
+
+ const activityParts: TurnActivityPart[] = [];
+
+ turn.assistantMessages.forEach((msg) => {
+ const messageId = msg.info.id;
+
+ // Only the LAST message with text is the summary (not justification)
+ // All earlier text messages are justification
+ const isFinalSummaryMessage = messageId === lastTextMessageId;
+
+ msg.parts.forEach((part, partIndex) => {
+ const baseId = `${messageId}-part-${partIndex}-${part.type}`;
+
+ if (part.type === 'tool') {
+ const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
+ const time = state?.time;
+ const end = typeof time?.end === 'number' ? time.end : undefined;
+
+ activityParts.push({
+ id: baseId,
+ turnId: turn.turnId,
+ messageId,
+ kind: 'tool',
+ part,
+ endedAt: end,
+ });
+ return;
+ }
+
+ if (part.type === 'reasoning') {
+ const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text
+ ?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
+ if (typeof text !== 'string' || text.trim().length === 0) return;
+ const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
+ const end = typeof time?.end === 'number' ? time.end : undefined;
+
+ activityParts.push({
+ id: baseId,
+ turnId: turn.turnId,
+ messageId,
+ kind: 'reasoning',
+ part,
+ endedAt: end,
+ });
+ return;
+ }
+
+ if (
+ showTextJustificationActivity &&
+ part.type === 'text' &&
+ (hasTools || hasReasoning) &&
+ !isFinalSummaryMessage
+ ) {
+ const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text ??
+ (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
+ if (typeof text !== 'string' || text.trim().length === 0) return;
+ const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
+ const end = typeof time?.end === 'number' ? time.end : undefined;
+
+ activityParts.push({
+ id: baseId,
+ turnId: turn.turnId,
+ messageId,
+ kind: 'justification',
+ part,
+ endedAt: end,
+ });
+ }
+ });
+ });
+
+ const activityGroupSegments: TurnActivityGroup[] = [];
+ const activityByPart = new WeakMap();
+ activityParts.forEach((activity) => {
+ activityByPart.set(activity.part, activity);
+ });
+
+ const taskMessageById = new Map();
+ const taskOrder: string[] = [];
+ const partsByAfterTool = new Map();
+ let currentAfterToolPartId: string | null = null;
+
+ turn.assistantMessages.forEach((msg) => {
+ const messageId = msg.info.id;
+
+ msg.parts.forEach((part, partIndex) => {
+ if (part.type === 'tool') {
+ const toolName = (part as { tool?: unknown }).tool;
+ if (isActivityStandaloneTool(toolName)) {
+ const toolPartId = `${messageId}-part-${partIndex}-${part.type}`;
+
+ if (!taskMessageById.has(toolPartId)) {
+ taskMessageById.set(toolPartId, messageId);
+ taskOrder.push(toolPartId);
+ }
+ currentAfterToolPartId = toolPartId;
+ return;
+ }
+ }
+
+ const activity = activityByPart.get(part);
+ if (!activity) return;
+
+ if (activity.kind === 'tool') {
+ const toolName = (activity.part as { tool?: unknown }).tool;
+ if (isActivityStandaloneTool(toolName)) return;
+ }
+
+ const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
+ list.push(activity);
+ partsByAfterTool.set(currentAfterToolPartId, list);
+ });
+ });
+
+ const pickAnchorForStartSegment = (segmentParts: TurnActivityPart[]): string | undefined => {
+ if (segmentParts.length === 0) return undefined;
+
+ const countByMessage = new Map();
+ segmentParts.forEach((activity) => {
+ countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
+ });
+
+ let firstWithAny: string | undefined;
+ let cumulative = 0;
+ for (const msg of turn.assistantMessages) {
+ const count = countByMessage.get(msg.info.id) ?? 0;
+ if (count > 0 && !firstWithAny) firstWithAny = msg.info.id;
+ cumulative += count;
+ if (cumulative >= 2) return msg.info.id;
+ }
+ return firstWithAny;
+ };
+
+ const orderedKeys: Array = [null, ...taskOrder];
+
+ orderedKeys.forEach((afterToolPartId) => {
+ const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
+ if (segmentParts.length === 0) return;
+
+ const anchorMessageId = afterToolPartId === null
+ ? pickAnchorForStartSegment(segmentParts)
+ : taskMessageById.get(afterToolPartId);
+
+ if (!anchorMessageId) return;
+
+ activityGroupSegments.push({
+ id: `${turn.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
+ anchorMessageId,
+ afterToolPartId,
+ parts: segmentParts,
+ });
+ });
+
+ return {
+ activityParts,
+ activityGroupSegments,
+ hasTools,
+ hasReasoning,
+ summaryBody,
+ diffStats,
+ };
+};
+
+// Build neighbor lookup map from messages
+const buildNeighborMap = (messages: ChatMessageEntry[]): Map => {
+ const map = new Map();
+ messages.forEach((message, index) => {
+ map.set(message.info.id, {
+ previousMessage: index > 0 ? messages[index - 1] : undefined,
+ nextMessage: index < messages.length - 1 ? messages[index + 1] : undefined,
+ });
+ });
+ return map;
+};
+
+const getMessageRole = (message: ChatMessageEntry): string => {
+ const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
+ return typeof role === 'string' ? role : '';
+};
+
+const getStructureKey = (messages: ChatMessageEntry[]): string => {
+ if (messages.length === 0) return '';
+ return messages
+ .map((message) => `${message.info?.id ?? ''}:${getMessageRole(message)}`)
+ .join('|');
+};
+
+export const TurnGroupingProvider: React.FC = ({ messages, children }) => {
+ const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
+ const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
+ const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
+ const defaultActivityExpanded =
+ toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
+ const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
+ const [structuredMessages, setStructuredMessages] = React.useState(messages);
+
+ React.useEffect(() => {
+ setStructuredMessages((previous) => {
+ if (getStructureKey(previous) === structureKey) {
+ return previous;
+ }
+ return messages;
+ });
+ }, [messages, structureKey]);
+
+ const staticStructureKey = React.useMemo(() => getStructureKey(structuredMessages), [structuredMessages]);
+
+ const staticValue = React.useMemo(() => {
+ const turns = detectTurns(structuredMessages);
+ const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
+
+ const messageToTurn = new Map();
+ turns.forEach((turn) => {
+ messageToTurn.set(turn.userMessage.info.id, turn);
+ turn.assistantMessages.forEach((msg) => {
+ messageToTurn.set(msg.info.id, turn);
+ });
+ });
+
+ const turnActivityInfo = new Map();
+ turns.forEach((turn) => {
+ if (turn.turnId === lastTurnId) return;
+ turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
+ });
+
+ const messageNeighbors = buildNeighborMap(structuredMessages);
+
+ const lastTurnMessageIds = new Set();
+ if (turns.length > 0) {
+ const lastTurn = turns[turns.length - 1]!;
+ lastTurnMessageIds.add(lastTurn.userMessage.info.id);
+ lastTurn.assistantMessages.forEach((msg) => {
+ lastTurnMessageIds.add(msg.info.id);
+ });
+ }
+
+ return {
+ structureKey: staticStructureKey,
+ turns,
+ messageToTurn,
+ turnActivityInfo,
+ lastTurnId,
+ lastTurnMessageIds,
+ defaultActivityExpanded,
+ messageNeighbors,
+ };
+ }, [defaultActivityExpanded, showTextJustificationActivity, staticStructureKey, structuredMessages]);
+
+ const lastTurnActivityInfo = React.useMemo(() => {
+ const lastTurnId = staticValue.lastTurnId;
+ if (!lastTurnId) return undefined;
+ // Find the last turn's user message in the current messages array to pick up
+ // streaming content changes without a second full detectTurns() pass.
+ const turns = staticValue.turns;
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : undefined;
+ if (!lastTurn) return undefined;
+ // Re-slice assistant messages from the live `messages` array so that
+ // streamed part updates are reflected without re-detecting all turns.
+ const lastTurnUserId = lastTurn.userMessage.info.id;
+ const liveAssistant: ChatMessageEntry[] = [];
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const candidate = messages[index];
+ if (!candidate) {
+ continue;
+ }
+
+ if (candidate.info.id === lastTurnUserId) {
+ break;
+ }
+
+ const role = (candidate.info as { clientRole?: string | null }).clientRole ?? candidate.info.role;
+ if (role === 'assistant') {
+ liveAssistant.push(candidate);
+ }
+ }
+
+ if (liveAssistant.length === 0 && messages.every((message) => message.info.id !== lastTurnUserId)) {
+ return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
+ }
+
+ liveAssistant.reverse();
+ const liveTurn: Turn = { ...lastTurn, assistantMessages: liveAssistant };
+ return getTurnActivityInfo(liveTurn, showTextJustificationActivity);
+ }, [staticValue, messages, showTextJustificationActivity]);
+
+ // UI state for expansion toggles
+ const [turnUiStates, setTurnUiStates] = React.useState>(
+ () => new Map()
+ );
+
+ // Reset turn UI states when expansion preference changes
+ React.useEffect(() => {
+ setTurnUiStates(new Map());
+ }, [toolCallExpansion]);
+
+ const toggleGroup = React.useCallback((turnId: string) => {
+ setTurnUiStates((prev) => {
+ const next = new Map(prev);
+ const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
+ next.set(turnId, { isExpanded: !current.isExpanded });
+ return next;
+ });
+ }, [defaultActivityExpanded]);
+
+ // UI state - changes on user interaction (expand/collapse)
+ const uiStateValue = React.useMemo(() => ({
+ turnUiStates,
+ toggleGroup,
+ }), [turnUiStates, toggleGroup]);
+
+ // Streaming state - changes frequently during assistant response
+ const streamingValue = React.useMemo(() => ({
+ sessionIsWorking,
+ lastTurnActivityInfo,
+ }), [lastTurnActivityInfo, sessionIsWorking]);
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
+
+// Clear context cache on unmount or session change
+export const clearTurnGroupingCache = (): void => {
+ contextCache.clear();
+};
diff --git a/ui/src/components/chat/hooks/useTurnGrouping.ts b/ui/src/components/chat/hooks/useTurnGrouping.ts
new file mode 100644
index 0000000..0f8dd82
--- /dev/null
+++ b/ui/src/components/chat/hooks/useTurnGrouping.ts
@@ -0,0 +1,517 @@
+import React from 'react';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
+import { useUIStore } from '@/stores/useUIStore';
+
+export interface ChatMessageEntry {
+ info: Message;
+ parts: Part[];
+}
+
+export interface Turn {
+ turnId: string;
+ userMessage: ChatMessageEntry;
+ assistantMessages: ChatMessageEntry[];
+}
+
+export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
+
+export interface TurnActivityPart {
+ id: string;
+ turnId: string;
+ messageId: string;
+ kind: TurnActivityKind;
+ part: Part;
+ endedAt?: number;
+}
+
+interface TurnDiffStats {
+ additions: number;
+ deletions: number;
+ files: number;
+}
+
+export interface TurnActivityGroup {
+ id: string;
+ anchorMessageId: string;
+ afterToolPartId: string | null;
+ parts: TurnActivityPart[];
+}
+
+export interface TurnGroupingContext {
+ turnId: string;
+ isFirstAssistantInTurn: boolean;
+ isLastAssistantInTurn: boolean;
+
+ summaryBody?: string;
+
+ activityParts: TurnActivityPart[];
+ activityGroupSegments: TurnActivityGroup[];
+ headerMessageId?: string;
+ hasTools: boolean;
+ hasReasoning: boolean;
+ diffStats?: TurnDiffStats;
+ userMessageCreatedAt?: number;
+
+ isWorking: boolean;
+ isGroupExpanded: boolean;
+
+ toggleGroup: () => void;
+}
+
+
+interface TurnUiState {
+ isExpanded: boolean;
+}
+
+interface TurnActivityInfo {
+ activityParts: TurnActivityPart[];
+ activityGroupSegments: TurnActivityGroup[];
+ hasTools: boolean;
+ hasReasoning: boolean;
+ summaryBody?: string;
+ diffStats?: TurnDiffStats;
+}
+
+const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']);
+
+const isActivityStandaloneTool = (toolName: unknown): boolean => {
+ return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
+};
+
+export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
+ const result: Turn[] = [];
+ let currentTurn: Turn | null = null;
+
+ messages.forEach((msg) => {
+ const role = (msg.info as { clientRole?: string | null | undefined }).clientRole ?? msg.info.role;
+
+ if (role === 'user') {
+ currentTurn = {
+ turnId: msg.info.id,
+ userMessage: msg,
+ assistantMessages: [],
+ };
+ result.push(currentTurn);
+ } else if (role === 'assistant' && currentTurn) {
+ currentTurn.assistantMessages.push(msg);
+ }
+ });
+
+ return result;
+};
+
+const extractFinalAssistantText = (turn: Turn): string | undefined => {
+ for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
+ const assistantMsg = turn.assistantMessages[messageIndex];
+ if (!assistantMsg) continue;
+
+ const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
+ if (infoFinish !== 'stop') continue;
+
+ for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
+ const part = assistantMsg.parts[partIndex];
+ if (!part || part.type !== 'text') continue;
+
+ const textContent = (part as { text?: string | null | undefined }).text ??
+ (part as { content?: string | null | undefined }).content;
+ if (typeof textContent === 'string' && textContent.trim().length > 0) {
+ return textContent;
+ }
+ }
+ }
+
+ return undefined;
+};
+
+const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean): TurnActivityInfo => {
+ interface SummaryDiff {
+ additions?: number | null | undefined;
+ deletions?: number | null | undefined;
+ file?: string | null | undefined;
+ }
+ interface UserSummaryPayload {
+ body?: string | null | undefined;
+ diffs?: SummaryDiff[] | null | undefined;
+ }
+
+ const summaryBody = extractFinalAssistantText(turn);
+
+ let diffStats: TurnDiffStats | undefined;
+
+ const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary;
+ const diffs = summary?.diffs;
+ if (Array.isArray(diffs) && diffs.length > 0) {
+ let additions = 0;
+ let deletions = 0;
+ let files = 0;
+
+ diffs.forEach((diff) => {
+ if (!diff) {
+ return;
+ }
+ const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
+ const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
+
+ if (diffAdditions !== 0 || diffDeletions !== 0) {
+ files += 1;
+ }
+ additions += diffAdditions;
+ deletions += diffDeletions;
+ });
+
+ if (files > 0) {
+ diffStats = {
+ additions,
+ deletions,
+ files,
+ };
+ }
+ }
+
+ let hasTools = false;
+ let hasReasoning = false;
+
+ turn.assistantMessages.forEach((msg) => {
+ msg.parts.forEach((part) => {
+ if (part.type === 'tool') {
+ hasTools = true;
+ } else if (part.type === 'reasoning') {
+ hasReasoning = true;
+ }
+ });
+ });
+
+ // Find the LAST assistant message that has text content - this is the summary
+ // All other text messages are justification (yapping during work)
+ let lastTextMessageId: string | undefined;
+ for (let i = turn.assistantMessages.length - 1; i >= 0; i--) {
+ const msg = turn.assistantMessages[i];
+ if (!msg) continue;
+ const hasText = msg.parts.some((p) => {
+ if (p.type !== 'text') return false;
+ const text = (p as { text?: string; content?: string }).text ??
+ (p as { text?: string; content?: string }).content;
+ return typeof text === 'string' && text.trim().length > 0;
+ });
+ if (hasText) {
+ lastTextMessageId = msg.info.id;
+ break;
+ }
+ }
+
+ const activityParts: TurnActivityPart[] = [];
+
+ turn.assistantMessages.forEach((msg) => {
+ const messageId = msg.info.id;
+
+ // Only the LAST message with text is the summary (not justification)
+ // All earlier text messages are justification
+ const isFinalSummaryMessage = messageId === lastTextMessageId;
+
+ msg.parts.forEach((part, partIndex) => {
+ const baseId = `${messageId}-part-${partIndex}-${part.type}`;
+
+ if (part.type === 'tool') {
+ const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
+ const time = state?.time;
+ const end = typeof time?.end === 'number' ? time.end : undefined;
+
+ activityParts.push({
+ id: baseId,
+ turnId: turn.turnId,
+ messageId,
+ kind: 'tool',
+ part,
+ endedAt: end,
+ });
+ return;
+ }
+
+ if (part.type === 'reasoning') {
+ const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text
+ ?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
+ if (typeof text !== 'string' || text.trim().length === 0) {
+ return;
+ }
+ const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
+ const end = typeof time?.end === 'number' ? time.end : undefined;
+
+ activityParts.push({
+ id: baseId,
+ turnId: turn.turnId,
+ messageId,
+ kind: 'reasoning',
+ part,
+ endedAt: end,
+ });
+ return;
+ }
+
+ if (
+ showTextJustificationActivity &&
+ part.type === 'text' &&
+ (hasTools || hasReasoning) &&
+ !isFinalSummaryMessage
+ ) {
+ const text =
+ (part as { text?: string | null | undefined; content?: string | null | undefined }).text ??
+ (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
+ if (typeof text !== 'string' || text.trim().length === 0) {
+ return;
+ }
+ const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
+ const end = typeof time?.end === 'number' ? time.end : undefined;
+
+ activityParts.push({
+ id: baseId,
+ turnId: turn.turnId,
+ messageId,
+ kind: 'justification',
+ part,
+ endedAt: end,
+ });
+ }
+ });
+ });
+
+ const activityGroupSegments: TurnActivityGroup[] = [];
+
+ const activityByPart = new WeakMap();
+ activityParts.forEach((activity) => {
+ activityByPart.set(activity.part, activity);
+ });
+
+ const taskMessageById = new Map();
+ const taskOrder: string[] = [];
+ const partsByAfterTool = new Map();
+
+ let currentAfterToolPartId: string | null = null;
+
+ turn.assistantMessages.forEach((msg) => {
+ const messageId = msg.info.id;
+
+ msg.parts.forEach((part, partIndex) => {
+ if (part.type === 'tool') {
+ const toolName = (part as { tool?: unknown }).tool;
+ if (isActivityStandaloneTool(toolName)) {
+ const toolPartId = `${messageId}-part-${partIndex}-${part.type}`;
+
+ if (!taskMessageById.has(toolPartId)) {
+ taskMessageById.set(toolPartId, messageId);
+ taskOrder.push(toolPartId);
+ }
+
+ currentAfterToolPartId = toolPartId;
+ return;
+ }
+ }
+
+ const activity = activityByPart.get(part);
+ if (!activity) {
+ return;
+ }
+
+ if (activity.kind === 'tool') {
+ const toolName = (activity.part as { tool?: unknown }).tool;
+ if (isActivityStandaloneTool(toolName)) {
+ return;
+ }
+ }
+
+ const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
+ list.push(activity);
+ partsByAfterTool.set(currentAfterToolPartId, list);
+ });
+ });
+
+ const pickAnchorForStartSegment = (segmentParts: TurnActivityPart[]): string | undefined => {
+ if (segmentParts.length === 0) return undefined;
+
+ const countByMessage = new Map();
+ segmentParts.forEach((activity) => {
+ countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
+ });
+
+ let firstWithAny: string | undefined;
+ let cumulative = 0;
+ for (const msg of turn.assistantMessages) {
+ const count = countByMessage.get(msg.info.id) ?? 0;
+ if (count > 0 && !firstWithAny) {
+ firstWithAny = msg.info.id;
+ }
+ cumulative += count;
+ if (cumulative >= 2) {
+ return msg.info.id;
+ }
+ }
+ return firstWithAny;
+ };
+
+ const orderedKeys: Array = [null, ...taskOrder];
+
+ orderedKeys.forEach((afterToolPartId) => {
+ const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
+ if (segmentParts.length === 0) {
+ return;
+ }
+
+ const anchorMessageId = afterToolPartId === null
+ ? pickAnchorForStartSegment(segmentParts)
+ : taskMessageById.get(afterToolPartId);
+
+ if (!anchorMessageId) {
+ return;
+ }
+
+ activityGroupSegments.push({
+ id: `${turn.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
+ anchorMessageId,
+ afterToolPartId,
+ parts: segmentParts,
+ });
+ });
+
+ return {
+ activityParts,
+ activityGroupSegments,
+ hasTools,
+ hasReasoning,
+ summaryBody,
+ diffStats,
+ };
+};
+
+interface UseTurnGroupingResult {
+ turns: Turn[];
+ getTurnForMessage: (messageId: string) => Turn | undefined;
+ getContextForMessage: (messageId: string) => TurnGroupingContext | undefined;
+}
+
+export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingResult => {
+ const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
+ const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
+
+ const turns = React.useMemo(() => detectTurns(messages), [messages]);
+
+ const lastTurnId = React.useMemo(() => {
+ if (turns.length === 0) return null;
+ return turns[turns.length - 1]!.turnId;
+ }, [turns]);
+
+ const messageToTurn = React.useMemo(() => {
+ const map = new Map();
+ turns.forEach((turn) => {
+ map.set(turn.userMessage.info.id, turn);
+ turn.assistantMessages.forEach((msg) => {
+ map.set(msg.info.id, turn);
+ });
+ });
+ return map;
+ }, [turns]);
+
+ const turnActivityInfo = React.useMemo(() => {
+ const map = new Map();
+ turns.forEach((turn) => {
+ map.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
+ });
+ return map;
+ }, [turns, showTextJustificationActivity]);
+
+ const [turnUiStates, setTurnUiStates] = React.useState>(
+ () => new Map()
+ );
+
+ const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
+ // Activity group is expanded for 'activity', 'detailed', and 'changes'; collapsed for 'collapsed'
+ const defaultActivityExpanded =
+ toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
+
+ // Reset turn UI states when the expansion preference changes
+ // This ensures the setting takes precedence over manual toggles
+ React.useEffect(() => {
+ setTurnUiStates(new Map());
+ }, [toolCallExpansion]);
+
+ const getOrCreateTurnState = React.useCallback(
+ (turnId: string): TurnUiState => {
+ const existing = turnUiStates.get(turnId);
+ if (existing) return existing;
+ return { isExpanded: defaultActivityExpanded };
+ },
+ [turnUiStates, defaultActivityExpanded]
+ );
+
+ const toggleGroup = React.useCallback((turnId: string) => {
+ setTurnUiStates((prev) => {
+ const next = new Map(prev);
+ const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
+ next.set(turnId, { isExpanded: !current.isExpanded });
+ return next;
+ });
+ }, [defaultActivityExpanded]);
+
+ const getTurnForMessage = React.useCallback(
+ (messageId: string): Turn | undefined => {
+ return messageToTurn.get(messageId);
+ },
+ [messageToTurn]
+ );
+
+ const getContextForMessage = React.useCallback(
+ (messageId: string): TurnGroupingContext | undefined => {
+ const turn = messageToTurn.get(messageId);
+ if (!turn) return undefined;
+
+ const isAssistantMessage = turn.assistantMessages.some(
+ (msg) => msg.info.id === messageId
+ );
+ if (!isAssistantMessage) return undefined;
+
+ const activityInfo = turnActivityInfo.get(turn.turnId);
+ const activityParts = activityInfo?.activityParts ?? [];
+ const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
+ const hasTools = Boolean(activityInfo?.hasTools);
+ const hasReasoning = Boolean(activityInfo?.hasReasoning);
+ const summaryBody = activityInfo?.summaryBody;
+ const diffStats = activityInfo?.diffStats;
+
+ const firstAssistantId = turn.assistantMessages[0]?.info.id;
+ const isFirstAssistantInTurn = messageId === firstAssistantId;
+ const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
+ const isLastAssistantInTurn = messageId === lastAssistantId;
+ const headerMessageId = firstAssistantId;
+
+ const uiState = getOrCreateTurnState(turn.turnId);
+ const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
+
+ const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
+ const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
+
+ return {
+ turnId: turn.turnId,
+ isFirstAssistantInTurn,
+ isLastAssistantInTurn,
+ summaryBody,
+ activityParts,
+ activityGroupSegments,
+ headerMessageId,
+ hasTools,
+ hasReasoning,
+ diffStats,
+ userMessageCreatedAt,
+ isWorking: isTurnWorking,
+ isGroupExpanded: uiState.isExpanded,
+ toggleGroup: () => toggleGroup(turn.turnId),
+ } satisfies TurnGroupingContext;
+ },
+ [getOrCreateTurnState, lastTurnId, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo]
+ );
+
+
+ return {
+ turns,
+ getTurnForMessage,
+ getContextForMessage,
+ };
+};
diff --git a/ui/src/components/chat/message/DiffViewToggle.tsx b/ui/src/components/chat/message/DiffViewToggle.tsx
new file mode 100644
index 0000000..dc88309
--- /dev/null
+++ b/ui/src/components/chat/message/DiffViewToggle.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { RiAlignJustify, RiLayoutColumnLine } from '@remixicon/react';
+
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+export type DiffViewMode = 'side-by-side' | 'unified';
+
+interface DiffViewToggleProps {
+ mode: DiffViewMode;
+ onModeChange: (mode: DiffViewMode) => void;
+ className?: string;
+}
+
+export const DiffViewToggle: React.FC = ({ mode, onModeChange, className }) => {
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ onModeChange(mode === 'side-by-side' ? 'unified' : 'side-by-side');
+ },
+ [mode, onModeChange]
+ );
+
+ return (
+
+ {mode === 'side-by-side' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/ui/src/components/chat/message/FadeInOnReveal.tsx b/ui/src/components/chat/message/FadeInOnReveal.tsx
new file mode 100644
index 0000000..f3c06e8
--- /dev/null
+++ b/ui/src/components/chat/message/FadeInOnReveal.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+
+interface FadeInOnRevealProps {
+ children: React.ReactNode;
+ className?: string;
+ skipAnimation?: boolean;
+}
+
+const FADE_ANIMATION_ENABLED = true;
+
+// Context to allow parent components (like VirtualMessageList) to disable animations
+// for items entering the viewport due to scrolling rather than new content
+const FadeInDisabledContext = React.createContext(false);
+
+export const FadeInDisabledProvider: React.FC<{ disabled: boolean; children: React.ReactNode }> = ({ disabled, children }) => (
+
+ {children}
+
+);
+
+export const FadeInOnReveal: React.FC = ({ children, className, skipAnimation }) => {
+ const contextDisabled = React.useContext(FadeInDisabledContext);
+ const shouldSkip = skipAnimation || contextDisabled;
+ const [visible, setVisible] = React.useState(shouldSkip);
+
+ React.useEffect(() => {
+ if (!FADE_ANIMATION_ENABLED || shouldSkip) {
+ return;
+ }
+
+ let frame: number | null = null;
+
+ const enable = () => setVisible(true);
+
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
+ frame = window.requestAnimationFrame(enable);
+ } else {
+ enable();
+ }
+
+ return () => {
+ if (
+ frame !== null &&
+ typeof window !== 'undefined' &&
+ typeof window.cancelAnimationFrame === 'function'
+ ) {
+ window.cancelAnimationFrame(frame);
+ }
+ };
+ }, [shouldSkip]);
+
+ if (!FADE_ANIMATION_ENABLED || shouldSkip) {
+ return <>{children}>;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
diff --git a/ui/src/components/chat/message/MessageBody.tsx b/ui/src/components/chat/message/MessageBody.tsx
new file mode 100644
index 0000000..703f8d4
--- /dev/null
+++ b/ui/src/components/chat/message/MessageBody.tsx
@@ -0,0 +1,1478 @@
+import React from 'react';
+import type { Part } from '@opencode-ai/sdk/v2';
+
+import UserTextPart from './parts/UserTextPart';
+import ToolPart from './parts/ToolPart';
+import ProgressiveGroup from './parts/ProgressiveGroup';
+import ReasoningPart from './parts/ReasoningPart';
+import { MessageFilesDisplay } from '../FileAttachment';
+import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
+import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
+import type { TurnGroupingContext } from '../hooks/useTurnGrouping';
+import { cn } from '@/lib/utils';
+import { isEmptyTextPart, extractTextContent } from './partUtils';
+import { FadeInOnReveal } from './FadeInOnReveal';
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiImageDownloadLine, RiLoader4Line } from '@remixicon/react';
+import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
+import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
+
+import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
+import { useMessageStore } from '@/stores/messageStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { flattenAssistantTextParts } from '@/lib/messages/messageText';
+import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
+import { TextSelectionMenu } from './TextSelectionMenu';
+import { copyTextToClipboard } from '@/lib/clipboard';
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { toPng } from 'html-to-image';
+import { toast } from '@/components/ui';
+import { formatTimestampForDisplay } from './timeFormat';
+
+type SubtaskPartLike = Part & {
+ type: 'subtask';
+ description?: unknown;
+ command?: unknown;
+ agent?: unknown;
+ prompt?: unknown;
+ taskSessionID?: unknown;
+ model?: {
+ providerID?: unknown;
+ modelID?: unknown;
+ };
+};
+
+type ShellActionPartLike = Part & {
+ type: 'text';
+ shellAction?: {
+ command?: unknown;
+ output?: unknown;
+ status?: unknown;
+ };
+};
+
+const isSubtaskPart = (part: Part): part is SubtaskPartLike => {
+ return part.type === 'subtask';
+};
+
+const isShellActionPart = (part: Part): part is ShellActionPartLike => {
+ const textPart = part as unknown as { type?: unknown; shellAction?: unknown };
+ return textPart.type === 'text' && typeof textPart.shellAction === 'object' && textPart.shellAction !== null;
+};
+
+const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null => {
+ if (!model || typeof model !== 'object') return null;
+ const providerID = typeof model.providerID === 'string' ? model.providerID.trim() : '';
+ const modelID = typeof model.modelID === 'string' ? model.modelID.trim() : '';
+ if (!providerID || !modelID) return null;
+ return `${providerID}/${modelID}`;
+};
+
+const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
+ const [expanded, setExpanded] = React.useState(false);
+ const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
+
+ const description = typeof part.description === 'string' ? part.description.trim() : '';
+ const command = typeof part.command === 'string' ? part.command.trim() : '';
+ const agent = typeof part.agent === 'string' ? part.agent.trim() : '';
+ const prompt = typeof part.prompt === 'string' ? part.prompt.trim() : '';
+ const taskSessionID = typeof part.taskSessionID === 'string' ? part.taskSessionID.trim() : '';
+ const model = normalizeSubtaskModel(part.model);
+
+ return (
+
+
+ Delegated task
+ {command ? (
+
+ /{command}
+
+ ) : null}
+ {agent ? (
+
+ @{agent}
+
+ ) : null}
+ {model ? (
+
+ {model}
+
+ ) : null}
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {prompt ? (
+
+
setExpanded((value) => !value)}
+ >
+ {expanded ? 'Hide prompt' : 'Show prompt'}
+
+ {expanded ? (
+
+ {prompt}
+
+ ) : null}
+
+ ) : null}
+
+ {taskSessionID ? (
+
+ {
+ void setCurrentSession(taskSessionID);
+ }}
+ >
+ Open subtask session
+
+
+ ) : null}
+
+ );
+};
+
+const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
+ const [expanded, setExpanded] = React.useState(false);
+ const [copiedOutput, setCopiedOutput] = React.useState(false);
+ const copiedResetTimeoutRef = React.useRef(null);
+
+ const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
+ const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
+ const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
+ const hasOutput = output.trim().length > 0;
+
+ const clearCopiedResetTimeout = React.useCallback(() => {
+ if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
+ window.clearTimeout(copiedResetTimeoutRef.current);
+ copiedResetTimeoutRef.current = null;
+ }
+ }, []);
+
+ React.useEffect(() => {
+ return () => {
+ clearCopiedResetTimeout();
+ };
+ }, [clearCopiedResetTimeout]);
+
+ const copyOutputToClipboard = React.useCallback(async () => {
+ if (!hasOutput) return;
+
+ const result = await copyTextToClipboard(output);
+ if (!result.ok) return;
+
+ clearCopiedResetTimeout();
+ setCopiedOutput(true);
+ if (typeof window !== 'undefined') {
+ copiedResetTimeoutRef.current = window.setTimeout(() => {
+ setCopiedOutput(false);
+ copiedResetTimeoutRef.current = null;
+ }, 2000);
+ }
+ }, [clearCopiedResetTimeout, hasOutput, output]);
+
+ return (
+
+
+ Shell command
+ {status ? (
+
+ {status}
+
+ ) : null}
+
+
+ {command ? (
+
+ {command}
+
+ ) : null}
+
+ {hasOutput ? (
+
+
+ setExpanded((value) => !value)}
+ >
+ {expanded ? 'Hide output' : 'Show output'}
+
+ {
+ void copyOutputToClipboard();
+ }}
+ aria-label={copiedOutput ? 'Copied' : 'Copy output'}
+ title={copiedOutput ? 'Copied' : 'Copy output'}
+ >
+ {copiedOutput ? : }
+
+
+ {expanded ? (
+
+ {output}
+
+ ) : null}
+
+ ) : null}
+
+ );
+};
+
+const formatTurnDuration = (durationMs: number): string => {
+ const totalSeconds = durationMs / 1000;
+ if (totalSeconds < 60) {
+ return `${totalSeconds.toFixed(1)}s`;
+ }
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = Math.round(totalSeconds % 60);
+ return `${minutes}m ${seconds}s`;
+};
+
+const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']);
+
+const isActivityStandaloneTool = (toolName: unknown): boolean => {
+ return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
+};
+
+interface MessageBodyProps {
+ messageId: string;
+ parts: Part[];
+ isUser: boolean;
+ isMessageCompleted: boolean;
+ messageFinish?: string;
+ messageCompletedAt?: number;
+ messageCreatedAt?: number;
+
+ syntaxTheme: { [key: string]: React.CSSProperties };
+
+ isMobile: boolean;
+ hasTouchInput?: boolean;
+ copiedCode: string | null;
+ onCopyCode: (code: string) => void;
+ expandedTools: Set;
+ onToggleTool: (toolId: string) => void;
+ onShowPopup: (content: ToolPopupContent) => void;
+ streamPhase: StreamPhase;
+ allowAnimation: boolean;
+ onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
+
+ shouldShowHeader?: boolean;
+ hasTextContent?: boolean;
+ onCopyMessage?: () => void;
+ copiedMessage?: boolean;
+ onAuxiliaryContentComplete?: () => void;
+ showReasoningTraces?: boolean;
+ agentMention?: AgentMentionInfo;
+ turnGroupingContext?: TurnGroupingContext;
+ onRevert?: () => void;
+ onFork?: () => void;
+ errorMessage?: string;
+ userActionsMode?: 'inline' | 'external-content' | 'external-actions';
+ stickyUserHeaderEnabled?: boolean;
+}
+
+const UserMessageBody: React.FC<{
+ messageId: string;
+ parts: Part[];
+ isMobile: boolean;
+ hasTouchInput?: boolean;
+ hasTextContent?: boolean;
+ onCopyMessage?: () => void;
+ copiedMessage?: boolean;
+ onShowPopup: (content: ToolPopupContent) => void;
+ agentMention?: AgentMentionInfo;
+ onRevert?: () => void;
+ onFork?: () => void;
+ userActionsMode?: 'inline' | 'external-content' | 'external-actions';
+ stickyUserHeaderEnabled?: boolean;
+}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
+ const [copyHintVisible, setCopyHintVisible] = React.useState(false);
+ const copyHintTimeoutRef = React.useRef(null);
+
+ const userContentParts = React.useMemo(() => {
+ return parts.filter((part) => {
+ if (part.type === 'text') {
+ return !isEmptyTextPart(part);
+ }
+ if (isSubtaskPart(part)) {
+ return true;
+ }
+ if (isShellActionPart(part)) {
+ return true;
+ }
+ return false;
+ });
+ }, [parts]);
+
+ const mentionToken = agentMention?.token;
+ let mentionInjected = false;
+
+ const canCopyMessage = Boolean(onCopyMessage);
+ const isMessageCopied = Boolean(copiedMessage);
+ const isTouchContext = Boolean(hasTouchInput ?? isMobile);
+ const hasCopyableText = Boolean(hasTextContent);
+ const showUserContent = userActionsMode !== 'external-actions';
+ const showUserActions = userActionsMode !== 'external-content';
+ const useStickyScrollableUserContent = stickyUserHeaderEnabled && userActionsMode === 'inline';
+
+ const clearCopyHintTimeout = React.useCallback(() => {
+ if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
+ window.clearTimeout(copyHintTimeoutRef.current);
+ copyHintTimeoutRef.current = null;
+ }
+ }, []);
+
+ const revealCopyHint = React.useCallback(() => {
+ if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') {
+ return;
+ }
+
+ clearCopyHintTimeout();
+ setCopyHintVisible(true);
+ copyHintTimeoutRef.current = window.setTimeout(() => {
+ setCopyHintVisible(false);
+ copyHintTimeoutRef.current = null;
+ }, 1800);
+ }, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]);
+
+ React.useEffect(() => {
+ if (!hasCopyableText) {
+ setCopyHintVisible(false);
+ clearCopyHintTimeout();
+ }
+ }, [clearCopyHintTimeout, hasCopyableText]);
+
+ const handleCopyButtonClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ if (!onCopyMessage || !hasCopyableText) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ onCopyMessage();
+
+ if (isTouchContext) {
+ revealCopyHint();
+ }
+ },
+ [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
+ );
+
+ const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? (
+
+
+ {onRevert && (
+
+
+ event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ onRevert();
+ }}
+ >
+
+
+
+ Revert from here
+
+ )}
+ {onFork && (
+
+
+ event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ onFork();
+ }}
+ >
+
+
+
+ Fork from here
+
+ )}
+ {canCopyMessage && hasCopyableText && (
+
+
+ event.stopPropagation()}
+ onClick={handleCopyButtonClick}
+ onFocus={() => setCopyHintVisible(true)}
+ onBlur={() => {
+ if (!isMessageCopied) {
+ setCopyHintVisible(false);
+ }
+ }}
+ >
+ {isMessageCopied ? (
+
+ ) : (
+
+ )}
+
+
+ Copy message
+
+ )}
+
+
+ ) : null;
+
+ if (!showUserContent) {
+ return <>{actionsBlock}>;
+ }
+
+ return (
+
+
+ {userContentParts.map((part, index) => {
+ if (isSubtaskPart(part)) {
+ return (
+
+
+
+ );
+ }
+
+ if (isShellActionPart(part)) {
+ return (
+
+
+
+ );
+ }
+
+ let mentionForPart: AgentMentionInfo | undefined;
+ if (agentMention && mentionToken && !mentionInjected) {
+ const candidateText = extractTextContent(part);
+ if (candidateText.includes(mentionToken)) {
+ mentionForPart = agentMention;
+ mentionInjected = true;
+ }
+ }
+ return (
+
+
+
+ );
+ })}
+
+
+ {actionsBlock}
+
+ );
+};
+
+const AssistantMessageBody: React.FC> = ({
+ messageId,
+ parts,
+ isMessageCompleted,
+ messageFinish,
+ messageCompletedAt,
+ messageCreatedAt,
+
+ syntaxTheme,
+ isMobile,
+ hasTouchInput,
+ expandedTools,
+ onToggleTool,
+ onShowPopup,
+ streamPhase: _streamPhase,
+ allowAnimation: _allowAnimation,
+ onContentChange,
+ hasTextContent = false,
+ onCopyMessage,
+ copiedMessage = false,
+ onAuxiliaryContentComplete,
+ showReasoningTraces = false,
+ turnGroupingContext,
+ errorMessage,
+}) => {
+
+ void _streamPhase;
+ void _allowAnimation;
+ const [copyHintVisible, setCopyHintVisible] = React.useState(false);
+ const copyHintTimeoutRef = React.useRef(null);
+ const messageContentRef = React.useRef(null);
+
+ const canCopyMessage = Boolean(onCopyMessage);
+ const isMessageCopied = Boolean(copiedMessage);
+ const isTouchContext = Boolean(hasTouchInput ?? isMobile);
+ const awaitingMessageCompletion = !isMessageCompleted;
+
+ const visibleParts = React.useMemo(() => {
+ return parts
+ .filter((part) => !isEmptyTextPart(part))
+ .filter((part) => {
+ const rawPart = part as Record;
+ return rawPart.type !== 'compaction';
+ });
+ }, [parts]);
+
+ const toolParts = React.useMemo(() => {
+ return visibleParts.filter((part): part is ToolPartType => part.type === 'tool');
+ }, [visibleParts]);
+
+ const assistantTextParts = React.useMemo(() => {
+ return visibleParts.filter((part) => part.type === 'text');
+ }, [visibleParts]);
+
+ const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
+ const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
+ const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
+ const hasStopFinish = messageFinish === 'stop';
+
+ const hasTools = toolParts.length > 0;
+
+ const hasPendingTools = React.useMemo(() => {
+ return toolParts.some((toolPart) => {
+ const state = (toolPart as Record).state as Record | undefined ?? {};
+ const status = state?.status;
+ return status === 'pending' || status === 'running' || status === 'started';
+ });
+ }, [toolParts]);
+
+ const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
+ const state = (toolPart as Record).state as Record | undefined ?? {};
+ const status = state?.status;
+ return status === 'pending' || status === 'running' || status === 'started';
+ }, []);
+
+ const isToolFinalized = React.useCallback((toolPart: ToolPartType) => {
+ const state = (toolPart as Record).state as Record | undefined ?? {};
+ const status = state?.status;
+ if (status === 'pending' || status === 'running' || status === 'started') {
+ return false;
+ }
+ const time = state?.time as Record | undefined ?? {};
+ const endTime = typeof time?.end === 'number' ? time.end : undefined;
+ const startTime = typeof time?.start === 'number' ? time.start : undefined;
+ if (typeof endTime !== 'number') {
+ return false;
+ }
+ if (typeof startTime === 'number' && endTime < startTime) {
+ return false;
+ }
+ return true;
+ }, []);
+
+ const shouldShowTool = React.useCallback((toolPart: ToolPartType): boolean => {
+ return isActiveTool(toolPart) || isToolFinalized(toolPart);
+ }, [isActiveTool, isToolFinalized]);
+
+ const allToolsFinalized = React.useMemo(() => {
+ if (toolParts.length === 0) {
+ return true;
+ }
+ if (hasPendingTools) {
+ return false;
+ }
+ return toolParts.every((toolPart) => isToolFinalized(toolPart));
+ }, [toolParts, hasPendingTools, isToolFinalized]);
+
+
+ const reasoningParts = React.useMemo(() => {
+ return visibleParts.filter((part) => part.type === 'reasoning');
+ }, [visibleParts]);
+
+ const reasoningComplete = React.useMemo(() => {
+ if (reasoningParts.length === 0) {
+ return true;
+ }
+ return reasoningParts.every((part) => {
+ const time = (part as Record).time as { end?: number } | undefined;
+ return typeof time?.end === 'number';
+ });
+ }, [reasoningParts]);
+
+ // Message is considered to have an "open step" if info.finish is not yet present
+ const hasOpenStep = typeof messageFinish !== 'string';
+
+ const shouldHoldForReasoning =
+ reasoningParts.length > 0 &&
+ hasTools &&
+ (hasPendingTools || hasOpenStep || !allToolsFinalized);
+
+
+ const shouldHoldTools = awaitingMessageCompletion
+ || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
+ const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
+
+ const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
+ const isTextlessAssistantMessage = assistantTextParts.length === 0;
+ const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
+ const auxiliaryCompletionAnnouncedRef = React.useRef(false);
+ const soloReasoningScrollTriggeredRef = React.useRef(false);
+
+ React.useEffect(() => {
+ soloReasoningScrollTriggeredRef.current = false;
+ }, [messageId]);
+
+ React.useEffect(() => {
+ if (!auxiliaryContentComplete) {
+ auxiliaryCompletionAnnouncedRef.current = false;
+ return;
+ }
+ if (auxiliaryCompletionAnnouncedRef.current) {
+ return;
+ }
+ auxiliaryCompletionAnnouncedRef.current = true;
+ onAuxiliaryContentComplete?.();
+ }, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
+
+ React.useEffect(() => {
+ if (awaitingMessageCompletion) {
+ soloReasoningScrollTriggeredRef.current = false;
+ return;
+ }
+ if (hasTools) {
+ soloReasoningScrollTriggeredRef.current = false;
+ return;
+ }
+ if (reasoningParts.length === 0) {
+ return;
+ }
+ if (shouldHoldReasoning || !reasoningComplete) {
+ return;
+ }
+ if (soloReasoningScrollTriggeredRef.current) {
+ return;
+ }
+ soloReasoningScrollTriggeredRef.current = true;
+ onContentChange?.('structural');
+ }, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
+
+ const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
+
+ const clearCopyHintTimeout = React.useCallback(() => {
+ if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
+ window.clearTimeout(copyHintTimeoutRef.current);
+ copyHintTimeoutRef.current = null;
+ }
+ }, []);
+
+ const revealCopyHint = React.useCallback(() => {
+ if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') {
+ return;
+ }
+
+ clearCopyHintTimeout();
+ setCopyHintVisible(true);
+ copyHintTimeoutRef.current = window.setTimeout(() => {
+ setCopyHintVisible(false);
+ copyHintTimeoutRef.current = null;
+ }, 1800);
+ }, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]);
+
+ React.useEffect(() => {
+ if (!hasCopyableText) {
+ setCopyHintVisible(false);
+ clearCopyHintTimeout();
+ }
+ }, [clearCopyHintTimeout, hasCopyableText]);
+
+ const handleCopyButtonClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ if (!onCopyMessage || !hasCopyableText) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ onCopyMessage();
+
+ if (isTouchContext) {
+ revealCopyHint();
+ }
+ },
+ [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
+ );
+
+ const handleForkClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ if (!createSessionFromAssistantMessage) {
+ return;
+ }
+ void createSessionFromAssistantMessage(messageId);
+ },
+ [createSessionFromAssistantMessage, messageId]
+ );
+
+ const handleForkMultiRunClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const assistantPlanText = flattenAssistantTextParts(assistantTextParts);
+ if (!assistantPlanText.trim()) {
+ return;
+ }
+
+ const prefilledPrompt = `${MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT}\n\n${assistantPlanText}`;
+ openMultiRunLauncherWithPrompt(prefilledPrompt);
+ },
+ [assistantTextParts, openMultiRunLauncherWithPrompt]
+ );
+
+ const [isSharing, setIsSharing] = React.useState(false);
+
+ const handleShareImage = React.useCallback(
+ async (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (!messageContentRef.current || isSharing) return;
+
+ setIsSharing(true);
+ let wrapper: HTMLDivElement | null = null;
+ try {
+ const originalElement = messageContentRef.current;
+ const computedStyle = window.getComputedStyle(originalElement);
+ const rootStyle = window.getComputedStyle(document.documentElement);
+ const resolvedBackgroundColor =
+ rootStyle.getPropertyValue('--surface-background').trim() ||
+ computedStyle.backgroundColor ||
+ window.getComputedStyle(document.body).backgroundColor;
+ const paddingSize = 24;
+
+ wrapper = document.createElement('div');
+ wrapper.style.cssText = `
+ padding: ${paddingSize}px;
+ background-color: ${resolvedBackgroundColor};
+ display: inline-block;
+ `;
+
+ const clone = originalElement.cloneNode(true) as HTMLElement;
+ clone.style.cssText = `
+ ${computedStyle.cssText}
+ transform: none;
+ contain: none;
+ `;
+
+ const timestampElements = clone.querySelectorAll('[aria-label^="Message time:"]');
+ const footerRowsAdjusted = new Set();
+ timestampElements.forEach((element) => {
+ const label = element.getAttribute('aria-label');
+ const timestamp = label?.replace('Message time:', '').trim();
+ if (!timestamp || element.textContent?.includes(timestamp)) {
+ return;
+ }
+
+ const timestampText = document.createElement('span');
+ timestampText.style.marginLeft = '4px';
+ timestampText.textContent = timestamp;
+ element.appendChild(timestampText);
+
+ const metaGroup = element.parentElement;
+ const footerRow = metaGroup?.parentElement as HTMLElement | null;
+ const actionsGroup = footerRow?.firstElementChild as HTMLElement | null;
+ if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) {
+ return;
+ }
+
+ actionsGroup.style.display = 'none';
+ footerRow.style.justifyContent = 'flex-start';
+ footerRowsAdjusted.add(footerRow);
+ });
+
+ wrapper.appendChild(clone);
+ document.body.appendChild(wrapper);
+
+ const dataUrl = await toPng(wrapper, {
+ quality: 1,
+ pixelRatio: 2,
+ backgroundColor: resolvedBackgroundColor,
+ });
+
+ const fileName = `message-${messageId}.png`;
+
+ if (isVSCodeRuntime()) {
+ const response = await fetch('/api/vscode/save-image', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fileName, dataUrl }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save image in VS Code');
+ }
+
+ const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string };
+ if (payload.saved !== true) {
+ if (payload.canceled) {
+ return;
+ }
+ throw new Error(payload.error || 'Failed to save image in VS Code');
+ }
+ } else {
+ const link = document.createElement('a');
+ link.download = fileName;
+ link.href = dataUrl;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+
+ toast.success('Image saved');
+ } catch (error) {
+ console.error('Failed to generate image:', error);
+ toast.error('Failed to generate image');
+ } finally {
+ if (wrapper && wrapper.parentNode) {
+ wrapper.parentNode.removeChild(wrapper);
+ }
+ setIsSharing(false);
+ }
+ },
+ [messageId, isSharing]
+ );
+
+ React.useEffect(() => {
+ return () => {
+ clearCopyHintTimeout();
+ };
+ }, [clearCopyHintTimeout]);
+
+ const toolConnections = React.useMemo(() => {
+ const connections: Record = {};
+ const displayableTools = toolParts.filter((toolPart) => {
+ if (isActivityStandaloneTool(toolPart.tool)) {
+ return false;
+ }
+ return shouldShowTool(toolPart);
+ });
+
+ displayableTools.forEach((toolPart, index) => {
+ connections[toolPart.id] = {
+ hasPrev: index > 0,
+ hasNext: index < displayableTools.length - 1,
+ };
+ });
+
+ return connections;
+ }, [toolParts, shouldShowTool]);
+
+ const activityPartsForTurn = React.useMemo(() => {
+ return turnGroupingContext?.activityParts ?? [];
+ }, [turnGroupingContext]);
+
+ const activityPartsForMessage = React.useMemo(() => {
+ if (!turnGroupingContext) return [];
+ return activityPartsForTurn.filter((activity) => activity.messageId === messageId);
+ }, [activityPartsForTurn, messageId, turnGroupingContext]);
+
+ const activityGroupSegmentsForMessage = React.useMemo(() => {
+ if (!turnGroupingContext) return [];
+ return turnGroupingContext.activityGroupSegments.filter((segment) => segment.anchorMessageId === messageId);
+ }, [messageId, turnGroupingContext]);
+
+ const activityPartsByPart = React.useMemo(() => {
+ const map = new Map();
+ activityPartsForMessage.forEach((activity) => {
+ map.set(activity.part, activity);
+ });
+ return map;
+ }, [activityPartsForMessage]);
+
+
+ const visibleActivityPartsForTurn = React.useMemo(() => {
+ if (!turnGroupingContext) return [];
+
+ // Filter out reasoning if showReasoningTraces is off.
+ // Justification parts are already filtered at the source (useTurnGrouping)
+ // based on showTextJustificationActivity, so we keep them here.
+ const base = !showReasoningTraces
+ ? activityPartsForTurn.filter((activity) => activity.kind !== 'reasoning')
+ : activityPartsForTurn;
+
+ // Tools rendered standalone are excluded from Activity group.
+ return base.filter((activity) => {
+ if (activity.kind !== 'tool') {
+ return true;
+ }
+ const toolName = (activity.part as ToolPartType).tool;
+ return !isActivityStandaloneTool(toolName);
+ });
+ }, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]);
+
+ const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!turnGroupingContext) {
+ return;
+ }
+
+ const hasTaskSplitSegments = turnGroupingContext.activityGroupSegments.some(
+ (segment) => segment.afterToolPartId !== null
+ );
+
+ const hasReasoningActivity = visibleActivityPartsForTurn.some(
+ (activity) => activity.kind === 'reasoning'
+ );
+
+ if (
+ visibleActivityPartsForTurn.length > 1 ||
+ (hasTaskSplitSegments && visibleActivityPartsForTurn.length > 0) ||
+ hasReasoningActivity
+ ) {
+ setHasEverHadMultipleVisibleActivities(true);
+ }
+ }, [turnGroupingContext, visibleActivityPartsForTurn]);
+
+ const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities);
+
+ const shouldRenderActivityGroup = Boolean(
+ turnGroupingContext &&
+ shouldShowActivityGroup &&
+ visibleActivityPartsForTurn.length > 0 &&
+ activityGroupSegmentsForMessage.length > 0
+ );
+
+ const standaloneToolParts = React.useMemo(() => {
+ return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool));
+ }, [toolParts]);
+
+
+ const renderedParts = React.useMemo(() => {
+ const rendered: React.ReactNode[] = [];
+
+ const renderActivitySegments = (afterToolPartId: string | null) => {
+ if (!turnGroupingContext || !shouldRenderActivityGroup) {
+ return;
+ }
+
+ activityGroupSegmentsForMessage
+ .filter((segment) => (segment.afterToolPartId ?? null) === afterToolPartId)
+ .forEach((segment) => {
+ // Filter out reasoning if showReasoningTraces is off.
+ // Justification parts are already filtered at the source (useTurnGrouping)
+ // based on showTextJustificationActivity, so we keep them here.
+ const visibleSegmentParts = !showReasoningTraces
+ ? segment.parts.filter((activity) => activity.kind !== 'reasoning')
+ : segment.parts;
+
+ if (visibleSegmentParts.length === 0) {
+ return;
+ }
+
+ rendered.push(
+
+ );
+ });
+ };
+
+ // Activity groups and standalone tasks are interleaved in message order.
+ // Note: Reasoning parts are rendered in the visibleParts.forEach loop below
+ // when Activity group isn't showing, to maintain proper ordering with tools.
+ renderActivitySegments(null);
+
+ standaloneToolParts.forEach((standaloneToolPart) => {
+ rendered.push(
+
+
+
+ );
+
+ renderActivitySegments(standaloneToolPart.id);
+ });
+
+ const partsWithTime: Array<{
+ part: Part;
+ index: number;
+ endTime: number | null;
+ element: React.ReactNode;
+ }> = [];
+
+ visibleParts.forEach((part, index) => {
+ const activity = activityPartsByPart.get(part);
+ if (!activity) {
+ return;
+ }
+
+ if (!turnGroupingContext) {
+ return;
+ }
+
+ let endTime: number | null = null;
+ let element: React.ReactNode | null = null;
+
+ if (!shouldShowActivityGroup) {
+ if (activity.kind === 'tool') {
+ const toolPart = part as ToolPartType;
+
+ if (isActivityStandaloneTool(toolPart.tool)) {
+ return;
+ }
+
+ const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
+ const time = toolState?.time;
+ const isFinalized = isToolFinalized(toolPart);
+
+ if (!shouldShowTool(toolPart)) {
+ return;
+ }
+
+ const connection = toolConnections[toolPart.id];
+
+ const toolElement = (
+
+
+
+ );
+
+ element = toolElement;
+ endTime = isFinalized && typeof time?.end === 'number' ? time.end : null;
+ } else if (activity.kind === 'reasoning' && showReasoningTraces) {
+ // Fallback rendering for reasoning when Activity group isn't shown
+ const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
+ const partEndTime = typeof time?.end === 'number' ? time.end : null;
+
+ const reasoningElement = (
+
+
+
+ );
+
+ element = reasoningElement;
+ endTime = partEndTime;
+ }
+
+ if (element) {
+ partsWithTime.push({
+ part,
+ index,
+ endTime,
+ element,
+ });
+ }
+ }
+ });
+
+ partsWithTime.sort((a, b) => {
+ if (a.endTime === null && b.endTime === null) {
+ return a.index - b.index;
+ }
+ if (a.endTime === null) {
+ return 1;
+ }
+ if (b.endTime === null) {
+ return -1;
+ }
+ return a.endTime - b.endTime;
+ });
+
+ partsWithTime.forEach(({ element }) => {
+ rendered.push(element);
+ });
+
+ return rendered;
+ }, [
+ activityPartsByPart,
+ activityGroupSegmentsForMessage,
+ expandedTools,
+ isMobile,
+ isToolFinalized,
+ messageId,
+ onContentChange,
+ onShowPopup,
+ onToggleTool,
+ shouldShowTool,
+ shouldShowActivityGroup,
+ showReasoningTraces,
+ syntaxTheme,
+ toolConnections,
+ turnGroupingContext,
+ visibleParts,
+ standaloneToolParts,
+ shouldRenderActivityGroup,
+ ]);
+
+ const userMessageId = turnGroupingContext?.turnId;
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+
+ const rawSummaryBodyFromStore = useMessageStore((state) => {
+ if (!userMessageId || !currentSessionId) return undefined;
+ const sessionMessages = state.messages.get(currentSessionId);
+ if (!sessionMessages) return undefined;
+ const userMsg = sessionMessages.find((m) => m.info?.id === userMessageId);
+ if (!userMsg) return undefined;
+ const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
+ const body = summary?.body;
+ return typeof body === 'string' && body.trim().length > 0 ? body : undefined;
+ });
+
+ const summaryCandidate =
+ typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
+ ? turnGroupingContext.summaryBody
+ : rawSummaryBodyFromStore;
+
+ const summaryBodyRef = React.useRef(undefined);
+ if (summaryCandidate && summaryCandidate.trim().length > 0) {
+ summaryBodyRef.current = summaryCandidate;
+ }
+ const prevUserMessageId = React.useRef(userMessageId);
+ if (prevUserMessageId.current !== userMessageId) {
+ prevUserMessageId.current = userMessageId;
+ summaryBodyRef.current = undefined;
+ }
+ const summaryBody = summaryBodyRef.current;
+
+ const showSummaryBody =
+ turnGroupingContext?.isLastAssistantInTurn &&
+ summaryBody &&
+ summaryBody.trim().length > 0;
+
+ const showErrorMessage = Boolean(errorMessage);
+
+ const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
+
+ const turnDurationText = React.useMemo(() => {
+ if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
+ const userCreatedAt = turnGroupingContext?.userMessageCreatedAt;
+ if (typeof userCreatedAt !== 'number' || typeof messageCompletedAt !== 'number') return undefined;
+ if (messageCompletedAt <= userCreatedAt) return undefined;
+ return formatTurnDuration(messageCompletedAt - userCreatedAt);
+ }, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]);
+
+ const footerTimestamp = React.useMemo(() => {
+ const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0
+ ? messageCompletedAt
+ : (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null);
+ if (timestamp === null) return null;
+
+ const formatted = formatTimestampForDisplay(timestamp);
+ return formatted.length > 0 ? formatted : null;
+ }, [messageCompletedAt, messageCreatedAt]);
+
+ const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
+
+ const footerButtons = (
+ <>
+ {onCopyMessage && (
+
+
+ event.stopPropagation()}
+ onClick={handleCopyButtonClick}
+ onFocus={() => {
+ if (hasCopyableText) {
+ setCopyHintVisible(true);
+ }
+ }}
+ onBlur={() => {
+ if (!isMessageCopied) {
+ setCopyHintVisible(false);
+ }
+ }}
+ >
+ {isMessageCopied ? (
+
+ ) : (
+
+ )}
+
+
+ Copy answer
+
+ )}
+
+
+ event.stopPropagation()}
+ onClick={handleShareImage}
+ >
+ {isSharing ? (
+
+ ) : (
+
+ )}
+
+
+ {isSharing ? 'Saving image...' : 'Save as image'}
+
+
+
+ event.stopPropagation()}
+ onClick={handleForkClick}
+ >
+
+
+
+ Start new session from this answer
+
+
+
+ event.stopPropagation()}
+ onClick={handleForkMultiRunClick}
+ >
+
+
+
+ Start new multi-run from this answer
+
+ >
+ );
+
+ return (
+
+
+
+
+
+ {renderedParts}
+ {showErrorMessage && (
+
+
+
+
+
+ )}
+ {showSummaryBody && (
+
+
+
+ {shouldShowFooter && (
+
+
+ {footerButtons}
+
+
+ {turnDurationText ? (
+
+
+ {turnDurationText}
+
+ ) : null}
+ {footerTimestamp ? (
+
+
+ {footerTimestamp}
+
+ ) : null}
+
+
+ )}
+
+
+ )}
+
+
+ {!showSummaryBody && shouldShowFooter && (
+
+
+ {footerButtons}
+
+
+ {turnDurationText ? (
+
+
+ {turnDurationText}
+
+ ) : null}
+ {footerTimestamp ? (
+
+
+ {footerTimestamp}
+
+ ) : null}
+
+
+ )}
+
+
+
+ );
+};
+
+const MessageBody: React.FC = ({ isUser, ...props }) => {
+
+ if (isUser) {
+ return (
+
+ );
+ }
+
+ return ;
+};
+
+export default React.memo(MessageBody);
diff --git a/ui/src/components/chat/message/MessageHeader.tsx b/ui/src/components/chat/message/MessageHeader.tsx
new file mode 100644
index 0000000..1673121
--- /dev/null
+++ b/ui/src/components/chat/message/MessageHeader.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { RiAiAgentLine, RiBrainAi3Line, RiUser3Line } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import { getAgentColor } from '@/lib/agentColors';
+import { FadeInOnReveal } from './FadeInOnReveal';
+import { useProviderLogo } from '@/hooks/useProviderLogo';
+
+interface MessageHeaderProps {
+ isUser: boolean;
+ providerID: string | null;
+ agentName: string | undefined;
+ modelName: string | undefined;
+ variant?: string;
+ isDarkTheme: boolean;
+}
+
+const MessageHeader: React.FC = ({ isUser, providerID, agentName, modelName, variant, isDarkTheme }) => {
+ const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
+
+ return (
+
+
+
+
+
+ {isUser ? (
+
+
+
+ ) : (
+
+ {hasLogo && logoSrc ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {isUser ? 'You' : (modelName || 'Assistant')}
+
+ {!isUser && agentName && (
+
+
+ {agentName}
+
+ )}
+ {!isUser && variant && (
+
+
+ {variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default React.memo(MessageHeader);
diff --git a/ui/src/components/chat/message/TextSelectionMenu.tsx b/ui/src/components/chat/message/TextSelectionMenu.tsx
new file mode 100644
index 0000000..2487908
--- /dev/null
+++ b/ui/src/components/chat/message/TextSelectionMenu.tsx
@@ -0,0 +1,413 @@
+import React from 'react';
+import { createPortal } from 'react-dom';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import { copyTextToClipboard } from '@/lib/clipboard';
+
+interface TextSelectionMenuProps {
+ containerRef: React.RefObject;
+}
+
+interface MenuPosition {
+ x: number;
+ y: number;
+ show: boolean;
+}
+
+const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
+const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
+
+export const TextSelectionMenu: React.FC = ({ containerRef }) => {
+ const [position, setPosition] = React.useState({ x: 0, y: 0, show: false });
+ const [selectedText, setSelectedText] = React.useState('');
+ const [isDragging, setIsDragging] = React.useState(false);
+ const [isOpening, setIsOpening] = React.useState(false);
+ const menuRef = React.useRef(null);
+ const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
+ const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
+ const openRafRef = React.useRef(null);
+ const isMenuVisibleRef = React.useRef(false);
+ const createSession = useSessionStore((state) => state.createSession);
+ const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
+ const isMobile = useUIStore((state) => state.isMobile);
+
+ React.useEffect(() => {
+ isMenuVisibleRef.current = position.show;
+ }, [position.show]);
+
+ React.useEffect(() => {
+ return () => {
+ if (openRafRef.current !== null) {
+ window.cancelAnimationFrame(openRafRef.current);
+ openRafRef.current = null;
+ }
+ };
+ }, []);
+
+ const hideMenu = React.useCallback(() => {
+ pendingSelectionRef.current = null;
+
+ if (!isMenuVisibleRef.current) {
+ return;
+ }
+
+ if (openRafRef.current !== null) {
+ window.cancelAnimationFrame(openRafRef.current);
+ openRafRef.current = null;
+ }
+ setIsOpening(false);
+
+ setPosition((prev) => ({ ...prev, show: false }));
+ setSelectedText('');
+ isMenuVisibleRef.current = false;
+ }, []);
+
+ const getDesktopClampedX = React.useCallback((anchorX: number) => {
+ if (typeof window === 'undefined') {
+ return anchorX;
+ }
+
+ const viewportWidth = window.innerWidth;
+ const menuWidth = menuWidthRef.current;
+ const halfWidth = menuWidth / 2;
+ const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
+ const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
+
+ if (minX > maxX) {
+ return viewportWidth / 2;
+ }
+
+ return Math.min(Math.max(anchorX, minX), maxX);
+ }, []);
+
+ const showMenu = React.useCallback(() => {
+ if (!pendingSelectionRef.current) return;
+
+ const { text, rect } = pendingSelectionRef.current;
+ const shouldAnimateIn = !position.show;
+
+ // Position menu above the selection
+ const menuX = isMobile
+ ? rect.left + rect.width / 2
+ : getDesktopClampedX(rect.left + rect.width / 2);
+ const menuY = rect.top - 10;
+
+ setSelectedText(text);
+ setPosition({
+ x: menuX,
+ y: menuY,
+ show: true,
+ });
+ isMenuVisibleRef.current = true;
+
+ if (shouldAnimateIn) {
+ setIsOpening(true);
+ if (openRafRef.current !== null) {
+ window.cancelAnimationFrame(openRafRef.current);
+ }
+ openRafRef.current = window.requestAnimationFrame(() => {
+ setIsOpening(false);
+ openRafRef.current = null;
+ });
+ }
+ }, [getDesktopClampedX, isMobile, position.show]);
+
+ React.useLayoutEffect(() => {
+ if (!position.show || isMobile || !menuRef.current) {
+ return;
+ }
+
+ const measuredWidth = menuRef.current.offsetWidth;
+ if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) {
+ return;
+ }
+
+ menuWidthRef.current = measuredWidth;
+ setPosition((prev) => ({
+ ...prev,
+ x: getDesktopClampedX(prev.x),
+ }));
+ }, [getDesktopClampedX, isMobile, position.show]);
+
+ React.useEffect(() => {
+ if (!position.show || isMobile) {
+ return;
+ }
+
+ const handleViewportResize = () => {
+ setPosition((prev) => ({
+ ...prev,
+ x: getDesktopClampedX(prev.x),
+ }));
+ };
+
+ window.addEventListener('resize', handleViewportResize);
+ return () => {
+ window.removeEventListener('resize', handleViewportResize);
+ };
+ }, [getDesktopClampedX, isMobile, position.show]);
+
+ const handleSelectionChange = React.useCallback(() => {
+ const selection = window.getSelection();
+ const container = containerRef.current;
+
+ if (!selection || !container) {
+ if (!isDragging) {
+ hideMenu();
+ }
+ return;
+ }
+
+ const text = selection.toString().trim();
+
+ // Only show if we have text and the selection is within our container
+ if (!text) {
+ if (!isDragging) {
+ hideMenu();
+ }
+ return;
+ }
+
+ // Check if selection is within the container
+ const range = selection.getRangeAt(0);
+
+ if (!container.contains(range.commonAncestorContainer)) {
+ if (!isDragging) {
+ hideMenu();
+ }
+ return;
+ }
+
+ // Get selection coordinates
+ const rect = range.getBoundingClientRect();
+
+ // Store the selection but don't show menu yet if dragging
+ pendingSelectionRef.current = { text, rect };
+
+ // Only show menu if we're not currently dragging
+ if (!isDragging) {
+ showMenu();
+ }
+ }, [containerRef, hideMenu, showMenu, isDragging]);
+
+ React.useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ // Track when dragging starts
+ const handleMouseDown = () => {
+ setIsDragging(true);
+ hideMenu();
+ };
+
+ // Track when dragging stops
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ // Check if we have a pending selection to show
+ if (pendingSelectionRef.current) {
+ // Small delay to ensure selection is finalized
+ setTimeout(() => {
+ const selection = window.getSelection();
+ if (selection && selection.toString().trim()) {
+ showMenu();
+ } else {
+ hideMenu();
+ }
+ }, 10);
+ }
+ };
+
+ // Listen for selection changes during drag
+ document.addEventListener('selectionchange', handleSelectionChange);
+
+ container.addEventListener('mousedown', handleMouseDown);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ // Hide menu when clicking outside
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(e.target as Node) &&
+ !window.getSelection()?.toString().trim()
+ ) {
+ hideMenu();
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('selectionchange', handleSelectionChange);
+ container.removeEventListener('mousedown', handleMouseDown);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [containerRef, handleSelectionChange, hideMenu, showMenu]);
+
+ const handleAddToChat = React.useCallback(() => {
+ if (!selectedText) return;
+
+ setPendingInputText(selectedText, 'append');
+
+ hideMenu();
+
+ // Clear selection
+ window.getSelection()?.removeAllRanges();
+ }, [selectedText, setPendingInputText, hideMenu]);
+
+ const handleCreateNewSession = React.useCallback(async () => {
+ if (!selectedText) return;
+
+ const session = await createSession(undefined, null, null);
+ if (session) {
+ setPendingInputText(selectedText, 'replace');
+ }
+
+ hideMenu();
+ window.getSelection()?.removeAllRanges();
+ }, [selectedText, createSession, setPendingInputText, hideMenu]);
+
+ const handleCopy = React.useCallback(async () => {
+ if (!selectedText) return;
+
+ const result = await copyTextToClipboard(selectedText);
+ if (!result.ok) {
+ console.error('Failed to copy:', result.error);
+ }
+
+ hideMenu();
+ window.getSelection()?.removeAllRanges();
+ }, [selectedText, hideMenu]);
+
+ if (!position.show) return null;
+
+ // Mobile: Show as a bar at the bottom of the screen, above the keyboard
+ if (isMobile) {
+ return createPortal(
+
+
+
+ Add to chat
+
+
+
+
+ New session
+
+
+
+
+ Copy
+
+
,
+ document.body
+ );
+ }
+
+ // Desktop: Show as a popup above the selection
+ return createPortal(
+
+
+
+
+ Add to chat
+
+
+
+
+
+
+ New session
+
+
+
,
+ document.body
+ );
+};
+
+export default TextSelectionMenu;
diff --git a/ui/src/components/chat/message/ToolOutputDialog.tsx b/ui/src/components/chat/message/ToolOutputDialog.tsx
new file mode 100644
index 0000000..8ad9930
--- /dev/null
+++ b/ui/src/components/chat/message/ToolOutputDialog.tsx
@@ -0,0 +1,1214 @@
+import React from 'react';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiLoader4Line, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
+import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { createPortal } from 'react-dom';
+
+import { cn } from '@/lib/utils';
+import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
+import { toolDisplayStyles } from '@/lib/typography';
+import { getLanguageFromExtension } from '@/lib/toolHelpers';
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
+import { getDefaultTheme } from '@/lib/theme/themes';
+import {
+ renderTodoOutput,
+ renderListOutput,
+ renderGrepOutput,
+ renderGlobOutput,
+ renderWebSearchOutput,
+ formatInputForDisplay,
+ parseReadToolOutput,
+} from './toolRenderers';
+import type { ToolPopupContent, DiffViewMode } from './types';
+import { DiffViewToggle } from './DiffViewToggle';
+import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
+
+interface ToolOutputDialogProps {
+ popup: ToolPopupContent;
+ onOpenChange: (open: boolean) => void;
+ syntaxTheme: { [key: string]: React.CSSProperties };
+ isMobile: boolean;
+}
+
+const getToolIcon = (toolName: string) => {
+ const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
+ const tool = toolName.toLowerCase();
+
+ if (tool === 'reasoning') {
+ return ;
+ }
+ if (tool === 'image-preview') {
+ return ;
+ }
+ if (tool === 'mermaid-preview') {
+ return ;
+ }
+ if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
+ return ;
+ }
+ if (tool === 'write' || tool === 'create' || tool === 'file_write') {
+ return ;
+ }
+ if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
+ return ;
+ }
+ if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
+ return ;
+ }
+ if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
+ return ;
+ }
+ if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
+ return ;
+ }
+ if (tool === 'glob') {
+ return ;
+ }
+ if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
+ return ;
+ }
+ if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web' || tool === 'google' || tool === 'bing' || tool === 'duckduckgo') {
+ return ;
+ }
+ if (tool === 'todowrite' || tool === 'todoread') {
+ return ;
+ }
+ if (tool === 'plan_enter') {
+ return ;
+ }
+ if (tool === 'plan_exit') {
+ return ;
+ }
+ if (tool.startsWith('git')) {
+ return ;
+ }
+ return ;
+};
+
+const PREVIEW_ANIMATION_MS = 150;
+const MERMAID_DIALOG_HEADER_HEIGHT = 40;
+const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
+const MERMAID_ASPECT_MAX_RETRIES = 3;
+
+type PierreThemeConfig = {
+ theme: { light: string; dark: string };
+ themeType: 'light' | 'dark';
+};
+
+const TOOL_DIFF_UNSAFE_CSS = `
+ [data-diff-header],
+ [data-diff] {
+ [data-separator] {
+ height: 24px !important;
+ }
+ }
+`;
+
+const TOOL_DIFF_METRICS = {
+ hunkLineCount: 50,
+ lineHeight: 24,
+ diffHeaderHeight: 44,
+ hunkSeparatorHeight: 24,
+ fileGap: 0,
+};
+
+const usePierreThemeConfig = (): PierreThemeConfig => {
+ const themeSystem = useOptionalThemeSystem();
+ const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
+ const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
+
+ const availableThemes = React.useMemo(
+ () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
+ [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
+ );
+ const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
+ const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
+
+ const lightTheme = React.useMemo(
+ () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
+ [availableThemes, fallbackLightTheme, lightThemeId],
+ );
+ const darkTheme = React.useMemo(
+ () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
+ [availableThemes, darkThemeId, fallbackDarkTheme],
+ );
+
+ React.useEffect(() => {
+ ensurePierreThemeRegistered(lightTheme);
+ ensurePierreThemeRegistered(darkTheme);
+ }, [darkTheme, lightTheme]);
+
+ const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
+
+ return {
+ theme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
+ themeType: currentVariant === 'dark' ? 'dark' : 'light',
+ };
+};
+
+type ViewportSize = { width: number; height: number };
+
+const getWindowViewport = (): ViewportSize => ({
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
+ height: typeof window !== 'undefined' ? window.innerHeight : 0,
+});
+
+const PREVIEW_VIEWPORT_LIMITS = {
+ mobile: { widthRatio: 0.94, heightRatio: 0.86, padding: 10 },
+ desktop: { widthRatio: 0.8, heightRatio: 0.8, padding: 16 },
+} as const;
+
+const getPreviewViewportBounds = (viewport: { width: number; height: number }, isMobile: boolean) => {
+ const limits = isMobile ? PREVIEW_VIEWPORT_LIMITS.mobile : PREVIEW_VIEWPORT_LIMITS.desktop;
+ const paddedWidth = Math.max(160, viewport.width - limits.padding * 2);
+ const paddedHeight = Math.max(160, viewport.height - limits.padding * 2);
+
+ return {
+ maxWidth: Math.max(160, Math.min(paddedWidth, viewport.width * limits.widthRatio)),
+ maxHeight: Math.max(160, Math.min(paddedHeight, viewport.height * limits.heightRatio)),
+ };
+};
+
+const getSvgAspectRatio = (svg: SVGElement): number | null => {
+ try {
+ const groups = Array.from(svg.querySelectorAll('g'));
+ let bestArea = 0;
+ let bestRatio: number | null = null;
+
+ for (const group of groups) {
+ if (!(group instanceof SVGGraphicsElement)) {
+ continue;
+ }
+ const box = group.getBBox();
+ if (!(box.width > 0 && box.height > 0)) {
+ continue;
+ }
+ const area = box.width * box.height;
+ if (area > bestArea) {
+ bestArea = area;
+ bestRatio = box.width / box.height;
+ }
+ }
+
+ if (bestRatio && Number.isFinite(bestRatio) && bestRatio > 0) {
+ return bestRatio;
+ }
+ } catch {
+ // Ignore getBBox failures and fall back to SVG attrs/viewBox.
+ }
+
+ const viewBox = svg.getAttribute('viewBox');
+ if (viewBox) {
+ const parts = viewBox.trim().split(/\s+/).map(Number);
+ if (parts.length === 4) {
+ const width = parts[2];
+ const height = parts[3];
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
+ return width / height;
+ }
+ }
+ }
+
+ const attrWidth = Number(svg.getAttribute('width'));
+ const attrHeight = Number(svg.getAttribute('height'));
+ if (Number.isFinite(attrWidth) && Number.isFinite(attrHeight) && attrWidth > 0 && attrHeight > 0) {
+ return attrWidth / attrHeight;
+ }
+
+ const rect = svg.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0) {
+ return rect.width / rect.height;
+ }
+
+ return null;
+};
+
+const usePreviewOverlayState = (open: boolean) => {
+ const [isRendered, setIsRendered] = React.useState(open);
+ const [isVisible, setIsVisible] = React.useState(open);
+ const [isTransitioning, setIsTransitioning] = React.useState(false);
+
+ React.useEffect(() => {
+ if (open) {
+ setIsRendered(true);
+ setIsTransitioning(true);
+ if (typeof window === 'undefined') {
+ setIsVisible(true);
+ return;
+ }
+
+ const raf = window.requestAnimationFrame(() => {
+ setIsVisible(true);
+ });
+
+ const doneId = window.setTimeout(() => {
+ setIsTransitioning(false);
+ }, PREVIEW_ANIMATION_MS);
+
+ return () => {
+ window.cancelAnimationFrame(raf);
+ window.clearTimeout(doneId);
+ };
+ }
+
+ setIsVisible(false);
+ setIsTransitioning(true);
+ if (typeof window === 'undefined') {
+ setIsRendered(false);
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setIsRendered(false);
+ setIsTransitioning(false);
+ }, PREVIEW_ANIMATION_MS);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [open]);
+
+ return { isRendered, isVisible, isTransitioning };
+};
+
+const usePreviewViewport = (open: boolean) => {
+ const [viewport, setViewport] = React.useState(getWindowViewport);
+
+ React.useEffect(() => {
+ if (!open || typeof window === 'undefined') {
+ return;
+ }
+
+ const onResize = () => {
+ setViewport(getWindowViewport());
+ };
+
+ onResize();
+ window.addEventListener('resize', onResize);
+ return () => {
+ window.removeEventListener('resize', onResize);
+ };
+ }, [open]);
+
+ return viewport;
+};
+
+const ImagePreviewDialog: React.FC<{
+ popup: ToolPopupContent;
+ onOpenChange: (open: boolean) => void;
+ isMobile: boolean;
+}> = ({ popup, onOpenChange, isMobile }) => {
+ const gallery = React.useMemo(() => {
+ const baseImage = popup.image;
+ if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
+ const fromPopup = Array.isArray(baseImage.gallery)
+ ? baseImage.gallery.filter((item): item is { url: string; mimeType?: string; filename?: string; size?: number } => Boolean(item?.url))
+ : [];
+
+ if (fromPopup.length > 0) {
+ return fromPopup;
+ }
+
+ return [{
+ url: baseImage.url,
+ mimeType: baseImage.mimeType,
+ filename: baseImage.filename,
+ size: baseImage.size,
+ }];
+ }, [popup.image]);
+
+ const [currentIndex, setCurrentIndex] = React.useState(0);
+ const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
+ const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
+ const viewport = usePreviewViewport(popup.open);
+
+ React.useEffect(() => {
+ if (!popup.open || gallery.length === 0) {
+ return;
+ }
+
+ const requestedIndex = typeof popup.image?.index === 'number' ? popup.image.index : -1;
+ if (requestedIndex >= 0 && requestedIndex < gallery.length) {
+ setCurrentIndex(requestedIndex);
+ return;
+ }
+
+ const matchingIndex = popup.image?.url
+ ? gallery.findIndex((item) => item.url === popup.image?.url)
+ : -1;
+ setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
+ }, [gallery, popup.image?.index, popup.image?.url, popup.open]);
+
+ const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image;
+ const imageTitle = currentImage?.filename || popup.title || 'Image preview';
+ const hasMultipleImages = gallery.length > 1;
+
+ const showPrevious = React.useCallback(() => {
+ if (gallery.length <= 1) return;
+ setCurrentIndex((prev) => (prev - 1 + gallery.length) % gallery.length);
+ }, [gallery.length]);
+
+ const showNext = React.useCallback(() => {
+ if (gallery.length <= 1) return;
+ setCurrentIndex((prev) => (prev + 1) % gallery.length);
+ }, [gallery.length]);
+
+ React.useEffect(() => {
+ if (!popup.open) {
+ return;
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onOpenChange(false);
+ return;
+ }
+
+ if (event.key === 'ArrowLeft' && hasMultipleImages) {
+ event.preventDefault();
+ showPrevious();
+ return;
+ }
+
+ if (event.key === 'ArrowRight' && hasMultipleImages) {
+ event.preventDefault();
+ showNext();
+ }
+ };
+
+ window.addEventListener('keydown', onKeyDown);
+ return () => {
+ window.removeEventListener('keydown', onKeyDown);
+ };
+ }, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
+
+ React.useEffect(() => {
+ setImageNaturalSize(null);
+ }, [currentImage?.url]);
+
+ const imageDisplaySize = React.useMemo(() => {
+ const maxWidth = Math.max(160, viewport.width * (isMobile ? 0.86 : 0.75));
+ const maxHeight = Math.max(160, viewport.height * (isMobile ? 0.72 : 0.75));
+
+ if (!imageNaturalSize) {
+ return {
+ width: Math.round(maxWidth),
+ height: Math.round(maxHeight),
+ };
+ }
+
+ const widthScale = maxWidth / imageNaturalSize.width;
+ const heightScale = maxHeight / imageNaturalSize.height;
+ const scale = Math.min(widthScale, heightScale);
+
+ return {
+ width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
+ height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
+ };
+ }, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
+
+ if (!isRendered || !currentImage || typeof document === 'undefined') {
+ return null;
+ }
+
+ const content = (
+
+
onOpenChange(false)}
+ />
+
+ {hasMultipleImages && (
+ <>
+
event.stopPropagation()}
+ onClick={showPrevious}
+ className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
+ aria-label="Previous image"
+ >
+
+
+
event.stopPropagation()}
+ onClick={showNext}
+ className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
+ aria-label="Next image"
+ >
+
+
+ >
+ )}
+
+
+
+
+
+ {imageTitle}
+
+
onOpenChange(false)}
+ aria-label="Close image preview"
+ >
+
+
+
+
+
{
+ const element = event.currentTarget;
+ const width = element.naturalWidth;
+ const height = element.naturalHeight;
+ if (width > 0 && height > 0) {
+ setImageNaturalSize((previous) => {
+ if (previous && previous.width === width && previous.height === height) {
+ return previous;
+ }
+ return { width, height };
+ });
+ }
+ }}
+ />
+
+
+
+ );
+
+ return createPortal(content, document.body);
+};
+
+// ── PERF-007: Virtualised sub-components for dialog ──────────────────
+
+const DialogUnifiedDiff: React.FC<{
+ popup: ToolPopupContent;
+ diffViewMode: DiffViewMode;
+ pierreThemeConfig: PierreThemeConfig;
+}> = React.memo(({ popup, diffViewMode, pierreThemeConfig }) => {
+ const patchContent = popup.content || '';
+
+ return (
+
+ );
+});
+
+DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
+
+const DialogReadContent: React.FC<{
+ popup: ToolPopupContent;
+ syntaxTheme: Record
;
+ pierreThemeConfig: PierreThemeConfig;
+}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => {
+ const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
+
+ const inputMeta = popup.metadata?.input;
+ const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record) : {};
+ const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
+ const filePath =
+ typeof inputObj.file_path === 'string'
+ ? inputObj.file_path
+ : typeof inputObj.filePath === 'string'
+ ? inputObj.filePath
+ : typeof inputObj.path === 'string'
+ ? inputObj.path
+ : 'read-output';
+
+ const fileContents = React.useMemo(() => parsedReadOutput.lines.map((line) => line.text).join('\n'), [parsedReadOutput]);
+ const detectedLanguage = React.useMemo(
+ () => popup.language || getLanguageFromExtension(filePath) || 'text',
+ [filePath, popup.language],
+ );
+
+ const codeLines: CodeLine[] = React.useMemo(() => {
+ const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
+ const result: CodeLine[] = [];
+ let nextLineNumber = offset;
+
+ for (const line of parsedReadOutput.lines) {
+ if (line.lineNumber !== null) {
+ nextLineNumber = line.lineNumber;
+ }
+ const shouldAssignFallback =
+ parsedReadOutput.type === 'file'
+ && !hasExplicitLineNumbers
+ && line.lineNumber === null
+ && !line.isInfo;
+ const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
+ ? (nextLineNumber + 1)
+ : null);
+ if (typeof effectiveLineNumber === 'number') {
+ nextLineNumber = effectiveLineNumber;
+ }
+
+ result.push({
+ text: line.text,
+ lineNumber: effectiveLineNumber,
+ isInfo: line.isInfo,
+ });
+ }
+
+ return result;
+ }, [offset, parsedReadOutput]);
+
+ if (parsedReadOutput.type === 'file') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+});
+
+DialogReadContent.displayName = 'DialogReadContent';
+const MermaidPreviewDialog: React.FC<{
+ popup: ToolPopupContent;
+ onOpenChange: (open: boolean) => void;
+ isMobile: boolean;
+}> = ({ popup, onOpenChange, isMobile }) => {
+ const [source, setSource] = React.useState(popup.mermaid?.source || '');
+ const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
+ const [errorMessage, setErrorMessage] = React.useState('');
+ const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
+ const [diagramAspectRatio, setDiagramAspectRatio] = React.useState(null);
+ const viewport = usePreviewViewport(popup.open);
+ const requestIdRef = React.useRef(0);
+ const mermaidPreviewRef = React.useRef(null);
+
+ const normalizeFilePath = React.useCallback((rawPath: string): string | null => {
+ const input = rawPath.trim();
+ if (!input.toLowerCase().startsWith('file://')) {
+ return null;
+ }
+
+ const isSafeLocalPath = (path: string): boolean => {
+ if (!path || /[\0\r\n]/.test(path)) {
+ return false;
+ }
+
+ const normalized = path.replace(/\\/g, '/');
+ const segments = normalized.split('/').filter(Boolean);
+ if (segments.includes('..')) {
+ return false;
+ }
+
+ if (normalized.startsWith('/')) {
+ return true;
+ }
+
+ return /^[A-Za-z]:\//.test(normalized);
+ };
+
+ const decodeLoose = (value: string): string => {
+ return value.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex: string) => {
+ const codePoint = Number.parseInt(hex, 16);
+ return Number.isFinite(codePoint) ? String.fromCharCode(codePoint) : `%${hex}`;
+ });
+ };
+
+ const canParse = typeof URL.canParse === 'function'
+ ? URL.canParse(input)
+ : false;
+
+ if (canParse) {
+ let pathname = decodeLoose(new URL(input).pathname || '');
+ if (/^\/[A-Za-z]:\//.test(pathname)) {
+ pathname = pathname.slice(1);
+ }
+ return isSafeLocalPath(pathname) ? pathname : null;
+ }
+
+ const stripped = input.replace(/^file:\/\//i, '');
+ const decoded = decodeLoose(stripped);
+ return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null);
+ }, []);
+
+ const decodeDataUrl = React.useCallback((value: string): string => {
+ const commaIndex = value.indexOf(',');
+ if (commaIndex < 0) {
+ throw new Error('Malformed data URL');
+ }
+
+ const metadata = value.slice(0, commaIndex).toLowerCase();
+ const payload = value.slice(commaIndex + 1);
+ if (metadata.includes(';base64')) {
+ return atob(payload);
+ }
+ return decodeURIComponent(payload);
+ }, []);
+
+ const loadMermaidSource = React.useCallback(async () => {
+ const target = popup.mermaid;
+ if (!target?.url) {
+ setStatus('error');
+ setErrorMessage('Missing Mermaid source URL.');
+ return;
+ }
+
+ if (target.source) {
+ setSource(target.source);
+ setStatus('ready');
+ setErrorMessage('');
+ return;
+ }
+
+ const requestId = requestIdRef.current + 1;
+ requestIdRef.current = requestId;
+
+ setStatus('loading');
+ setErrorMessage('');
+
+ let sourcePromise: Promise;
+ if (target.url.startsWith('data:')) {
+ sourcePromise = Promise.resolve(decodeDataUrl(target.url));
+ } else if (target.url.toLowerCase().startsWith('file://')) {
+ const normalizedPath = normalizeFilePath(target.url);
+ if (!normalizedPath) {
+ sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
+ } else {
+ sourcePromise = fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`)
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
+ }
+ return response.text();
+ });
+ }
+ } else {
+ const canParse = typeof URL.canParse === 'function'
+ ? URL.canParse(target.url, window.location.origin)
+ : false;
+ const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
+
+ if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
+ sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
+ } else {
+ sourcePromise = fetch(resolvedUrl.toString())
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
+ }
+ return response.text();
+ });
+ }
+ }
+
+ await sourcePromise
+ .then((resolvedSource) => {
+ if (requestIdRef.current !== requestId) {
+ return;
+ }
+
+ setSource(resolvedSource);
+ setStatus('ready');
+ })
+ .catch((error) => {
+ if (requestIdRef.current !== requestId) {
+ return;
+ }
+ setStatus('error');
+ setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
+ });
+ }, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
+
+ React.useEffect(() => {
+ if (!popup.open || !popup.mermaid) {
+ return;
+ }
+ void loadMermaidSource();
+ }, [loadMermaidSource, popup.mermaid, popup.open]);
+
+ React.useEffect(() => {
+ if (!popup.open) {
+ return;
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onOpenChange(false);
+ }
+ };
+
+ window.addEventListener('keydown', onKeyDown);
+ return () => {
+ window.removeEventListener('keydown', onKeyDown);
+ };
+ }, [onOpenChange, popup.open]);
+
+ React.useEffect(() => {
+ if (!popup.open || status !== 'ready') {
+ setDiagramAspectRatio(null);
+ return;
+ }
+
+ const measureAspectRatio = () => {
+ const svg = mermaidPreviewRef.current?.querySelector('svg');
+ if (!svg) {
+ return false;
+ }
+
+ const aspectRatio = getSvgAspectRatio(svg as SVGElement);
+ if (!aspectRatio || !Number.isFinite(aspectRatio) || aspectRatio <= 0) {
+ return false;
+ }
+
+ setDiagramAspectRatio((previous) => {
+ if (previous && Math.abs(previous - aspectRatio) < 0.001) {
+ return previous;
+ }
+ return aspectRatio;
+ });
+ return true;
+ };
+
+ let rafId = window.requestAnimationFrame(() => {
+ if (!measureAspectRatio()) {
+ rafId = window.requestAnimationFrame(() => {
+ measureAspectRatio();
+ });
+ }
+ });
+
+ let retryCount = 0;
+ let timeoutId: number | undefined;
+ const scheduleRetry = () => {
+ if (retryCount >= MERMAID_ASPECT_MAX_RETRIES) {
+ return;
+ }
+
+ timeoutId = window.setTimeout(() => {
+ retryCount += 1;
+ if (!measureAspectRatio()) {
+ scheduleRetry();
+ }
+ }, MERMAID_ASPECT_RETRY_DELAY_MS);
+ };
+ scheduleRetry();
+
+ const observer = new MutationObserver(() => {
+ measureAspectRatio();
+ });
+
+ if (mermaidPreviewRef.current) {
+ observer.observe(mermaidPreviewRef.current, { childList: true, subtree: true, attributes: true });
+ }
+
+ return () => {
+ window.cancelAnimationFrame(rafId);
+ if (typeof timeoutId === 'number') {
+ window.clearTimeout(timeoutId);
+ }
+ observer.disconnect();
+ };
+ }, [popup.open, source, status]);
+
+ const mermaidMarkdown = `\`\`\`mermaid\n${source}\n\`\`\``;
+
+ const dialogSize = React.useMemo(() => {
+ const { maxWidth, maxHeight } = getPreviewViewportBounds(viewport, isMobile);
+ const availableDiagramHeight = Math.max(160, maxHeight - MERMAID_DIALOG_HEADER_HEIGHT);
+
+ if (diagramAspectRatio && diagramAspectRatio < 1) {
+ const squareSide = Math.min(maxWidth, availableDiagramHeight);
+ return { width: Math.round(squareSide), height: Math.round(squareSide) };
+ }
+
+ return { width: Math.round(maxWidth), height: Math.round(availableDiagramHeight) };
+ }, [diagramAspectRatio, isMobile, viewport]);
+
+ if (!isRendered || typeof document === 'undefined') {
+ return null;
+ }
+
+ const content = (
+
+
onOpenChange(false)}
+ />
+
+
+
event.stopPropagation()}
+ >
+
+ onOpenChange(false)}
+ aria-label="Close diagram preview"
+ >
+
+
+
+
+
+ {status === 'loading' && (
+
+
+ Loading diagram...
+
+ )}
+
+ {status === 'error' && (
+
+
+ {errorMessage || 'Unable to render Mermaid diagram.'}
+
+
{
+ void loadMermaidSource();
+ }}
+ className="px-3 py-1.5 rounded-lg typography-meta border transition-colors hover:bg-[var(--interactive-hover)]"
+ style={{
+ borderColor: 'var(--interactive-border)',
+ color: 'var(--surface-foreground)',
+ }}
+ >
+ Retry
+
+
+ )}
+
+ {status === 'ready' && (
+
+
+
+ )}
+
+
+
+
+
+ );
+
+ return createPortal(content, document.body);
+};
+
+const ToolOutputDialog: React.FC
= ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
+ const [diffViewMode, setDiffViewMode] = React.useState('unified');
+ const pierreThemeConfig = usePierreThemeConfig();
+
+ React.useEffect(() => {
+ if (!popup.open) return;
+ setDiffViewMode('unified');
+ }, [popup.open, popup.title]);
+
+ if (popup.image) {
+ return ;
+ }
+
+ if (popup.mermaid) {
+ return ;
+ }
+
+ return (
+
+ button]:top-1.5',
+ isMobile ? 'w-[95vw] max-w-[95vw]' : 'max-w-5xl',
+ isMobile ? '[&>button]:right-1' : '[&>button]:top-2.5 [&>button]:right-4'
+ )}
+ style={{ maxHeight: '90vh' }}
+ >
+
+
+ {popup.metadata?.tool ? getToolIcon(popup.metadata.tool as string) : (
+
+ )}
+ {popup.title}
+ {popup.isDiff && (
+
+ )}
+
+
+
+
+ {popup.metadata?.input && typeof popup.metadata.input === 'object' &&
+ Object.keys(popup.metadata.input).length > 0 &&
+ popup.metadata?.tool !== 'todowrite' &&
+ popup.metadata?.tool !== 'todoread' &&
+ popup.metadata?.tool !== 'apply_patch' ? (() => {
+ const meta = popup.metadata!;
+ const input = meta.input as Record
;
+
+ const getInputValue = (key: string): string | null => {
+ const val = input[key];
+ return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : null);
+ };
+ return (
+
+
+ {meta.tool === 'bash'
+ ? 'Command:'
+ : meta.tool === 'task'
+ ? 'Task Details:'
+ : 'Input:'}
+
+ {meta.tool === 'bash' && getInputValue('command') ? (
+
+
+ {getInputValue('command')!}
+
+
+ ) : meta.tool === 'task' && getInputValue('prompt') ? (
+
+ {getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
+ {getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
+ {`Instructions:\n${getInputValue('prompt')}`}
+
+ ) : meta.tool === 'write' && getInputValue('content') ? (
+
+ ) : (
+
+ {formatInputForDisplay(input, meta.tool as string)}
+
+ )}
+
+ );
+ })() : null}
+
+ {popup.isDiff ? (
+
+ ) : popup.content ? (
+
+ {(() => {
+ const tool = popup.metadata?.tool;
+
+ if (tool === 'todowrite' || tool === 'todoread') {
+ return (
+ renderTodoOutput(popup.content) || (
+
+ {popup.content}
+
+ )
+ );
+ }
+
+ if (tool === 'list') {
+ return (
+ renderListOutput(popup.content) || (
+
+ {popup.content}
+
+ )
+ );
+ }
+
+ if (tool === 'grep') {
+ return (
+ renderGrepOutput(popup.content, isMobile) || (
+
+ {popup.content}
+
+ )
+ );
+ }
+
+ if (tool === 'glob') {
+ return (
+ renderGlobOutput(popup.content, isMobile) || (
+
+ {popup.content}
+
+ )
+ );
+ }
+
+ if (tool === 'task' || tool === 'reasoning') {
+ return (
+
+
+
+ );
+ }
+
+ if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
+ return (
+ renderWebSearchOutput(popup.content, syntaxTheme) || (
+
+ {popup.content}
+
+ )
+ );
+ }
+
+ if (tool === 'read') {
+ return
;
+ }
+
+ return (
+
+ {popup.content}
+
+ );
+ })()}
+
+ ) : (
+
+
Command completed successfully
+
No output was produced
+
+ )}
+
+
+
+
+ );
+};
+
+export default ToolOutputDialog;
diff --git a/ui/src/components/chat/message/messageRole.ts b/ui/src/components/chat/message/messageRole.ts
new file mode 100644
index 0000000..b0c42b9
--- /dev/null
+++ b/ui/src/components/chat/message/messageRole.ts
@@ -0,0 +1,34 @@
+import type { Message } from '@opencode-ai/sdk/v2';
+
+export interface MessageRoleInfo {
+ role: string;
+ isUser: boolean;
+}
+
+export const deriveMessageRole = (
+ messageInfo: Message | (Message & { clientRole?: string; userMessageMarker?: boolean })
+): MessageRoleInfo => {
+ const info = messageInfo as Message & { clientRole?: string; userMessageMarker?: boolean; origin?: string; source?: string };
+ const clientRole = info?.clientRole;
+ const serverRole = info?.role;
+ const userMarker = info?.userMessageMarker === true;
+
+ const isUser =
+ userMarker ||
+ clientRole === 'user' ||
+ serverRole === 'user' ||
+ info?.origin === 'user' ||
+ info?.source === 'user';
+
+ if (isUser) {
+ return {
+ role: 'user',
+ isUser: true,
+ };
+ }
+
+ return {
+ role: clientRole || serverRole || 'assistant',
+ isUser: false,
+ };
+};
diff --git a/ui/src/components/chat/message/partUtils.ts b/ui/src/components/chat/message/partUtils.ts
new file mode 100644
index 0000000..7a55767
--- /dev/null
+++ b/ui/src/components/chat/message/partUtils.ts
@@ -0,0 +1,70 @@
+import type { Part } from '@opencode-ai/sdk/v2';
+
+type PartWithText = Part & { text?: string; content?: string; value?: string };
+
+export const extractTextContent = (part: Part): string => {
+ const partWithText = part as PartWithText;
+ const rawText = partWithText.text;
+ if (typeof rawText === 'string') {
+ return rawText;
+ }
+ return partWithText.content || partWithText.value || '';
+};
+
+export const isEmptyTextPart = (part: Part): boolean => {
+ if (part.type !== 'text') {
+ return false;
+ }
+ const text = extractTextContent(part);
+ return !text || text.trim().length === 0;
+};
+
+type PartWithSynthetic = Part & { synthetic?: boolean };
+
+interface VisibleFilterOptions {
+ includeReasoning?: boolean;
+}
+
+export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions = {}): Part[] => {
+ const { includeReasoning = true } = options;
+
+ // Check if there are any non-synthetic parts
+ const hasNonSynthetic = parts.some((part) => {
+ const partWithSynthetic = part as PartWithSynthetic;
+ return !partWithSynthetic.synthetic;
+ });
+
+ return parts.filter((part) => {
+ const partWithSynthetic = part as PartWithSynthetic;
+ const isSynthetic = Boolean(partWithSynthetic.synthetic);
+
+ if (isSynthetic && part.type === 'text') {
+ const text = extractTextContent(part);
+ if (text.includes('')) {
+ return false;
+ }
+ }
+
+ // Only filter out synthetic parts if there are non-synthetic parts present
+ // Otherwise, show synthetic parts so the message is displayed
+ if (isSynthetic && hasNonSynthetic) {
+ return false;
+ }
+ if (!includeReasoning && part.type === 'reasoning') {
+ return false;
+ }
+ const isPatchPart = part.type === 'patch';
+
+ return !isPatchPart;
+ });
+};
+
+type PartWithTime = Part & { time?: { start?: number; end?: number } };
+
+export const isFinalizedTextPart = (part: Part): boolean => {
+ if (part.type !== 'text') {
+ return false;
+ }
+ const time = (part as PartWithTime).time;
+ return Boolean(time && typeof time.end !== 'undefined');
+};
diff --git a/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/ui/src/components/chat/message/parts/AssistantTextPart.tsx
new file mode 100644
index 0000000..d589007
--- /dev/null
+++ b/ui/src/components/chat/message/parts/AssistantTextPart.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import type { Part } from '@opencode-ai/sdk/v2';
+import { MarkdownRenderer } from '../../MarkdownRenderer';
+import type { StreamPhase } from '../types';
+import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
+import { ReasoningTimelineBlock, formatReasoningText } from './ReasoningPart';
+
+type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
+
+interface AssistantTextPartProps {
+ part: Part;
+ messageId: string;
+ streamPhase: StreamPhase;
+ allowAnimation: boolean;
+ onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
+ renderAsReasoning?: boolean;
+}
+
+const AssistantTextPart: React.FC = ({
+ part,
+ messageId,
+ streamPhase,
+ allowAnimation,
+ onContentChange,
+ renderAsReasoning = false,
+}) => {
+ const partWithText = part as PartWithText;
+ const rawText = partWithText.text;
+ const baseTextContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
+ const textContent = React.useMemo(() => {
+ if (renderAsReasoning) {
+ return formatReasoningText(baseTextContent);
+ }
+ return baseTextContent;
+ }, [baseTextContent, renderAsReasoning]);
+ const isStreamingPhase = streamPhase === 'streaming';
+ const isCooldownPhase = streamPhase === 'cooldown';
+ const wasStreamingRef = React.useRef(isStreamingPhase);
+
+ if (isStreamingPhase || isCooldownPhase) {
+ wasStreamingRef.current = true;
+ return null;
+ }
+
+ const time = partWithText.time;
+ const isFinalized = time && typeof time.end !== 'undefined';
+
+ if (!isFinalized && (!textContent || textContent.trim().length === 0)) {
+ return null;
+ }
+
+ if (!textContent || textContent.trim().length === 0) {
+ return null;
+ }
+
+ if (renderAsReasoning) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default AssistantTextPart;
diff --git a/ui/src/components/chat/message/parts/JustificationBlock.tsx b/ui/src/components/chat/message/parts/JustificationBlock.tsx
new file mode 100644
index 0000000..9d34051
--- /dev/null
+++ b/ui/src/components/chat/message/parts/JustificationBlock.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import type { Part } from '@opencode-ai/sdk/v2';
+import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
+import { ReasoningTimelineBlock } from './ReasoningPart';
+
+type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
+
+const cleanJustificationText = (text: string): string => {
+ if (typeof text !== 'string' || text.trim().length === 0) {
+ return '';
+ }
+
+ return text
+ .split('\n')
+ .map((line: string) => line.replace(/^>\s?/, '').trimEnd())
+ .filter((line: string) => line.trim().length > 0)
+ .join('\n')
+ .trim();
+};
+
+interface JustificationBlockProps {
+ part: Part;
+ messageId: string;
+ onContentChange?: (reason?: ContentChangeReason) => void;
+}
+
+const JustificationBlock: React.FC = ({
+ part,
+ messageId,
+ onContentChange,
+}) => {
+ const partWithText = part as PartWithText;
+ const rawText = partWithText.text || partWithText.content || '';
+ const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
+ const time = partWithText.time;
+
+ // Don't render if there's no text content
+ if (!textContent || textContent.trim().length === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default React.memo(JustificationBlock);
diff --git a/ui/src/components/chat/message/parts/MigratingPart.tsx b/ui/src/components/chat/message/parts/MigratingPart.tsx
new file mode 100644
index 0000000..54f71d3
--- /dev/null
+++ b/ui/src/components/chat/message/parts/MigratingPart.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+
+interface MigratingPartProps {
+
+ isMigrating: boolean;
+ children: React.ReactNode;
+ className?: string;
+}
+
+const MigratingPart: React.FC = ({
+ isMigrating,
+ children,
+ className,
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default React.memo(MigratingPart);
diff --git a/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/ui/src/components/chat/message/parts/ProgressiveGroup.tsx
new file mode 100644
index 0000000..8a0f49f
--- /dev/null
+++ b/ui/src/components/chat/message/parts/ProgressiveGroup.tsx
@@ -0,0 +1,322 @@
+import React from 'react';
+import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import type { TurnActivityPart } from '../../hooks/useTurnGrouping';
+import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
+import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
+import type { ToolPopupContent } from '../types';
+import ToolPart from './ToolPart';
+import ReasoningPart from './ReasoningPart';
+import JustificationBlock from './JustificationBlock';
+import { FadeInOnReveal } from '../FadeInOnReveal';
+
+const MAX_VISIBLE_COLLAPSED = 6;
+
+interface DiffStats {
+ additions: number;
+ deletions: number;
+ files: number;
+}
+
+interface ProgressiveGroupProps {
+ parts: TurnActivityPart[];
+ isExpanded: boolean;
+ onToggle: () => void;
+ syntaxTheme: Record;
+ isMobile: boolean;
+ expandedTools: Set;
+ onToggleTool: (toolId: string) => void;
+ onShowPopup: (content: ToolPopupContent) => void;
+ onContentChange?: (reason?: ContentChangeReason) => void;
+ diffStats?: DiffStats;
+}
+
+const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
+ return [...parts].sort((a, b) => {
+ const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
+ const bTime = typeof b.endedAt === 'number' ? b.endedAt : undefined;
+
+ if (aTime === undefined && bTime === undefined) return 0;
+ if (aTime === undefined) return 1;
+ if (bTime === undefined) return -1;
+
+ return aTime - bTime;
+ });
+};
+
+const getToolConnections = (
+ parts: TurnActivityPart[]
+): Record => {
+ const connections: Record = {};
+ const toolParts = parts.filter((p) => p.kind === 'tool');
+
+ toolParts.forEach((activity, index) => {
+ const partId = activity.id;
+ connections[partId] = {
+ hasPrev: index > 0,
+ hasNext: index < toolParts.length - 1,
+ };
+ });
+
+ return connections;
+};
+
+const ProgressiveGroup: React.FC = ({
+ parts,
+ isExpanded,
+ onToggle,
+ syntaxTheme,
+ isMobile,
+ expandedTools,
+ onToggleTool,
+ onShowPopup,
+ onContentChange,
+ diffStats,
+}) => {
+ const previousExpandedRef = React.useRef(isExpanded);
+ // Track if we just expanded from collapsed state
+ const [justExpandedFromCollapsed, setJustExpandedFromCollapsed] = React.useState(false);
+
+ const [expansionKey, setExpansionKey] = React.useState(0);
+
+ // Track which parts have already been shown in collapsed view (for fade-in animation)
+ const shownInCollapsedRef = React.useRef>(new Set());
+
+ React.useEffect(() => {
+ if (previousExpandedRef.current === isExpanded) return;
+ const wasCollapsed = previousExpandedRef.current === false;
+ previousExpandedRef.current = isExpanded;
+ onContentChange?.('structural');
+
+ if (isExpanded && wasCollapsed) {
+ setExpansionKey((k) => k + 1);
+ setJustExpandedFromCollapsed(true);
+ // Clear collapsed tracking when expanding (will restart when collapsed again)
+ shownInCollapsedRef.current.clear();
+ // Reset after a short delay (after animations would have started)
+ const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50);
+ return () => clearTimeout(timer);
+ } else {
+ setJustExpandedFromCollapsed(false);
+ }
+ }, [isExpanded, onContentChange]);
+
+ const displayParts = React.useMemo(() => {
+ return sortPartsByTime(parts);
+ }, [parts]);
+
+ const toolConnections = getToolConnections(displayParts);
+
+ // For collapsed state: show last N items, but ensure at least one in-flight item is visible if exists
+ const visibleCollapsedParts = React.useMemo(() => {
+ const defaultVisible = displayParts.slice(-MAX_VISIBLE_COLLAPSED);
+
+ const hasVisibleActive = defaultVisible.some((p) => p.endedAt === undefined);
+ if (hasVisibleActive) {
+ return defaultVisible;
+ }
+
+ const activeParts = displayParts.filter((p) => p.endedAt === undefined);
+ if (activeParts.length === 0) {
+ return defaultVisible;
+ }
+
+ const newestActive = activeParts[activeParts.length - 1];
+ const visibleIds = new Set(defaultVisible.map((p) => p.id));
+
+ if (visibleIds.has(newestActive.id)) {
+ return defaultVisible;
+ }
+
+ const replacementIndex = 0;
+ const result = [...defaultVisible];
+ result[replacementIndex] = newestActive;
+
+ return result.sort((a, b) => {
+ const aIndex = displayParts.findIndex((p) => p.id === a.id);
+ const bIndex = displayParts.findIndex((p) => p.id === b.id);
+ return aIndex - bIndex;
+ });
+ }, [displayParts]);
+
+ // Set of part IDs that were visible in collapsed state
+ const visibleInCollapsedIds = React.useMemo(() => {
+ const ids = new Set();
+ visibleCollapsedParts.forEach((p) => {
+ ids.add(p.id);
+ });
+ return ids;
+ }, [visibleCollapsedParts]);
+
+ // Connections for collapsed view (based on visible parts only)
+ const collapsedToolConnections = React.useMemo(() => {
+ return getToolConnections(visibleCollapsedParts);
+ }, [visibleCollapsedParts]);
+
+ const hiddenCount = Math.max(0, displayParts.length - MAX_VISIBLE_COLLAPSED);
+
+ if (displayParts.length === 0) {
+ return null;
+ }
+
+ const partsToRender = isExpanded ? displayParts : visibleCollapsedParts;
+ const connectionsToUse = isExpanded ? toolConnections : collapsedToolConnections;
+
+ // If there are no hidden items, header is not interactive
+ const isHeaderInteractive = hiddenCount > 0;
+
+ return (
+
+
+
+
+
+ {isHeaderInteractive ? (
+ <>
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+
+ )}
+
+
Activity
+
+
+ {diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
+
+
+
+ +{Math.max(0, diffStats.additions)}
+
+ /
+
+ -{Math.max(0, diffStats.deletions)}
+
+
+
+ )}
+
+
+
+
+ {!isExpanded && hiddenCount > 0 && (
+
+ +{hiddenCount} more...
+
+ )}
+
+ {partsToRender.map((activity) => {
+ const partId = activity.id;
+ const connection = connectionsToUse[partId];
+
+ const animationKey = `${partId}-exp${expansionKey}`;
+
+ // Determine if animation should be skipped:
+ // 1. When expanding from collapsed: skip for items that were already visible
+ // 2. When collapsed: skip for items already shown before (track in ref)
+ const wasVisibleInCollapsed = visibleInCollapsedIds.has(activity.id);
+
+ let skipAnimation = false;
+ if (justExpandedFromCollapsed && wasVisibleInCollapsed) {
+ // Expanding: don't animate items that were already visible in collapsed state
+ skipAnimation = true;
+ } else if (!isExpanded) {
+ // Collapsed: animate only items that haven't been shown yet
+ if (shownInCollapsedRef.current.has(activity.id)) {
+ skipAnimation = true;
+ } else {
+ // Mark as shown for future renders
+ shownInCollapsedRef.current.add(activity.id);
+ }
+ }
+
+ switch (activity.kind) {
+ case 'tool':
+ return (
+
+ onToggleTool(partId)}
+ syntaxTheme={syntaxTheme}
+ isMobile={isMobile}
+ onContentChange={onContentChange}
+ onShowPopup={onShowPopup}
+ hasPrevTool={connection?.hasPrev ?? false}
+ hasNextTool={connection?.hasNext ?? false}
+ />
+
+ );
+
+ case 'reasoning':
+ return (
+
+
+
+ );
+
+ case 'justification':
+ return (
+
+
+
+ );
+
+ default:
+ return null;
+ }
+ })}
+
+
+
+ );
+};
+
+export default React.memo(ProgressiveGroup);
diff --git a/ui/src/components/chat/message/parts/ReasoningPart.tsx b/ui/src/components/chat/message/parts/ReasoningPart.tsx
new file mode 100644
index 0000000..c232a4c
--- /dev/null
+++ b/ui/src/components/chat/message/parts/ReasoningPart.tsx
@@ -0,0 +1,256 @@
+import React from 'react';
+import type { ComponentType } from 'react';
+import type { Part } from '@opencode-ai/sdk/v2';
+import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import { formatTimestampForDisplay } from '../timeFormat';
+import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { useUIStore } from '@/stores/useUIStore';
+
+type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
+
+export type ReasoningVariant = 'thinking' | 'justification';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type IconComponent = ComponentType;
+
+const variantConfig: Record<
+ ReasoningVariant,
+ { label: string; Icon: IconComponent }
+> = {
+ thinking: { label: 'Thinking', Icon: RiBrainAi3Line },
+ justification: { label: 'Justification', Icon: RiChatAi3Line },
+};
+
+const cleanReasoningText = (text: string): string => {
+ if (typeof text !== 'string' || text.trim().length === 0) {
+ return '';
+ }
+
+ return text
+ .split('\n')
+ .map((line: string) => line.replace(/^>\s?/, '').trimEnd())
+ .filter((line: string) => line.trim().length > 0)
+ .join('\n')
+ .trim();
+};
+
+const getReasoningSummary = (text: string): string => {
+ if (!text) {
+ return '';
+ }
+
+ const trimmed = text.trim();
+ const newlineIndex = trimmed.indexOf('\n');
+ const periodIndex = trimmed.indexOf('.');
+
+ const cutoffCandidates = [
+ newlineIndex >= 0 ? newlineIndex : Infinity,
+ periodIndex >= 0 ? periodIndex : Infinity,
+ ];
+ const cutoff = Math.min(...cutoffCandidates);
+
+ if (!Number.isFinite(cutoff)) {
+ return trimmed;
+ }
+
+ return trimmed.substring(0, cutoff).trim();
+};
+
+const formatDuration = (start: number, end?: number, now: number = Date.now()): string => {
+ const duration = end ? end - start : now - start;
+ const seconds = duration / 1000;
+ const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
+ return `${displaySeconds.toFixed(1)}s`;
+};
+
+const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
+ const [now, setNow] = React.useState(() => Date.now());
+
+ React.useEffect(() => {
+ if (!active) {
+ return;
+ }
+
+ const timer = window.setInterval(() => {
+ setNow(Date.now());
+ }, 100);
+
+ return () => window.clearInterval(timer);
+ }, [active]);
+
+ return <>{formatDuration(start, end, now)}>;
+};
+
+type ReasoningTimelineBlockProps = {
+ text: string;
+ variant: ReasoningVariant;
+ onContentChange?: (reason?: ContentChangeReason) => void;
+ blockId: string;
+ time?: { start?: number; end?: number };
+};
+
+export const ReasoningTimelineBlock: React.FC = ({
+ text,
+ variant,
+ onContentChange,
+ blockId,
+ time,
+}) => {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const isMobile = useUIStore((state) => state.isMobile);
+ const showActivityHeaderTimestamps = useUIStore((state) => state.showActivityHeaderTimestamps);
+
+ const summary = React.useMemo(() => getReasoningSummary(text), [text]);
+ const { label, Icon } = variantConfig[variant];
+ const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined;
+ const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined;
+ const endedTimestampText = React.useMemo(() => {
+ if (typeof timeEnd !== 'number') {
+ return null;
+ }
+
+ const formatted = formatTimestampForDisplay(timeEnd);
+ return formatted.length > 0 ? formatted : null;
+ }, [timeEnd]);
+
+ React.useEffect(() => {
+ if (text.trim().length === 0) {
+ return;
+ }
+ onContentChange?.('structural');
+ }, [onContentChange, isExpanded, text]);
+
+ if (!text || text.trim().length === 0) {
+ return null;
+ }
+
+ return (
+
+
setIsExpanded((prev) => !prev)}
+ >
+
+
+
+
+
+
+ {isExpanded ? : }
+
+
+
{label}
+
+
+ {(summary || typeof timeStart === 'number' || endedTimestampText) ? (
+
+ {summary ? {summary} : null}
+ {typeof timeStart === 'number' ? (
+
+
+
+
+ {!isMobile && endedTimestampText && showActivityHeaderTimestamps ? (
+
+ {endedTimestampText}
+
+ ) : null}
+
+ ) : null}
+ {typeof timeStart !== 'number' && !isMobile && endedTimestampText && showActivityHeaderTimestamps ? (
+
+ {endedTimestampText}
+
+ ) : null}
+
+ ) : null}
+
+
+ {isExpanded && (
+
+
+ {text}
+
+
+ )}
+
+ );
+};
+
+type ReasoningPartProps = {
+ part: Part;
+ onContentChange?: (reason?: ContentChangeReason) => void;
+ messageId: string;
+};
+
+const ReasoningPart: React.FC = ({
+ part,
+ onContentChange,
+ messageId,
+}) => {
+ const partWithText = part as PartWithText;
+ const rawText = partWithText.text || partWithText.content || '';
+ const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
+ const time = partWithText.time;
+
+ // Show reasoning even if time.end isn't set yet (during streaming)
+ // Only hide if there's no text content
+ if (!textContent || textContent.trim().length === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const formatReasoningText = (text: string): string => cleanReasoningText(text);
+
+export default ReasoningPart;
diff --git a/ui/src/components/chat/message/parts/ToolPart.tsx b/ui/src/components/chat/message/parts/ToolPart.tsx
new file mode 100644
index 0000000..8d49c78
--- /dev/null
+++ b/ui/src/components/chat/message/parts/ToolPart.tsx
@@ -0,0 +1,1938 @@
+
+import React from 'react';
+import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
+import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
+import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
+import { cn } from '@/lib/utils';
+import { formatTimestampForDisplay } from '../timeFormat';
+import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
+import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
+import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
+import { toolDisplayStyles } from '@/lib/typography';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { opencodeClient } from '@/lib/opencode/client';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
+import type { ToolPopupContent } from '../types';
+import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
+import { getDefaultTheme } from '@/lib/theme/themes';
+
+import {
+ renderListOutput,
+ renderGrepOutput,
+ renderGlobOutput,
+ renderTodoOutput,
+ renderWebSearchOutput,
+ formatEditOutput,
+ detectLanguageFromOutput,
+ formatInputForDisplay,
+ parseReadToolOutput,
+} from '../toolRenderers';
+import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
+import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock';
+
+type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } };
+
+interface ToolPartProps {
+ part: ToolPartType;
+ isExpanded: boolean;
+ onToggle: (toolId: string) => void;
+ syntaxTheme: { [key: string]: React.CSSProperties };
+ isMobile: boolean;
+ onContentChange?: (reason?: ContentChangeReason) => void;
+ onShowPopup?: (content: ToolPopupContent) => void;
+ hasPrevTool?: boolean;
+ hasNextTool?: boolean;
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const getToolIcon = (toolName: string) => {
+ const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
+ const tool = toolName.toLowerCase();
+
+ if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
+ return ;
+ }
+ if (tool === 'write' || tool === 'create' || tool === 'file_write') {
+ return ;
+ }
+ if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
+ return ;
+ }
+ if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
+ return ;
+ }
+ if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
+ return ;
+ }
+ if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
+ return ;
+ }
+ if (tool === 'glob') {
+ return ;
+ }
+ if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
+ return ;
+ }
+ if (
+ tool === 'web-search' ||
+ tool === 'websearch' ||
+ tool === 'search_web' ||
+ tool === 'codesearch' ||
+ tool === 'google' ||
+ tool === 'bing' ||
+ tool === 'duckduckgo' ||
+ tool === 'perplexity'
+ ) {
+ return ;
+ }
+ if (tool === 'todowrite' || tool === 'todoread') {
+ return ;
+ }
+ if (tool === 'structuredoutput' || tool === 'structured_output') {
+ return ;
+ }
+ if (tool === 'skill') {
+ return ;
+ }
+ if (tool === 'task') {
+ return ;
+ }
+ if (tool === 'question') {
+ return ;
+ }
+ if (tool === 'plan_enter') {
+ return ;
+ }
+ if (tool === 'plan_exit') {
+ return ;
+ }
+ if (tool.startsWith('git')) {
+ return ;
+ }
+ return ;
+};
+
+const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
+ const duration = end ? end - start : now - start;
+ const seconds = duration / 1000;
+
+ const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
+ return `${displaySeconds.toFixed(1)}s`;
+};
+
+const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
+ const [now, setNow] = React.useState(() => Date.now());
+
+ React.useEffect(() => {
+ if (!active) {
+ return;
+ }
+ const timer = window.setInterval(() => {
+ setNow(Date.now());
+ }, 100);
+ return () => window.clearInterval(timer);
+ }, [active]);
+
+ return <>{formatDuration(start, end, now)}>;
+};
+
+const parseDiffStats = (metadata?: Record): { added: number; removed: number } | null => {
+ if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
+
+ const lines = metadata.diff.split('\n');
+ let added = 0;
+ let removed = 0;
+
+ for (const line of lines) {
+ if (line.startsWith('+') && !line.startsWith('+++')) added++;
+ if (line.startsWith('-') && !line.startsWith('---')) removed++;
+ }
+
+ if (added === 0 && removed === 0) return null;
+ return { added, removed };
+};
+
+const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
+ if (!diffText || typeof diffText !== 'string') {
+ return undefined;
+ }
+
+ const lines = diffText.split('\n');
+ let currentNewLine: number | undefined;
+ let firstHunkStart: number | undefined;
+
+ for (const rawLine of lines) {
+ const line = rawLine.replace(/\r$/, '');
+ const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
+ if (hunkMatch) {
+ const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
+ if (Number.isFinite(parsed)) {
+ currentNewLine = Math.max(1, parsed);
+ if (!Number.isFinite(firstHunkStart)) {
+ firstHunkStart = currentNewLine;
+ }
+ }
+ continue;
+ }
+
+ if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) {
+ continue;
+ }
+
+ if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
+ continue;
+ }
+
+ if (line.startsWith('+')) {
+ return currentNewLine;
+ }
+
+ if (line.startsWith(' ')) {
+ currentNewLine += 1;
+ continue;
+ }
+
+ if (line.startsWith('-') || line.startsWith('\\')) {
+ continue;
+ }
+ }
+
+ return firstHunkStart;
+};
+
+const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record): number | undefined => {
+ if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
+ return undefined;
+ }
+
+ if (typeof metadata.diff === 'string') {
+ const line = extractFirstChangedLineFromDiff(metadata.diff);
+ if (Number.isFinite(line)) {
+ return line;
+ }
+ }
+
+ const files = Array.isArray(metadata.files) ? metadata.files : [];
+ const firstFile = files[0] as { diff?: unknown } | undefined;
+ if (typeof firstFile?.diff === 'string') {
+ const line = extractFirstChangedLineFromDiff(firstFile.diff);
+ if (Number.isFinite(line)) {
+ return line;
+ }
+ }
+
+ return undefined;
+};
+
+const getPrimaryDiffFromMetadata = (
+ tool: string,
+ metadata?: Record,
+ preferredPath?: string,
+): string | undefined => {
+ if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
+ return undefined;
+ }
+
+ const files = Array.isArray(metadata.files) ? metadata.files : [];
+ if (files.length > 0) {
+ const preferred = typeof preferredPath === 'string' && preferredPath.length > 0
+ ? preferredPath
+ : undefined;
+ const matched = preferred
+ ? files.find((file) => {
+ if (!file || typeof file !== 'object') {
+ return false;
+ }
+ const candidate = file as { relativePath?: unknown; filePath?: unknown };
+ return candidate.relativePath === preferred || candidate.filePath === preferred;
+ })
+ : files[0];
+
+ if (matched && typeof matched === 'object') {
+ const patch = (matched as { diff?: unknown }).diff;
+ if (typeof patch === 'string' && patch.trim().length > 0) {
+ return patch;
+ }
+ }
+ }
+
+ if (typeof metadata.diff === 'string' && metadata.diff.trim().length > 0) {
+ return metadata.diff;
+ }
+
+ return undefined;
+};
+
+const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
+ if (absolutePath.startsWith(currentDirectory)) {
+ const relativePath = absolutePath.substring(currentDirectory.length);
+
+ return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
+ }
+
+ return absolutePath;
+};
+
+const usePierreThemeConfig = () => {
+ const themeSystem = useOptionalThemeSystem();
+ const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
+ const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
+
+ const availableThemes = React.useMemo(
+ () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
+ [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
+ );
+ const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
+ const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
+
+ const lightTheme = React.useMemo(
+ () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
+ [availableThemes, fallbackLightTheme, lightThemeId],
+ );
+ const darkTheme = React.useMemo(
+ () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
+ [availableThemes, darkThemeId, fallbackDarkTheme],
+ );
+
+ React.useEffect(() => {
+ ensurePierreThemeRegistered(lightTheme);
+ ensurePierreThemeRegistered(darkTheme);
+ }, [darkTheme, lightTheme]);
+
+ const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
+
+ return {
+ pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
+ pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
+ };
+};
+
+// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
+const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
+ const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
+ if (!match) return null;
+
+ const pairs: Array<{ question: string; answer: string }> = [];
+ const content = match[1];
+
+ // Match "question"="answer" pairs, handling multiline answers
+ const pairRegex = /"([^"]+)"="([^"]*(?:[^"\\]|\\.)*)"/g;
+ let pairMatch;
+ while ((pairMatch = pairRegex.exec(content)) !== null) {
+ pairs.push({
+ question: pairMatch[1],
+ answer: pairMatch[2],
+ });
+ }
+
+ return pairs.length > 0 ? pairs : null;
+};
+
+const formatStructuredOutputDescription = (input: Record | undefined, output: unknown): string => {
+ if (typeof output === 'string' && output.trim().length > 0) {
+ const maxLength = 100;
+ const text = output.trim();
+ return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
+ }
+
+ if (!input || typeof input !== 'object') {
+ return 'Result';
+ }
+
+ const rawValue = Object.prototype.hasOwnProperty.call(input, 'result') ? input.result : input;
+
+ const toPreview = (value: unknown): string => {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ if (Array.isArray(value)) {
+ const joined = value
+ .map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
+ .join(', ');
+ return joined;
+ }
+ if (value && typeof value === 'object') {
+ const record = value as Record;
+ if (typeof record.subject === 'string' && record.subject.trim().length > 0) {
+ return record.subject;
+ }
+ if (typeof record.title === 'string' && record.title.trim().length > 0) {
+ return record.title;
+ }
+ return JSON.stringify(value);
+ }
+ return '';
+ };
+
+ const preview = toPreview(rawValue).trim();
+ if (!preview) {
+ return 'Result';
+ }
+
+ const maxLength = 100;
+ const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview;
+ return truncated;
+};
+
+const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => {
+ const stateWithData = state as ToolStateWithMetadata;
+ const metadata = stateWithData.metadata;
+ const input = stateWithData.input;
+
+ if (part.tool === 'apply_patch') {
+ const files = Array.isArray(metadata?.files) ? metadata?.files : [];
+ const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
+ const filePath = firstFile?.relativePath || firstFile?.filePath;
+ if (files.length > 1) return null;
+ if (typeof filePath === 'string') {
+ return getRelativePath(filePath, currentDirectory);
+ }
+ return null;
+ }
+
+ if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
+ const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
+ if (typeof filePath === 'string') {
+ return getRelativePath(filePath, currentDirectory);
+ }
+ }
+
+ if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool) && input) {
+ const filePath = input?.filePath || input?.file_path || input?.path;
+ if (typeof filePath === 'string') {
+ return getRelativePath(filePath, currentDirectory);
+ }
+ }
+
+ return null;
+};
+
+const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => {
+ const stateWithData = state as ToolStateWithMetadata;
+ const metadata = stateWithData.metadata;
+ const input = stateWithData.input;
+ const tool = part.tool.toLowerCase();
+
+ if (tool === 'structuredoutput' || tool === 'structured_output') {
+ return formatStructuredOutputDescription(input, stateWithData.output);
+ }
+
+ const filePathLabel = getToolDescriptionPath(part, state, currentDirectory);
+ if (filePathLabel) {
+ return filePathLabel;
+ }
+
+ if (part.tool === 'apply_patch') {
+ const files = Array.isArray(metadata?.files) ? metadata?.files : [];
+ if (files.length > 1) {
+ return `${files.length} files`;
+ }
+ return 'Patch';
+ }
+
+ // Question tool: show "Asked N question(s)"
+ if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) {
+ const count = input.questions.length;
+ return `Asked ${count} question${count !== 1 ? 's' : ''}`;
+ }
+
+ if (part.tool === 'bash' && input?.command && typeof input.command === 'string') {
+ const firstLine = input.command.split('\n')[0];
+ return firstLine.substring(0, 100);
+ }
+
+ if (part.tool === 'task' && input?.description && typeof input.description === 'string') {
+ return input.description.substring(0, 80);
+ }
+
+ if (part.tool === 'skill' && input?.name && typeof input.name === 'string') {
+ return input.name;
+ }
+
+ if (part.tool === 'plan_enter') {
+ return 'Switching to planning';
+ }
+
+ if (part.tool === 'plan_exit') {
+ return 'Switching to building';
+ }
+
+ const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
+ return typeof desc === 'string' ? desc : '';
+};
+
+interface ToolScrollableSectionProps {
+ children: React.ReactNode;
+ maxHeightClass?: string;
+ className?: string;
+ outerClassName?: string;
+ disableHorizontal?: boolean;
+}
+
+const ToolScrollableSection: React.FC = ({
+ children,
+ maxHeightClass = 'max-h-[60vh]',
+ className,
+ outerClassName,
+ disableHorizontal = false,
+}) => (
+
+
+ {children}
+
+
+);
+
+type TaskToolSummaryEntry = {
+ id?: string;
+ tool?: string;
+ state?: {
+ status?: string;
+ title?: string;
+ };
+};
+
+type SessionMessageWithParts = {
+ info?: {
+ role?: string;
+ };
+ parts?: Array<{
+ id?: string;
+ type?: string;
+ tool?: string;
+ state?: {
+ status?: string;
+ title?: string;
+ };
+ }>;
+};
+
+const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = [];
+
+const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
+ if (typeof output !== 'string' || output.trim().length === 0) {
+ return undefined;
+ }
+ const parsedMetadata = parseTaskMetadataBlock(output);
+ if (parsedMetadata.sessionId) {
+ return parsedMetadata.sessionId;
+ }
+ const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
+ const candidate = match?.[1];
+ return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined;
+};
+
+const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
+ const entries: TaskToolSummaryEntry[] = [];
+
+ for (const message of messages) {
+ if (message?.info?.role !== 'assistant') {
+ continue;
+ }
+ const parts = Array.isArray(message.parts) ? message.parts : [];
+ for (const part of parts) {
+ if (part?.type !== 'tool') {
+ continue;
+ }
+ const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
+ if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') {
+ continue;
+ }
+ entries.push({
+ id: part.id,
+ tool: part.tool,
+ state: {
+ status: part.state?.status,
+ title: part.state?.title,
+ },
+ });
+ }
+ }
+
+ return entries;
+};
+
+const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
+ const title = entry.state?.title;
+ if (typeof title === 'string' && title.trim().length > 0) {
+ return title;
+ }
+ if (typeof entry.tool === 'string' && entry.tool.trim().length > 0) {
+ return entry.tool;
+ }
+ return 'tool';
+};
+
+const FILE_PATH_LABEL_TOOLS = new Set([
+ 'read',
+ 'view',
+ 'file_read',
+ 'cat',
+ 'write',
+ 'create',
+ 'file_write',
+ 'edit',
+ 'multiedit',
+ 'apply_patch',
+]);
+
+const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
+ if (!FILE_PATH_LABEL_TOOLS.has(toolName.toLowerCase())) {
+ return false;
+ }
+
+ const trimmed = label.trim();
+ if (!trimmed || trimmed === 'Patch' || /^\d+\s+files$/.test(trimmed)) {
+ return false;
+ }
+
+ return trimmed.includes('/') || trimmed.includes('\\');
+};
+
+const stripTaskMetadataFromOutput = (output: string): string => {
+ // Strip only a trailing ... block.
+ return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
+};
+
+const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const normalized: TaskToolSummaryEntry[] = [];
+ for (const entry of value) {
+ if (typeof entry === 'string') {
+ normalized.push({
+ tool: 'tool',
+ state: { status: 'completed', title: entry },
+ });
+ continue;
+ }
+
+ if (!entry || typeof entry !== 'object') {
+ continue;
+ }
+
+ const record = entry as {
+ id?: unknown;
+ tool?: unknown;
+ title?: unknown;
+ status?: unknown;
+ state?: { status?: unknown; title?: unknown };
+ };
+
+ const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined;
+ const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined;
+ const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined);
+ const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined);
+
+ normalized.push({
+ id: typeof record.id === 'string' ? record.id : undefined,
+ tool: typeof record.tool === 'string' ? record.tool : 'tool',
+ state: {
+ status,
+ title,
+ },
+ });
+ }
+
+ return normalized;
+};
+
+const parseTaskMetadataBlock = (output: string | undefined): {
+ sessionId?: string;
+ summaryEntries: TaskToolSummaryEntry[];
+} => {
+ if (typeof output !== 'string' || output.trim().length === 0) {
+ return { summaryEntries: [] };
+ }
+
+ const blockMatch = output.match(/\s*([\s\S]*?)\s*<\/task_metadata>/i);
+ if (!blockMatch?.[1]) {
+ return { summaryEntries: [] };
+ }
+
+ const raw = blockMatch[1].trim();
+ if (!raw) {
+ return { summaryEntries: [] };
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as {
+ sessionId?: unknown;
+ sessionID?: unknown;
+ summary?: unknown;
+ entries?: unknown;
+ tools?: unknown;
+ calls?: unknown;
+ };
+
+ const summaryEntries = normalizeTaskSummaryEntries(
+ parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls
+ );
+
+ const sessionId =
+ (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0
+ ? parsed.sessionId.trim()
+ : undefined) ??
+ (typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0
+ ? parsed.sessionID.trim()
+ : undefined);
+
+ return { sessionId, summaryEntries };
+ } catch {
+ return { summaryEntries: [] };
+ }
+};
+
+const TaskToolSummary: React.FC<{
+ entries: TaskToolSummaryEntry[];
+ isExpanded: boolean;
+ isMobile: boolean;
+ hasPrevTool: boolean;
+ hasNextTool: boolean;
+ output?: string;
+ sessionId?: string;
+ onShowPopup?: (content: ToolPopupContent) => void;
+ input?: Record;
+}> = ({ entries, isExpanded, isMobile, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => {
+ const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
+ const displayEntries = React.useMemo(() => {
+ const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
+ return nonPending.length > 0 ? nonPending : entries;
+ }, [entries]);
+
+ const trimmedOutput = typeof output === 'string'
+ ? stripTaskMetadataFromOutput(output)
+ : '';
+ const hasOutput = trimmedOutput.length > 0;
+ const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
+
+ const handleOpenSession = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (sessionId) {
+ setCurrentSession(sessionId);
+ }
+ };
+
+ const agentType = typeof input?.subagent_type === 'string'
+ ? input.subagent_type
+ : 'subagent';
+
+ if (displayEntries.length === 0 && !hasOutput && !sessionId) {
+ return null;
+ }
+
+ const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
+ const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
+
+ return (
+
+ {displayEntries.length > 0 ? (
+
+
+ {hiddenCount > 0 ? (
+
+{hiddenCount} more…
+ ) : null}
+
+ {visibleEntries.map((entry, idx) => {
+ const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool';
+ const label = getTaskSummaryLabel(entry);
+ const status = entry.state?.status;
+
+ const displayName = getToolMetadata(toolName).displayName;
+
+ return (
+
+ {getToolIcon(toolName)}
+ {displayName}
+ {status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
+ renderPathLikeGitChanges(label)
+ ) : (
+ {label}
+ )}
+
+ );
+ })}
+
+
+ ) : null}
+
+ {sessionId && (
+
event.stopPropagation()}
+ onClick={handleOpenSession}
+ >
+
+ Open {agentType.charAt(0).toUpperCase() + agentType.slice(1)} subtask
+
+ )}
+
+ {hasOutput ? (
+
0 || sessionId) && 'pt-1')}
+ >
+
event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ setIsOutputExpanded((prev) => !prev);
+ }}
+ >
+ {isOutputExpanded ? (
+
+ ) : (
+
+ )}
+ Output
+
+ {isOutputExpanded ? (
+
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+};
+
+interface DiffPreviewProps {
+ diff: string;
+ pierreTheme: { light: string; dark: string };
+ pierreThemeType: 'light' | 'dark';
+ diffViewMode: DiffViewMode;
+}
+
+const TOOL_DIFF_UNSAFE_CSS = `
+ [data-diff-header],
+ [data-diff] {
+ [data-separator] {
+ height: 24px !important;
+ }
+ }
+`;
+
+const TOOL_DIFF_METRICS = {
+ hunkLineCount: 50,
+ lineHeight: 24,
+ diffHeaderHeight: 44,
+ hunkSeparatorHeight: 24,
+ fileGap: 0,
+};
+
+type DiffPatchEntry = {
+ id: string;
+ title: string;
+ patch: string;
+};
+
+const renderPathLikeGitChanges = (path: string, grow = true) => {
+ const lastSlash = path.lastIndexOf('/');
+ if (lastSlash === -1) {
+ return (
+
+ {path}
+
+ );
+ }
+
+ const dir = path.slice(0, lastSlash);
+ const name = path.slice(lastSlash + 1);
+
+ return (
+
+
+ {dir}
+
+
+ /
+ {name}
+
+
+ );
+};
+
+const getDiffPatchEntries = (
+ metadata: Record | undefined,
+ fallbackDiff: string,
+ currentDirectory: string,
+): DiffPatchEntry[] => {
+ const files = Array.isArray(metadata?.files) ? metadata.files : [];
+
+ const entries = files
+ .map((file, index) => {
+ if (!file || typeof file !== 'object') {
+ return null;
+ }
+
+ const record = file as { relativePath?: unknown; filePath?: unknown; diff?: unknown };
+ const patch = typeof record.diff === 'string' ? record.diff.trim() : '';
+ if (!patch) {
+ return null;
+ }
+
+ const rawPath = typeof record.relativePath === 'string'
+ ? record.relativePath
+ : typeof record.filePath === 'string'
+ ? record.filePath
+ : `File ${index + 1}`;
+
+ const title = typeof rawPath === 'string'
+ ? getRelativePath(rawPath, currentDirectory)
+ : `File ${index + 1}`;
+
+ return {
+ id: `${title}-${index}`,
+ title,
+ patch,
+ } satisfies DiffPatchEntry;
+ })
+ .filter((entry): entry is DiffPatchEntry => entry !== null);
+
+ if (entries.length > 0) {
+ return entries;
+ }
+
+ return [
+ {
+ id: 'diff-0',
+ title: 'Diff',
+ patch: fallbackDiff,
+ },
+ ];
+};
+
+const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
+ return (
+
+ );
+});
+
+DiffPreview.displayName = 'DiffPreview';
+
+interface WriteInputPreviewProps {
+ content: string;
+ filePath?: string;
+ displayPath: string;
+ pierreTheme: { light: string; dark: string };
+ pierreThemeType: 'light' | 'dark';
+}
+
+const WriteInputPreview: React.FC = React.memo(({
+ content,
+ filePath,
+ displayPath,
+ pierreTheme,
+ pierreThemeType,
+}) => {
+ const language = React.useMemo(
+ () => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
+ [content, filePath]
+ );
+
+ const lineCount = Math.max(content.split('\n').length, 1);
+ const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
+
+ return (
+
+
+ {renderPathLikeGitChanges(displayPath)}
+ ({headerLineLabel})
+
+
+
+ );
+});
+
+WriteInputPreview.displayName = 'WriteInputPreview';
+
+// ── PERF-007: Read tool output with virtualised highlighting ─────────
+interface ReadToolVirtualizedProps {
+ outputString: string;
+ input?: Record;
+ syntaxTheme: { [key: string]: React.CSSProperties };
+ toolName: string;
+ currentDirectory: string;
+ pierreTheme: { light: string; dark: string };
+ pierreThemeType: 'light' | 'dark';
+ renderScrollableBlock: (
+ content: React.ReactNode,
+ options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
+ ) => React.ReactNode;
+}
+
+const ReadToolVirtualized: React.FC = React.memo(({
+ outputString,
+ input,
+ syntaxTheme,
+ toolName,
+ currentDirectory,
+ pierreTheme,
+ pierreThemeType,
+ renderScrollableBlock,
+}) => {
+ const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]);
+
+ const language = React.useMemo(() => {
+ const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n');
+ return detectLanguageFromOutput(contentForLanguage, toolName, input as Record);
+ }, [parsedReadOutput, toolName, input]);
+
+ const rawFilePath =
+ typeof input?.filePath === 'string'
+ ? input.filePath
+ : typeof input?.file_path === 'string'
+ ? input.file_path
+ : typeof input?.path === 'string'
+ ? input.path
+ : 'read-output';
+ const displayPath = getRelativePath(rawFilePath, currentDirectory);
+
+ const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({
+ text: line.text,
+ lineNumber: line.lineNumber,
+ isInfo: line.isInfo,
+ })), [parsedReadOutput]);
+
+ if (parsedReadOutput.type === 'file') {
+ const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n');
+ const lineCount = Math.max(parsedReadOutput.lines.length, 1);
+ const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
+ return renderScrollableBlock(
+
+
+ {renderPathLikeGitChanges(displayPath)}
+ ({headerLineLabel})
+
+
+
,
+ { className: 'p-1' }
+ ) as React.ReactElement;
+ }
+
+ return renderScrollableBlock(
+ ,
+ { className: 'p-1' }
+ ) as React.ReactElement;
+});
+
+ReadToolVirtualized.displayName = 'ReadToolVirtualized';
+
+interface ImagePreviewProps {
+ content: string;
+ filePath: string;
+ displayPath: string;
+}
+
+const ImagePreview: React.FC = React.memo(({ content, filePath, displayPath }) => {
+ const mimeType = getImageMimeType(filePath);
+ const isSvg = filePath.toLowerCase().endsWith('.svg');
+
+ // For SVG, content might be raw XML, otherwise assume base64
+ const imageSrc = React.useMemo(() => {
+ if (isSvg && !content.startsWith('data:')) {
+ // Raw SVG content
+ return `data:image/svg+xml;base64,${btoa(content)}`;
+ }
+ if (content.startsWith('data:')) {
+ return content;
+ }
+ // Assume base64 encoded
+ return `data:${mimeType};base64,${content}`;
+ }, [content, mimeType, isSvg]);
+
+ return (
+
+
+ {renderPathLikeGitChanges(displayPath)}
+
+
+
+
+
+ );
+});
+
+ImagePreview.displayName = 'ImagePreview';
+
+interface ToolExpandedContentProps {
+ part: ToolPartType;
+ state: ToolStateUnion;
+ syntaxTheme: { [key: string]: React.CSSProperties };
+ isMobile: boolean;
+ currentDirectory: string;
+ onShowPopup?: (content: ToolPopupContent) => void;
+ hasPrevTool: boolean;
+ hasNextTool: boolean;
+}
+
+const ToolExpandedContent: React.FC = React.memo(({
+ part,
+ state,
+ syntaxTheme,
+ isMobile,
+ currentDirectory,
+ onShowPopup,
+ hasPrevTool,
+ hasNextTool,
+}) => {
+ const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
+ const [diffViewMode, setDiffViewMode] = React.useState('unified');
+ const stateWithData = state as ToolStateWithMetadata;
+ const metadata = stateWithData.metadata;
+ const input = stateWithData.input;
+ const rawOutput = stateWithData.output;
+ const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
+ const outputString = typeof rawOutput === 'string' ? rawOutput : '';
+
+ const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
+ const diffEntries = React.useMemo(
+ () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
+ [currentDirectory, diffContent, metadata]
+ );
+ const writeFilePath = part.tool === 'write'
+ ? typeof input?.filePath === 'string'
+ ? input.filePath
+ : typeof input?.file_path === 'string'
+ ? input.file_path
+ : typeof input?.path === 'string'
+ ? input.path
+ : undefined
+ : undefined;
+ const writeInputContent = part.tool === 'write'
+ ? typeof (input as { content?: unknown })?.content === 'string'
+ ? (input as { content?: string }).content
+ : typeof (input as { text?: unknown })?.text === 'string'
+ ? (input as { text?: string }).text
+ : null
+ : null;
+ const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
+ const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
+ const writeDisplayPath = shouldShowWriteInputPreview
+ ? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
+ : null;
+
+ const inputTextContent = React.useMemo(() => {
+ if (!input || typeof input !== 'object' || Object.keys(input).length === 0) {
+ return '';
+ }
+
+ if ('command' in input && typeof input.command === 'string' && part.tool === 'bash') {
+ return formatInputForDisplay(input, part.tool);
+ }
+
+ if (typeof (input as { content?: unknown }).content === 'string') {
+ return (input as { content?: string }).content ?? '';
+ }
+
+ return formatInputForDisplay(input, part.tool);
+ }, [input, part.tool]);
+ const hasInputText = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0;
+
+ React.useEffect(() => {
+ setDiffViewMode('unified');
+ }, [part.id]);
+
+ const renderScrollableBlock = (
+ content: React.ReactNode,
+ options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
+ ) => (
+
+ {content}
+
+ );
+
+ const renderResultContent = () => {
+ // Question tool: show parsed Q&A summary
+ if (part.tool === 'question') {
+ if (state.status === 'completed' && hasStringOutput) {
+ const parsedQA = parseQuestionOutput(outputString);
+ if (parsedQA && parsedQA.length > 0) {
+ return renderScrollableBlock(
+
+ {parsedQA.map((qa, index) => (
+
+
{qa.question}
+
{qa.answer}
+
+ ))}
+
,
+ { maxHeightClass: 'max-h-[40vh]' }
+ );
+ }
+ }
+
+ if (state.status === 'error' && 'error' in state) {
+ return (
+
+
Error:
+
+ {state.error}
+
+
+ );
+ }
+
+ return Awaiting response...
;
+ }
+
+ if (part.tool === 'todowrite' || part.tool === 'todoread') {
+ if (state.status === 'completed' && hasStringOutput) {
+ const todoContent = renderTodoOutput(outputString, { unstyled: true });
+ return renderScrollableBlock(
+ todoContent ?? (
+ Unable to parse todo list
+ )
+ );
+ }
+
+ if (state.status === 'error' && 'error' in state) {
+ return (
+
+
Error:
+
+ {state.error}
+
+
+ );
+ }
+
+ return Processing todo list...
;
+ }
+
+ if (part.tool === 'list' && hasStringOutput) {
+ const listOutput = renderListOutput(outputString, { unstyled: true });
+ return renderScrollableBlock(
+ listOutput ?? (
+
+ {outputString}
+
+ )
+ );
+ }
+
+ if (part.tool === 'grep' && hasStringOutput) {
+ const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true });
+ return renderScrollableBlock(
+ grepOutput ?? (
+
+ {outputString}
+
+ )
+ );
+ }
+
+ if (part.tool === 'glob' && hasStringOutput) {
+ const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true });
+ return renderScrollableBlock(
+ globOutput ?? (
+
+ {outputString}
+
+ )
+ );
+ }
+
+ if (part.tool === 'task' && hasStringOutput) {
+ return renderScrollableBlock(
+
+
+
+ );
+ }
+
+ if ((part.tool === 'web-search' || part.tool === 'websearch' || part.tool === 'search_web') && hasStringOutput) {
+ const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true });
+ return renderScrollableBlock(
+ webSearchContent ?? (
+
+ {outputString}
+
+ )
+ );
+ }
+
+ if (part.tool === 'codesearch' && hasStringOutput) {
+ return renderScrollableBlock(
+
+
+
+ );
+ }
+
+ if (part.tool === 'skill' && hasStringOutput) {
+ return renderScrollableBlock(
+
+
+
+ );
+ }
+
+ if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffEntries.length > 0) {
+ return renderScrollableBlock(
+
+ {diffEntries.map((entry) => (
+
+ {diffEntries.length > 1 ? (
+
+ {renderPathLikeGitChanges(entry.title)}
+
+ ) : null}
+
+
+ ))}
+
,
+ { className: 'p-1' }
+ );
+ }
+
+ if (hasStringOutput && outputString.trim()) {
+ if (part.tool === 'read') {
+ return ;
+ }
+
+ return renderScrollableBlock(
+
+ {formatEditOutput(outputString, part.tool, metadata)}
+ ,
+ { className: 'p-1' }
+ );
+ }
+
+ return renderScrollableBlock(
+ No output produced
,
+ { maxHeightClass: 'max-h-60' }
+ );
+ };
+
+ return (
+
+
+ {(part.tool === 'todowrite' || part.tool === 'todoread' || part.tool === 'question') ? (
+ renderResultContent()
+ ) : (
+ <>
+ {shouldShowWriteInputPreview && isWriteImageFile ? (
+
+ {renderScrollableBlock(
+
+ )}
+
+ ) : shouldShowWriteInputPreview ? (
+
+ {renderScrollableBlock(
+
+ )}
+
+ ) : hasInputText ? (
+
+ {renderScrollableBlock(
+
+ {inputTextContent}
+ ,
+ { maxHeightClass: 'max-h-60', className: 'tool-input-surface' }
+ )}
+
+ ) : null}
+
+ {part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
+
+
+
+ Result:
+
+ {(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? (
+
+ ) : null}
+
+ {renderResultContent()}
+
+ )}
+
+ {state.status === 'error' && 'error' in state && (
+
+
Error:
+
+ {state.error}
+
+
+ )}
+ >
+ )}
+
+ );
+});
+
+ToolExpandedContent.displayName = 'ToolExpandedContent';
+
+const ToolPart: React.FC = ({
+ part,
+ isExpanded,
+ onToggle,
+ syntaxTheme,
+ isMobile,
+ onContentChange,
+ onShowPopup,
+ hasPrevTool = false,
+ hasNextTool = false,
+}) => {
+ const state = part.state;
+ const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
+ const showActivityHeaderTimestamps = useUIStore((store) => store.showActivityHeaderTimestamps);
+
+ const isTaskTool = part.tool.toLowerCase() === 'task';
+
+ const status = state.status as string | undefined;
+ const isFinalized = status === 'completed' || status === 'error';
+ const isActive = status === 'running' || status === 'pending' || status === 'started';
+ const isError = state.status === 'error';
+
+
+
+ const shouldNotifyStructuralChange = isFinalized || isTaskTool;
+
+ React.useEffect(() => {
+ if (!shouldNotifyStructuralChange) {
+ return;
+ }
+ if (typeof isExpanded === 'boolean') {
+ onContentChange?.('structural');
+ }
+ }, [isExpanded, onContentChange, shouldNotifyStructuralChange]);
+
+ const stateWithData = state as ToolStateWithMetadata;
+ const metadata = stateWithData.metadata;
+ const input = stateWithData.input;
+ const time = stateWithData.time;
+
+ const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>({});
+
+ React.useEffect(() => {
+ setPinnedTime({});
+ }, [part.id]);
+
+ React.useEffect(() => {
+ setPinnedTime((prev) => {
+ const next = { ...prev };
+ let changed = false;
+
+ if (typeof time?.start === 'number' && (typeof prev.start !== 'number' || time.start < prev.start)) {
+ next.start = time.start;
+ changed = true;
+ }
+
+ if (typeof time?.end === 'number' && prev.end !== time.end) {
+ next.end = time.end;
+ changed = true;
+ }
+
+ return changed ? next : prev;
+ });
+ }, [time?.end, time?.start]);
+
+ const effectiveTimeStart = pinnedTime.start ?? time?.start;
+ const effectiveTimeEnd = pinnedTime.end ?? time?.end;
+
+ const endedTimestampText = React.useMemo(() => {
+ if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) {
+ return null;
+ }
+
+ const formatted = formatTimestampForDisplay(effectiveTimeEnd);
+ return formatted.length > 0 ? formatted : null;
+ }, [effectiveTimeEnd]);
+
+ const taskOutputString = React.useMemo(() => {
+ return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
+ }, [stateWithData.output]);
+
+ const parsedTaskMetadata = React.useMemo(() => {
+ return parseTaskMetadataBlock(taskOutputString);
+ }, [taskOutputString]);
+
+ const taskSessionId = React.useMemo(() => {
+ if (!isTaskTool) {
+ return undefined;
+ }
+ const candidate = metadata as { sessionId?: string } | undefined;
+ if (typeof candidate?.sessionId === 'string' && candidate.sessionId.trim().length > 0) {
+ return candidate.sessionId;
+ }
+ if (parsedTaskMetadata.sessionId) {
+ return parsedTaskMetadata.sessionId;
+ }
+ return readTaskSessionIdFromOutput(taskOutputString);
+ }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, taskOutputString]);
+
+ const childSessionMessages = useSessionStore(
+ React.useCallback((store) => {
+ if (!taskSessionId) {
+ return EMPTY_SESSION_MESSAGES;
+ }
+ return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES;
+ }, [taskSessionId])
+ );
+
+ const metadataTaskSummaryEntries = React.useMemo(() => {
+ if (!isTaskTool) {
+ return [];
+ }
+ const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined);
+ const normalized = normalizeTaskSummaryEntries(
+ candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls
+ );
+
+ if (normalized.length > 0) {
+ return normalized;
+ }
+
+ return parsedTaskMetadata.summaryEntries;
+ }, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]);
+
+ const childSessionTaskSummaryEntries = React.useMemo(() => {
+ if (!isTaskTool || !taskSessionId) {
+ return [];
+ }
+ if (!Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
+ return [];
+ }
+ return buildTaskSummaryEntriesFromSession(childSessionMessages);
+ }, [childSessionMessages, isTaskTool, taskSessionId]);
+
+ const taskSummaryEntries = React.useMemo(() => {
+ if (childSessionTaskSummaryEntries.length > 0) {
+ return childSessionTaskSummaryEntries;
+ }
+ return metadataTaskSummaryEntries;
+ }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
+
+ const fetchedTaskSessionsRef = React.useRef>(new Set());
+ React.useEffect(() => {
+ if (!isTaskTool || !taskSessionId) {
+ return;
+ }
+ if (childSessionTaskSummaryEntries.length > 0) {
+ return;
+ }
+ if (fetchedTaskSessionsRef.current.has(taskSessionId)) {
+ return;
+ }
+
+ fetchedTaskSessionsRef.current.add(taskSessionId);
+ let cancelled = false;
+
+ void opencodeClient
+ .getSessionMessages(taskSessionId, 500)
+ .then((messages) => {
+ if (cancelled || !Array.isArray(messages)) {
+ return;
+ }
+ if (messages.length === 0) {
+ fetchedTaskSessionsRef.current.delete(taskSessionId);
+ return;
+ }
+ useSessionStore.getState().syncMessages(taskSessionId, messages);
+ })
+ .catch(() => {
+ fetchedTaskSessionsRef.current.delete(taskSessionId);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [childSessionTaskSummaryEntries.length, isTaskTool, taskSessionId]);
+
+
+ const taskSummaryLenRef = React.useRef(taskSummaryEntries.length);
+ React.useEffect(() => {
+ if (!isTaskTool) {
+ return;
+ }
+ if (taskSummaryLenRef.current === taskSummaryEntries.length) {
+ return;
+ }
+ taskSummaryLenRef.current = taskSummaryEntries.length;
+ onContentChange?.('structural');
+ }, [isTaskTool, onContentChange, taskSummaryEntries.length]);
+
+ const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null;
+ const descriptionPath = getToolDescriptionPath(part, state, currentDirectory);
+ const description = getToolDescription(part, state, currentDirectory);
+ const displayName = getToolMetadata(part.tool).displayName;
+
+ // Get justification text (tool title/description) when setting is enabled
+ const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
+ const justificationText = React.useMemo(() => {
+ if (!showTextJustificationActivity) return null;
+ if (part.tool === 'apply_patch') return null;
+ if (part.tool.toLowerCase() === 'structuredoutput' || part.tool.toLowerCase() === 'structured_output') return null;
+ // Get title or description from state - this is the "yapping" text like "Shows system information"
+ const title = (stateWithData as { title?: string }).title;
+ if (typeof title === 'string' && title.trim().length > 0) {
+ return title;
+ }
+ const inputDesc = input?.description;
+ if (typeof inputDesc === 'string' && inputDesc.trim().length > 0) {
+ return inputDesc;
+ }
+ return null;
+ }, [showTextJustificationActivity, part.tool, stateWithData, input]);
+
+ const runtime = React.useContext(RuntimeAPIContext);
+
+ const handleMainClick = (e: { stopPropagation: () => void }) => {
+ if (isTaskTool || !runtime?.editor) {
+ onToggle(part.id);
+ return;
+ }
+
+ let filePath: unknown;
+ let targetLine: number | undefined;
+ let toolDiff: string | undefined;
+ if (part.tool === 'edit' || part.tool === 'multiedit') {
+ filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
+ targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
+ if (typeof filePath === 'string') {
+ toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
+ }
+ } else if (part.tool === 'apply_patch') {
+ const files = Array.isArray(metadata?.files) ? metadata?.files : [];
+ const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
+ filePath = firstFile?.relativePath || firstFile?.filePath;
+ targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
+ if (typeof filePath === 'string') {
+ toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
+ }
+ } else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) {
+ filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
+ }
+
+ if (typeof filePath === 'string') {
+ e.stopPropagation();
+ let absolutePath = filePath;
+ if (!filePath.startsWith('/')) {
+ absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
+ }
+ if (runtime.runtime.isVSCode && toolDiff && (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch')) {
+ const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
+ void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff });
+ return;
+ }
+ runtime.editor.openFile(absolutePath, targetLine);
+ } else {
+ onToggle(part.id);
+ }
+ };
+
+ const handleMainKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return;
+ }
+ event.preventDefault();
+ handleMainClick(event);
+ };
+
+ if (!isFinalized && !isActive && !isTaskTool) {
+ return null;
+ }
+
+ return (
+
+ {}
+
+
+ {}
+
{ event.stopPropagation(); onToggle(part.id); }}
+ >
+ {}
+
+ {getToolIcon(part.tool)}
+
+ {}
+
+ {isExpanded ? : }
+
+
+
+ {displayName}
+
+
+
+
+
+ {justificationText && (
+
+ {justificationText}
+
+ )}
+ {!justificationText && description && (
+ descriptionPath && description === descriptionPath ? (
+ renderPathLikeGitChanges(descriptionPath, false)
+ ) : (
+
+ {description}
+
+ )
+ )}
+ {diffStats && (
+
+ +{diffStats.added}
+ {' '}
+ -{diffStats.removed}
+
+ )}
+
+ {typeof effectiveTimeStart === 'number' ? (
+
+
+
+
+ {!isMobile && endedTimestampText && showActivityHeaderTimestamps ? (
+
+ {endedTimestampText}
+
+ ) : null}
+
+ ) : null}
+ {typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText && showActivityHeaderTimestamps ? (
+
+ {endedTimestampText}
+
+ ) : null}
+
+
+
+ {}
+ {isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized || taskSessionId) ? (
+
+ ) : null}
+
+ {!isTaskTool && isExpanded ? (
+
+ ) : null}
+
+ );
+};
+
+export default ToolPart;
diff --git a/ui/src/components/chat/message/parts/UserTextPart.tsx b/ui/src/components/chat/message/parts/UserTextPart.tsx
new file mode 100644
index 0000000..19623ec
--- /dev/null
+++ b/ui/src/components/chat/message/parts/UserTextPart.tsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import type { Part } from '@opencode-ai/sdk/v2';
+import type { AgentMentionInfo } from '../types';
+import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
+import { useUIStore } from '@/stores/useUIStore';
+
+type PartWithText = Part & { text?: string; content?: string; value?: string };
+
+type UserTextPartProps = {
+ part: Part;
+ messageId: string;
+ isMobile: boolean;
+ agentMention?: AgentMentionInfo;
+};
+
+const buildMentionUrl = (name: string): string => {
+ const encoded = encodeURIComponent(name);
+ return `https://opencode.ai/docs/agents/#${encoded}`;
+};
+
+const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
+ return mode === 'markdown' ? 'markdown' : 'plain';
+};
+
+const UserTextPart: React.FC = ({ part, messageId, agentMention }) => {
+ const CLAMP_LINES = 2;
+ const partWithText = part as PartWithText;
+ const rawText = partWithText.text;
+ const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
+
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const [isTruncated, setIsTruncated] = React.useState(false);
+ const [collapseZoneHeight, setCollapseZoneHeight] = React.useState(0);
+ const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
+ const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
+ const textRef = React.useRef(null);
+
+ const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
+ return false;
+ }
+
+ const range = selection.getRangeAt(0);
+ return element.contains(range.startContainer) || element.contains(range.endContainer);
+ }, []);
+
+ React.useEffect(() => {
+ const el = textRef.current;
+ if (!el) return;
+
+ const checkTruncation = () => {
+ if (!isExpanded) {
+ setIsTruncated(el.scrollHeight > el.clientHeight);
+ }
+
+ const styles = window.getComputedStyle(el);
+ const lineHeight = parseFloat(styles.lineHeight);
+ const fontSize = parseFloat(styles.fontSize);
+ const fallbackLineHeight = isFinite(fontSize) ? fontSize * 1.4 : 20;
+ const resolvedLineHeight = isFinite(lineHeight) ? lineHeight : fallbackLineHeight;
+ setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES)));
+ };
+
+ checkTruncation();
+
+ const resizeObserver = new ResizeObserver(checkTruncation);
+ resizeObserver.observe(el);
+
+ return () => resizeObserver.disconnect();
+ }, [textContent, isExpanded]);
+
+ const handleClick = React.useCallback((event: React.MouseEvent) => {
+ const element = textRef.current;
+ if (!element) {
+ return;
+ }
+
+ if (hasActiveSelectionInElement(element)) {
+ return;
+ }
+
+ if (!isExpanded) {
+ if (isTruncated) {
+ setIsExpanded(true);
+ }
+ return;
+ }
+
+ const clickY = event.clientY - element.getBoundingClientRect().top;
+ if (clickY <= collapseZoneHeight) {
+ setIsExpanded(false);
+ }
+ }, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
+
+ const processedMarkdownContent = React.useMemo(() => {
+ if (!agentMention?.token || !textContent.includes(agentMention.token)) {
+ return textContent;
+ }
+
+ const mentionHtml = `${agentMention.token} `;
+ return textContent.replace(agentMention.token, mentionHtml);
+ }, [agentMention, textContent]);
+
+ const plainTextContent = React.useMemo(() => {
+ if (!agentMention?.token || !textContent.includes(agentMention.token)) {
+ return textContent;
+ }
+
+ const idx = textContent.indexOf(agentMention.token);
+ const before = textContent.slice(0, idx);
+ const after = textContent.slice(idx + agentMention.token.length);
+ return (
+ <>
+ {before}
+ event.stopPropagation()}
+ >
+ {agentMention.token}
+
+ {after}
+ >
+ );
+ }, [agentMention, textContent]);
+
+ if (!textContent || textContent.trim().length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {normalizedRenderingMode === 'markdown' ? (
+
+ ) : (
+ plainTextContent
+ )}
+
+
+ );
+};
+
+export default React.memo(UserTextPart);
diff --git a/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx
new file mode 100644
index 0000000..179c366
--- /dev/null
+++ b/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx
@@ -0,0 +1,339 @@
+/**
+ * VirtualizedCodeBlock — PERF-007
+ *
+ * Replaces per-line with:
+ * 1. ONE Prism.highlight() call to tokenize all code at once
+ * 2. @tanstack/react-virtual to only render visible rows
+ *
+ * This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows).
+ * For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain s.
+ */
+
+import React from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import Prism from 'prismjs';
+
+// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
+// but we call Prism directly so we need them registered).
+import 'prismjs/components/prism-markup';
+import 'prismjs/components/prism-markup-templating';
+import 'prismjs/components/prism-typescript';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/components/prism-jsx';
+import 'prismjs/components/prism-tsx';
+import 'prismjs/components/prism-css';
+import 'prismjs/components/prism-json';
+import 'prismjs/components/prism-bash';
+import 'prismjs/components/prism-python';
+import 'prismjs/components/prism-rust';
+import 'prismjs/components/prism-go';
+import 'prismjs/components/prism-java';
+import 'prismjs/components/prism-c';
+import 'prismjs/components/prism-cpp';
+import 'prismjs/components/prism-csharp';
+import 'prismjs/components/prism-ruby';
+import 'prismjs/components/prism-yaml';
+import 'prismjs/components/prism-toml';
+import 'prismjs/components/prism-markdown';
+import 'prismjs/components/prism-sql';
+import 'prismjs/components/prism-diff';
+import 'prismjs/components/prism-docker';
+import 'prismjs/components/prism-swift';
+import 'prismjs/components/prism-kotlin';
+import 'prismjs/components/prism-lua';
+import 'prismjs/components/prism-php';
+import 'prismjs/components/prism-scss';
+
+// ── Threshold: files smaller than this render without virtualization ──
+const VIRTUALIZE_THRESHOLD = 80;
+const ROW_HEIGHT = 20; // px — matches typography-code line-height
+
+// ── Types ────────────────────────────────────────────────────────────
+export interface CodeLine {
+ text: string;
+ lineNumber?: number | null;
+ isInfo?: boolean;
+ /** For diff lines */
+ type?: 'context' | 'added' | 'removed';
+}
+
+interface VirtualizedCodeBlockProps {
+ lines: CodeLine[];
+ language: string;
+ syntaxTheme: Record
;
+ /** Max visible height in CSS (default: 60vh) */
+ maxHeight?: string;
+ /** Show line numbers (default: true) */
+ showLineNumbers?: boolean;
+ /** Styles per line type (for diffs) */
+ lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
+}
+
+const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
+
+const styleObjectToCss = (style: React.CSSProperties): string => {
+ return Object.entries(style)
+ .filter(([, v]) => v !== undefined && v !== null)
+ .map(([k, v]) => `${toKebabCase(k)}:${String(v)};`)
+ .join('');
+};
+
+const buildSelectorList = (rawKey: string): string[] => {
+ return rawKey
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .flatMap((selector) => {
+ if (selector.startsWith('.token')) {
+ return [`.oc-virtualized-prism ${selector}`];
+ }
+ if (selector.startsWith('token.')) {
+ return [`.oc-virtualized-prism .${selector}`];
+ }
+ if (/^[a-z0-9_-]+$/i.test(selector)) {
+ return [`.oc-virtualized-prism .token.${selector}`];
+ }
+ if (selector.includes('token')) {
+ return [`.oc-virtualized-prism ${selector}`];
+ }
+ return [];
+ });
+};
+
+const buildPrismThemeCss = (theme: Record): string => {
+ const rules: string[] = [];
+ Object.entries(theme).forEach(([rawKey, style]) => {
+ const selectors = buildSelectorList(rawKey);
+ if (selectors.length === 0) {
+ return;
+ }
+ const css = styleObjectToCss(style);
+ if (!css) {
+ return;
+ }
+ rules.push(`${selectors.join(',')}{${css}}`);
+ });
+ return rules.join('\n');
+};
+
+const LANGUAGE_ALIASES: Record = {
+ text: 'plain',
+ plaintext: 'plain',
+ shell: 'bash',
+ sh: 'bash',
+ zsh: 'bash',
+ patch: 'diff',
+ dockerfile: 'docker',
+ js: 'javascript',
+ ts: 'typescript',
+};
+
+const normalizeLanguage = (language: string): string => {
+ const lower = language.toLowerCase();
+ return LANGUAGE_ALIASES[lower] ?? lower;
+};
+
+const HIGHLIGHT_CACHE_MAX = 5000;
+const highlightCache = new Map();
+
+const highlightLine = (text: string, language: string): string => {
+ const normalizedLanguage = normalizeLanguage(language);
+ const cacheKey = `${normalizedLanguage}\n${text}`;
+ const cached = highlightCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const grammar = Prism.languages[normalizedLanguage] ?? Prism.languages.text;
+ if (!grammar) {
+ const escaped = escapeHtml(text);
+ highlightCache.set(cacheKey, escaped);
+ return escaped;
+ }
+
+ try {
+ const highlighted = Prism.highlight(text, grammar, normalizedLanguage);
+ if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
+ const oldestKey = highlightCache.keys().next().value;
+ if (typeof oldestKey === 'string') {
+ highlightCache.delete(oldestKey);
+ }
+ }
+ highlightCache.set(cacheKey, highlighted);
+ return highlighted;
+ } catch {
+ const escaped = escapeHtml(text);
+ highlightCache.set(cacheKey, escaped);
+ return escaped;
+ }
+};
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+// ── Component ────────────────────────────────────────────────────────
+export const VirtualizedCodeBlock: React.FC = React.memo((props) => {
+ const {
+ lines,
+ language,
+ syntaxTheme,
+ maxHeight = '60vh',
+ showLineNumbers = true,
+ lineStyles,
+ } = props;
+ const prismThemeCss = React.useMemo(() => buildPrismThemeCss(syntaxTheme), [syntaxTheme]);
+
+ const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
+
+ // ── Small file: render directly (no virtualizer overhead) ──
+ if (!shouldVirtualize) {
+ return (
+
+ {prismThemeCss ? : null}
+ {lines.map((line, idx) => (
+
+ ))}
+
+ );
+ }
+
+ // ── Large file: virtualise ──
+ return (
+
+ );
+});
+
+VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
+
+// ── Virtualised container (extracted so the hook is top-level) ────────
+interface VirtualizedRowsProps {
+ lines: CodeLine[];
+ language: string;
+ prismThemeCss: string;
+ maxHeight: string;
+ showLineNumbers: boolean;
+ lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
+}
+
+const VirtualizedRows: React.FC = React.memo(({
+ lines,
+ language,
+ prismThemeCss,
+ maxHeight,
+ showLineNumbers,
+ lineStyles,
+}) => {
+ const parentRef = React.useRef(null);
+
+ const virtualizer = useVirtualizer({
+ count: lines.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => ROW_HEIGHT,
+ overscan: 20, // render 20 extra rows above/below viewport
+ });
+
+ return (
+
+ {prismThemeCss ? : null}
+
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const line = lines[vItem.index];
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+});
+
+VirtualizedRows.displayName = 'VirtualizedRows';
+
+// ── Single row ───────────────────────────────────────────────────────
+interface RowProps {
+ line: CodeLine;
+ language: string;
+ showLineNumbers: boolean;
+ style?: React.CSSProperties;
+}
+
+const Row: React.FC = React.memo(({ line, language, showLineNumbers, style }) => {
+ const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]);
+
+ return (
+
+ {showLineNumbers && (
+
+ {!line.isInfo && line.lineNumber != null ? line.lineNumber : ''}
+
+ )}
+
+ {line.isInfo ? (
+
+ {line.text}
+
+ ) : (
+
+ )}
+
+
+ );
+});
+
+Row.displayName = 'VirtualizedCodeBlock.Row';
diff --git a/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx
new file mode 100644
index 0000000..5b603ce
--- /dev/null
+++ b/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx
@@ -0,0 +1,232 @@
+import React from 'react';
+import { Text } from '@/components/ui/text';
+
+interface WorkingPlaceholderProps {
+ isWorking: boolean;
+ statusText: string | null;
+ isGenericStatus?: boolean;
+ isWaitingForPermission?: boolean;
+ retryInfo?: { attempt?: number; next?: number } | null;
+}
+
+const STATUS_DISPLAY_TIME_MS = 1200;
+
+const EPOCH_SECONDS_THRESHOLD = 1_000_000_000;
+const EPOCH_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
+
+const toRetryTargetTimestamp = (next: number): number => {
+ if (next >= EPOCH_MILLISECONDS_THRESHOLD) {
+ return next;
+ }
+ if (next >= EPOCH_SECONDS_THRESHOLD) {
+ return next * 1000;
+ }
+ return Date.now() + next;
+};
+
+const formatRetryCountdown = (seconds: number): string => {
+ if (seconds < 60) {
+ return `${seconds}s`;
+ }
+
+ if (seconds < 3600) {
+ const minutes = Math.floor(seconds / 60);
+ const remainderSeconds = seconds % 60;
+ return remainderSeconds > 0 ? `${minutes}m ${remainderSeconds}s` : `${minutes}m`;
+ }
+
+ if (seconds < 86400) {
+ const hours = Math.floor(seconds / 3600);
+ const remainderMinutes = Math.floor((seconds % 3600) / 60);
+ return remainderMinutes > 0 ? `${hours}h ${remainderMinutes}m` : `${hours}h`;
+ }
+
+ const days = Math.floor(seconds / 86400);
+ const remainderHours = Math.floor((seconds % 86400) / 3600);
+ if (remainderHours > 0) {
+ return `${days}d ${remainderHours}h`;
+ }
+
+ return `${days}d`;
+
+};
+
+export function WorkingPlaceholder({
+ isWorking,
+ statusText,
+ isGenericStatus,
+ isWaitingForPermission,
+ retryInfo,
+}: WorkingPlaceholderProps) {
+ const [displayedText, setDisplayedText] = React.useState(null);
+ const [displayedPermission, setDisplayedPermission] = React.useState(false);
+
+ const statusShownAtRef = React.useRef(0);
+ const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
+ const processQueueTimerRef = React.useRef | null>(null);
+
+ // Countdown state for retry mode
+ const [retryCountdown, setRetryCountdown] = React.useState(null);
+
+ React.useEffect(() => {
+ const rawNext = retryInfo?.next;
+ if (!rawNext || rawNext <= 0) {
+ setRetryCountdown(null);
+ return;
+ }
+
+ const retryTargetAt = toRetryTargetTimestamp(rawNext);
+
+ const update = () => {
+ const remaining = Math.max(0, retryTargetAt - Date.now());
+ setRetryCountdown(Math.ceil(remaining / 1000));
+ };
+
+ update();
+ const id = setInterval(update, 500);
+ return () => clearInterval(id);
+ }, [retryInfo?.next, retryInfo?.attempt]);
+
+ const clearTimers = React.useCallback(() => {
+ if (processQueueTimerRef.current) {
+ clearTimeout(processQueueTimerRef.current);
+ processQueueTimerRef.current = null;
+ }
+ }, []);
+
+ const showStatus = React.useCallback((text: string, permission: boolean) => {
+ clearTimers();
+ queuedStatusRef.current = null;
+ setDisplayedText(text);
+ setDisplayedPermission(permission);
+ statusShownAtRef.current = Date.now();
+ }, [clearTimers]);
+
+ const scheduleQueueProcess = React.useCallback(() => {
+ if (processQueueTimerRef.current) return;
+ const elapsed = Date.now() - statusShownAtRef.current;
+ const remaining = Math.max(0, STATUS_DISPLAY_TIME_MS - elapsed);
+ processQueueTimerRef.current = setTimeout(() => {
+ processQueueTimerRef.current = null;
+
+ const queued = queuedStatusRef.current;
+ if (queued) {
+ showStatus(queued.text, queued.permission);
+ }
+ }, remaining);
+ }, [showStatus]);
+
+ React.useEffect(() => {
+ if (!isWorking) {
+ clearTimers();
+ queuedStatusRef.current = null;
+ setDisplayedText(null);
+ setDisplayedPermission(false);
+ return;
+ }
+
+ // Retry state has its own display — skip the normal queue
+ if (retryInfo) {
+ clearTimers();
+ queuedStatusRef.current = null;
+ return;
+ }
+
+ const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText;
+ const incomingPermission = Boolean(isWaitingForPermission);
+ const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission;
+
+ if (!incomingText) {
+ return;
+ }
+
+ if (!displayedText) {
+ showStatus(incomingText, incomingPermission);
+ return;
+ }
+
+ if (incomingText === displayedText && incomingPermission === displayedPermission) {
+ return;
+ }
+
+ // Ignore generic churn.
+ if (incomingGeneric) {
+ return;
+ }
+
+ const elapsed = Date.now() - statusShownAtRef.current;
+ if (elapsed >= STATUS_DISPLAY_TIME_MS) {
+ showStatus(incomingText, incomingPermission);
+ return;
+ }
+
+ queuedStatusRef.current = { text: incomingText, permission: incomingPermission };
+ scheduleQueueProcess();
+ }, [
+ isWorking,
+ statusText,
+ isGenericStatus,
+ isWaitingForPermission,
+ retryInfo,
+ displayedText,
+ displayedPermission,
+ clearTimers,
+ showStatus,
+ scheduleQueueProcess,
+ ]);
+
+ React.useEffect(() => () => clearTimers(), [clearTimers]);
+
+ if (!isWorking) {
+ return null;
+ }
+
+ // Retry state: show countdown and attempt info
+ if (retryInfo) {
+ const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : '';
+ const countdownLabel = retryCountdown !== null && retryCountdown > 0
+ ? ` in ${formatRetryCountdown(retryCountdown)}`
+ : '';
+ const retryText = `Retrying${countdownLabel}${attemptLabel}...`;
+
+ return (
+
+
+
+ {retryText}
+
+
+
+ );
+ }
+
+ if (!displayedText) {
+ return null;
+ }
+
+ const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
+ const displayText = `${label}...`;
+
+ return (
+
+
+
+ {displayText}
+
+
+
+ );
+}
diff --git a/ui/src/components/chat/message/timeFormat.ts b/ui/src/components/chat/message/timeFormat.ts
new file mode 100644
index 0000000..bd92f08
--- /dev/null
+++ b/ui/src/components/chat/message/timeFormat.ts
@@ -0,0 +1,48 @@
+const pad2 = (value: number): string => String(value).padStart(2, '0');
+
+const isSameDay = (left: Date, right: Date): boolean => {
+ return (
+ left.getFullYear() === right.getFullYear() &&
+ left.getMonth() === right.getMonth() &&
+ left.getDate() === right.getDate()
+ );
+};
+
+const isYesterday = (date: Date, now: Date): boolean => {
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ return isSameDay(date, yesterday);
+};
+
+const isValidTimestamp = (timestamp: number): boolean => {
+ return Number.isFinite(timestamp) && !Number.isNaN(new Date(timestamp).getTime());
+};
+
+export const formatTimestampForDisplay = (timestamp: number): string => {
+ if (!isValidTimestamp(timestamp)) {
+ return '';
+ }
+
+ const date = new Date(timestamp);
+ const now = new Date();
+
+ const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
+
+ if (isSameDay(date, now)) {
+ return timePart;
+ }
+
+ if (isYesterday(date, now)) {
+ return `Yesterday ${timePart}`;
+ }
+
+ const monthPart = date.toLocaleString(undefined, { month: 'short' });
+ const dayPart = date.getDate();
+ const datePart = `${monthPart} ${dayPart}`;
+
+ if (date.getFullYear() === now.getFullYear()) {
+ return `${datePart}, ${timePart}`;
+ }
+
+ return `${datePart}, ${date.getFullYear()}, ${timePart}`;
+};
diff --git a/ui/src/components/chat/message/toolRenderers.tsx b/ui/src/components/chat/message/toolRenderers.tsx
new file mode 100644
index 0000000..ed6fc99
--- /dev/null
+++ b/ui/src/components/chat/message/toolRenderers.tsx
@@ -0,0 +1,722 @@
+import { RiCheckLine } from '@remixicon/react';
+
+import { cn } from '@/lib/utils';
+import { typography } from '@/lib/typography';
+import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers';
+import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
+
+const cleanOutput = (output: string) => {
+ let cleaned = output.replace(/^\s*\n?/, '').replace(/\n?<\/file>\s*$/, '');
+ cleaned = cleaned.replace(/^\s*\d{5}\|\s?/gm, '');
+ return cleaned.trim();
+};
+
+export const hasLspDiagnostics = (output: string): boolean => {
+ if (!output) return false;
+ return output.includes('') || output.includes('This file has errors') || output.includes('please fix');
+};
+
+const stripLspDiagnostics = (output: string): string => {
+ if (!output) return '';
+ return output.replace(/This file has errors.*?<\/file_diagnostics>/s, '').trim();
+};
+
+const formatInputForDisplay = (input: Record, toolName?: string) => {
+ if (!input || typeof input !== 'object') {
+ return String(input);
+ }
+ return formatToolInput(input, toolName || '');
+};
+
+export const formatEditOutput = (output: string, toolName: string, metadata?: Record): string => {
+ let cleaned = cleanOutput(output);
+
+ if ((toolName === 'edit' || toolName === 'multiedit') && hasLspDiagnostics(cleaned)) {
+ cleaned = stripLspDiagnostics(cleaned);
+ }
+
+ if ((toolName === 'edit' || toolName === 'multiedit') && cleaned.trim().length === 0 && metadata?.diff) {
+
+ const diff = metadata.diff;
+ return typeof diff === 'string' ? diff : String(diff);
+ }
+
+ return cleaned;
+};
+
+export interface ParsedReadOutputLine {
+ text: string;
+ lineNumber: number | null;
+ isInfo: boolean;
+}
+
+export interface ParsedReadToolOutput {
+ type: 'file' | 'directory' | 'unknown';
+ lines: ParsedReadOutputLine[];
+}
+
+export const parseReadToolOutput = (output: string): ParsedReadToolOutput => {
+ const typeMatch = output.match(/(file|directory)<\/type>/i);
+ const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type'];
+
+ const contentMatch = output.match(/([\s\S]*?)<\/content>/i);
+ const rawContent = contentMatch?.[1] ?? output;
+ const normalizedContent = rawContent.replace(/\r\n/g, '\n');
+ const rawLines = normalizedContent.split('\n');
+
+ const isTruncationInfoLine = (text: string): boolean => {
+ return /\(\s*File has more lines\..*offset.*\)/i.test(text.trim());
+ };
+
+ const parsedLines = rawLines.map((line): ParsedReadOutputLine => {
+ const trimmed = line.trim();
+ const isInfo = (trimmed.startsWith('(') && trimmed.endsWith(')')) || isTruncationInfoLine(trimmed);
+
+ if (detectedType !== 'directory') {
+ const numberedMatch = line.match(/^(\d+):\s?(.*)$/);
+ if (numberedMatch) {
+ const numberedText = numberedMatch[2];
+ const numberedTrimmed = numberedText.trim();
+ const numberedIsInfo =
+ (numberedTrimmed.startsWith('(') && numberedTrimmed.endsWith(')'))
+ || isTruncationInfoLine(numberedTrimmed);
+ return {
+ lineNumber: numberedIsInfo ? null : Number(numberedMatch[1]),
+ text: numberedText,
+ isInfo: numberedIsInfo,
+ };
+ }
+ }
+
+ return {
+ lineNumber: null,
+ text: line,
+ isInfo,
+ };
+ });
+
+ const lines = parsedLines.filter((line, index, arr) => {
+ if (line.text.trim().length > 0) {
+ return true;
+ }
+
+ const prev = arr[index - 1];
+ const next = arr[index + 1];
+ const adjacentToInfo = Boolean(prev?.isInfo || next?.isInfo);
+ const hasNumber = line.lineNumber !== null;
+
+ // Drop numbered blank lines wrapped around helper/info rows.
+ if (adjacentToInfo && hasNumber) {
+ return false;
+ }
+
+ return true;
+ });
+
+ return {
+ type: detectedType,
+ lines,
+ };
+};
+
+export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
+ try {
+ const lines = output.trim().split('\n').filter(Boolean);
+ if (lines.length === 0) return null;
+
+ const items: Array<{ name: string; depth: number; isFile: boolean }> = [];
+ lines.forEach((line) => {
+ const match = line.match(/^(\s*)(.+)$/);
+ if (match) {
+ const [, spaces, name] = match;
+ const depth = Math.floor(spaces.length / 2);
+ const isFile = !name.endsWith('/');
+ items.push({
+ name: name.replace(/\/$/, ''),
+ depth,
+ isFile,
+ });
+ }
+ });
+
+ return (
+
+ {items.map((item, idx) => (
+
+ {item.isFile ? (
+ {item.name}
+ ) : (
+ {item.name}/
+ )}
+
+ ))}
+
+ );
+ } catch {
+ return null;
+ }
+};
+
+export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
+ try {
+ const lines = output.trim().split('\n').filter(Boolean);
+ if (lines.length === 0) return null;
+
+ const fileGroups: Record> = {};
+
+ lines.forEach((line) => {
+ const match = line.match(/^(.+?):(\d+):(.*)$/) || line.match(/^(.+?):(.*)$/);
+ if (match) {
+ const [, filepath, lineNumOrContent, content] = match;
+ const lineNum = content !== undefined ? lineNumOrContent : '';
+ const actualContent = content !== undefined ? content : lineNumOrContent;
+
+ if (!fileGroups[filepath]) {
+ fileGroups[filepath] = [];
+ }
+ fileGroups[filepath].push({ lineNum, content: actualContent });
+ }
+ });
+
+ return (
+
+
+ Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
+
+ {Object.entries(fileGroups).map(([filepath, matches]) => (
+
+
+ {filepath}
+
+
+ {matches.map((match, idx) => {
+ if (!match.lineNum && !match.content) {
+ return null;
+ }
+ return (
+
+
+
+ {match.lineNum && (
+
+ Line {match.lineNum}:
+
+ )}
+
+ {match.content || '\u00A0'}
+
+
+
+ );
+ })}
+
+
+ ))}
+
+ );
+
+ } catch {
+ return null;
+ }
+};
+
+export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
+ try {
+ const paths = output.trim().split('\n').filter(Boolean);
+ if (paths.length === 0) return null;
+
+ const groups: Record = {};
+ paths.forEach((path) => {
+ const lastSlash = path.lastIndexOf('/');
+ const dir = lastSlash > 0 ? path.substring(0, lastSlash) : '/';
+ const filename = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
+
+ if (!groups[dir]) {
+ groups[dir] = [];
+ }
+ groups[dir].push(filename);
+ });
+
+ const sortedDirs = Object.keys(groups).sort();
+
+ return (
+
+
+ Found {paths.length} file{paths.length !== 1 ? 's' : ''}
+
+ {sortedDirs.map((dir) => (
+
+
+ {dir}/
+
+
+ {groups[dir].sort().map((filename) => (
+
+ ))}
+
+
+ ))}
+
+ );
+ } catch {
+ return null;
+ }
+};
+
+type Todo = {
+ id?: string;
+ content: string;
+ status: 'in_progress' | 'pending' | 'completed' | 'cancelled';
+ priority?: 'high' | 'medium' | 'low';
+};
+
+export const renderTodoOutput = (output: string, options?: { unstyled?: boolean }) => {
+ try {
+ const todos = JSON.parse(output) as Todo[];
+ if (!Array.isArray(todos)) {
+ return null;
+ }
+
+ const todosByStatus = {
+ in_progress: todos.filter((t) => t.status === 'in_progress'),
+ pending: todos.filter((t) => t.status === 'pending'),
+ completed: todos.filter((t) => t.status === 'completed'),
+ cancelled: todos.filter((t) => t.status === 'cancelled'),
+ };
+
+ const getPriorityDot = (priority?: string) => {
+ const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1';
+ switch (priority) {
+ case 'high':
+ return
;
+ case 'medium':
+ return
;
+ case 'low':
+ return
;
+ default:
+ return
;
+ }
+ };
+
+ return (
+
+
+ Total: {todos.length}
+ {todosByStatus.in_progress.length > 0 && (
+ In Progress: {todosByStatus.in_progress.length}
+ )}
+ {todosByStatus.pending.length > 0 && (
+ Pending: {todosByStatus.pending.length}
+ )}
+ {todosByStatus.completed.length > 0 && (
+ Completed: {todosByStatus.completed.length}
+ )}
+ {todosByStatus.cancelled.length > 0 && (
+ Cancelled: {todosByStatus.cancelled.length}
+ )}
+
+
+ {todosByStatus.in_progress.length > 0 && (
+
+
+
+ {todosByStatus.in_progress.map((todo, idx) => (
+
+ {getPriorityDot(todo.priority)}
+ {todo.content}
+
+ ))}
+
+
+ )}
+
+ {todosByStatus.pending.length > 0 && (
+
+
+
+ {todosByStatus.pending.map((todo, idx) => (
+
+ {getPriorityDot(todo.priority)}
+ {todo.content}
+
+ ))}
+
+
+ )}
+
+ {todosByStatus.completed.length > 0 && (
+
+
+
+ Completed
+
+
+ {todosByStatus.completed.map((todo, idx) => (
+
+
+ {todo.content}
+
+ ))}
+
+
+ )}
+
+ {todosByStatus.cancelled.length > 0 && (
+
+
+ ×
+ Cancelled
+
+
+ {todosByStatus.cancelled.map((todo, idx) => (
+
+ ×
+ {todo.content}
+
+ ))}
+
+
+ )}
+
+ );
+ } catch {
+ return null;
+ }
+};
+
+export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: string]: React.CSSProperties }, options?: { unstyled?: boolean }) => {
+ try {
+ return (
+
+
+
+ );
+ } catch {
+ return null;
+ }
+};
+
+export type DiffLineType = 'context' | 'added' | 'removed';
+
+export interface UnifiedDiffLine {
+ type: DiffLineType;
+ lineNumber: number | null;
+ content: string;
+}
+
+export interface UnifiedDiffHunk {
+ file: string;
+ oldStart: number;
+ newStart: number;
+ lines: UnifiedDiffLine[];
+}
+
+export interface SideBySideDiffLine {
+ leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
+ rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
+}
+
+export interface SideBySideDiffHunk {
+ file: string;
+ oldStart: number;
+ newStart: number;
+ lines: SideBySideDiffLine[];
+}
+
+export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
+ const lines = diffText.split('\n');
+ let currentFile = '';
+ const hunks: UnifiedDiffHunk[] = [];
+
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
+ if (line.startsWith('Index:')) {
+ currentFile = line.split(' ')[1].split('/').pop() || 'file';
+ }
+ i++;
+ continue;
+ }
+
+ if (line.startsWith('@@')) {
+ const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
+ const oldStart = match ? parseInt(match[1]) : 0;
+ const newStart = match ? parseInt(match[2]) : 0;
+
+ const unifiedLines: UnifiedDiffLine[] = [];
+ let oldLineNum = oldStart;
+ let newLineNum = newStart;
+ let j = i + 1;
+
+ while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
+ const contentLine = lines[j];
+ if (contentLine.startsWith('+')) {
+ unifiedLines.push({ type: 'added', lineNumber: newLineNum, content: contentLine.substring(1) });
+ newLineNum++;
+ } else if (contentLine.startsWith('-')) {
+ unifiedLines.push({ type: 'removed', lineNumber: oldLineNum, content: contentLine.substring(1) });
+ oldLineNum++;
+ } else if (contentLine.startsWith(' ')) {
+ unifiedLines.push({ type: 'context', lineNumber: newLineNum, content: contentLine.substring(1) });
+ oldLineNum++;
+ newLineNum++;
+ }
+ j++;
+ }
+
+ hunks.push({
+ file: currentFile,
+ oldStart,
+ newStart,
+ lines: unifiedLines,
+ });
+
+ i = j;
+ continue;
+ }
+
+ i++;
+ }
+
+ return hunks;
+};
+
+export const parseDiffToLines = (diffText: string): SideBySideDiffHunk[] => {
+ const lines = diffText.split('\n');
+ let currentFile = '';
+ const hunks: SideBySideDiffHunk[] = [];
+
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
+ if (line.startsWith('Index:')) {
+ currentFile = line.split(' ')[1].split('/').pop() || 'file';
+ }
+ i++;
+ continue;
+ }
+
+ if (line.startsWith('@@')) {
+ const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
+ const oldStart = match ? parseInt(match[1]) : 0;
+ const newStart = match ? parseInt(match[2]) : 0;
+
+ const changes: Array<{
+ type: 'context' | 'added' | 'removed';
+ content: string;
+ oldLine?: number;
+ newLine?: number;
+ }> = [];
+
+ let oldLineNum = oldStart;
+ let newLineNum = newStart;
+ let j = i + 1;
+
+ while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
+ const contentLine = lines[j];
+ if (contentLine.startsWith('+')) {
+ changes.push({ type: 'added', content: contentLine.substring(1), newLine: newLineNum });
+ newLineNum++;
+ } else if (contentLine.startsWith('-')) {
+ changes.push({ type: 'removed', content: contentLine.substring(1), oldLine: oldLineNum });
+ oldLineNum++;
+ } else if (contentLine.startsWith(' ')) {
+ changes.push({
+ type: 'context',
+ content: contentLine.substring(1),
+ oldLine: oldLineNum,
+ newLine: newLineNum,
+ });
+ oldLineNum++;
+ newLineNum++;
+ }
+ j++;
+ }
+
+ const alignedLines: Array<{
+ leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
+ rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
+ }> = [];
+
+ const leftSide: Array<{ type: 'context' | 'removed'; lineNumber: number; content: string }> = [];
+ const rightSide: Array<{ type: 'context' | 'added'; lineNumber: number; content: string }> = [];
+
+ changes.forEach((change) => {
+ if (change.type === 'context') {
+ leftSide.push({ type: 'context', lineNumber: change.oldLine!, content: change.content });
+ rightSide.push({ type: 'context', lineNumber: change.newLine!, content: change.content });
+ } else if (change.type === 'removed') {
+ leftSide.push({ type: 'removed', lineNumber: change.oldLine!, content: change.content });
+ } else if (change.type === 'added') {
+ rightSide.push({ type: 'added', lineNumber: change.newLine!, content: change.content });
+ }
+ });
+
+ const alignmentPoints: Array<{ leftIdx: number; rightIdx: number }> = [];
+
+ leftSide.forEach((leftItem, leftIdx) => {
+ if (leftItem.type === 'context') {
+ const rightIdx = rightSide.findIndex((rightItem, rIdx) =>
+ rightItem.type === 'context' &&
+ rightItem.content === leftItem.content &&
+ !alignmentPoints.some((ap) => ap.rightIdx === rIdx)
+ );
+ if (rightIdx >= 0) {
+ alignmentPoints.push({ leftIdx, rightIdx });
+ }
+ }
+ });
+
+ alignmentPoints.sort((a, b) => a.leftIdx - b.leftIdx);
+
+ let leftIdx = 0;
+ let rightIdx = 0;
+ let alignIdx = 0;
+
+ while (leftIdx < leftSide.length || rightIdx < rightSide.length) {
+ const nextAlign = alignIdx < alignmentPoints.length ? alignmentPoints[alignIdx] : null;
+
+ if (nextAlign && leftIdx === nextAlign.leftIdx && rightIdx === nextAlign.rightIdx) {
+ const leftItem = leftSide[leftIdx];
+ const rightItem = rightSide[rightIdx];
+
+ alignedLines.push({
+ leftLine: {
+ type: 'context',
+ lineNumber: leftItem.lineNumber,
+ content: leftItem.content,
+ },
+ rightLine: {
+ type: 'context',
+ lineNumber: rightItem.lineNumber,
+ content: rightItem.content,
+ },
+ });
+
+ leftIdx++;
+ rightIdx++;
+ alignIdx++;
+ } else {
+ const needProcessLeft = leftIdx < leftSide.length && (!nextAlign || leftIdx < nextAlign.leftIdx);
+ const needProcessRight = rightIdx < rightSide.length && (!nextAlign || rightIdx < nextAlign.rightIdx);
+
+ if (needProcessLeft && needProcessRight) {
+ const leftItem = leftSide[leftIdx];
+ const rightItem = rightSide[rightIdx];
+
+ alignedLines.push({
+ leftLine: {
+ type: leftItem.type,
+ lineNumber: leftItem.lineNumber,
+ content: leftItem.content,
+ },
+ rightLine: {
+ type: rightItem.type,
+ lineNumber: rightItem.lineNumber,
+ content: rightItem.content,
+ },
+ });
+
+ leftIdx++;
+ rightIdx++;
+ } else if (needProcessLeft) {
+ const leftItem = leftSide[leftIdx];
+ alignedLines.push({
+ leftLine: {
+ type: leftItem.type,
+ lineNumber: leftItem.lineNumber,
+ content: leftItem.content,
+ },
+ rightLine: {
+ type: 'empty',
+ lineNumber: null,
+ content: '',
+ },
+ });
+ leftIdx++;
+ } else if (needProcessRight) {
+ const rightItem = rightSide[rightIdx];
+ alignedLines.push({
+ leftLine: {
+ type: 'empty',
+ lineNumber: null,
+ content: '',
+ },
+ rightLine: {
+ type: rightItem.type,
+ lineNumber: rightItem.lineNumber,
+ content: rightItem.content,
+ },
+ });
+ rightIdx++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ hunks.push({
+ file: currentFile,
+ oldStart,
+ newStart,
+ lines: alignedLines,
+ });
+
+ i = j;
+ continue;
+ }
+
+ i++;
+ }
+
+ return hunks;
+};
+
+export const detectLanguageFromOutput = (output: string, toolName: string, input?: Record) => {
+ return detectToolOutputLanguage(toolName, output, input);
+};
+
+export { formatInputForDisplay };
diff --git a/ui/src/components/chat/message/types.ts b/ui/src/components/chat/message/types.ts
new file mode 100644
index 0000000..ba9e3b4
--- /dev/null
+++ b/ui/src/components/chat/message/types.ts
@@ -0,0 +1,37 @@
+export type StreamPhase = 'streaming' | 'cooldown' | 'completed';
+
+export type DiffViewMode = 'side-by-side' | 'unified';
+
+export interface AgentMentionInfo {
+ name: string;
+ token: string;
+}
+
+export interface ToolPopupContent {
+ open: boolean;
+ title: string;
+ content: string;
+ language?: string;
+ isDiff?: boolean;
+ diffHunks?: Array>;
+ metadata?: Record;
+ image?: {
+ url: string;
+ mimeType?: string;
+ filename?: string;
+ size?: number;
+ gallery?: Array<{
+ url: string;
+ mimeType?: string;
+ filename?: string;
+ size?: number;
+ }>;
+ index?: number;
+ };
+ mermaid?: {
+ url: string;
+ mimeType?: string;
+ filename?: string;
+ source?: string;
+ };
+}
diff --git a/ui/src/components/chat/mobileControlsUtils.ts b/ui/src/components/chat/mobileControlsUtils.ts
new file mode 100644
index 0000000..f262d4e
--- /dev/null
+++ b/ui/src/components/chat/mobileControlsUtils.ts
@@ -0,0 +1,105 @@
+import type { Agent } from '@opencode-ai/sdk/v2';
+
+export type MobileControlsPanel = 'model' | 'agent' | 'variant' | null;
+
+export const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
+
+export const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
+
+export const getAgentDisplayName = (agents: Agent[], agentName?: string) => {
+ if (agentName) {
+ const agent = agents.find((entry) => entry.name === agentName);
+ return agent ? capitalizeLabel(agent.name) : capitalizeLabel(agentName);
+ }
+
+ const primaryAgents = agents.filter((agent) => isPrimaryMode(agent.mode));
+ const buildAgent = primaryAgents.find((agent) => agent.name === 'build');
+ const fallbackAgent = buildAgent || primaryAgents[0] || agents[0];
+ return fallbackAgent ? capitalizeLabel(fallbackAgent.name) : 'Select agent';
+};
+
+type ProviderModel = { id?: string; name?: string };
+
+export const getModelDisplayName = (
+ provider: { models?: ProviderModel[] } | undefined,
+ modelId: string | undefined,
+) => {
+ if (!provider || !modelId) {
+ return 'Not selected';
+ }
+ const models = Array.isArray(provider.models) ? provider.models : [];
+ const model = models.find((entry) => entry.id === modelId);
+ if (typeof model?.name === 'string' && model.name.trim().length > 0) {
+ return model.name;
+ }
+ if (typeof model?.id === 'string' && model.id.trim().length > 0) {
+ return model.id;
+ }
+ return modelId;
+};
+
+export const formatEffortLabel = (variant?: string) => {
+ if (!variant || variant.trim().length === 0) {
+ return 'Default';
+ }
+ const trimmed = variant.trim();
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
+ return trimmed;
+ }
+ return capitalizeLabel(trimmed);
+};
+
+export const DEFAULT_EFFORT_KEY = 'default';
+
+export const serializeEffortVariant = (variant?: string) => {
+ const trimmed = typeof variant === 'string' ? variant.trim() : '';
+ return trimmed.length > 0 ? trimmed : DEFAULT_EFFORT_KEY;
+};
+
+export const parseEffortVariant = (variant: string) => {
+ return variant === DEFAULT_EFFORT_KEY ? undefined : variant;
+};
+
+const EFFORT_RANKS: Record = {
+ max: 6,
+ maximum: 6,
+ xhigh: 5,
+ high: 4,
+ medium: 3,
+ default: 2,
+ low: 1,
+ min: 0,
+ minimal: 0,
+};
+
+export const getEffortRank = (variant?: string) => {
+ if (!variant || variant.trim().length === 0) {
+ return EFFORT_RANKS.default;
+ }
+ const normalized = variant.trim().toLowerCase();
+ if (Object.prototype.hasOwnProperty.call(EFFORT_RANKS, normalized)) {
+ return EFFORT_RANKS[normalized];
+ }
+ const numeric = Number.parseFloat(normalized);
+ return Number.isFinite(numeric) ? numeric : 0;
+};
+
+export const getQuickEffortOptions = (variants: string[]) => {
+ const options = new Map();
+ options.set('default', undefined);
+ for (const variant of variants) {
+ options.set(variant, variant);
+ }
+
+ const ordered = Array.from(options.values()).sort((a, b) => getEffortRank(b) - getEffortRank(a));
+ if (ordered.length <= 4) {
+ return ordered;
+ }
+
+ const top = ordered.slice(0, 3);
+ const lowest = ordered[ordered.length - 1];
+ if (top.some((item) => item === lowest)) {
+ return top;
+ }
+ return [...top, lowest];
+};
diff --git a/ui/src/components/comments/CodeMirrorCommentWidgets.tsx b/ui/src/components/comments/CodeMirrorCommentWidgets.tsx
new file mode 100644
index 0000000..e697182
--- /dev/null
+++ b/ui/src/components/comments/CodeMirrorCommentWidgets.tsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import { InlineCommentCard } from './InlineCommentCard';
+import { InlineCommentInput } from './InlineCommentInput';
+import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
+import type { BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
+
+type LineRange = {
+ start: number;
+ end: number;
+ side?: 'additions' | 'deletions';
+};
+
+interface CodeMirrorCommentWidgetsOptions {
+ drafts: InlineCommentDraft[];
+ editingDraftId: string | null;
+ commentText: string;
+ selection: LineRange | null;
+ isDragging: boolean;
+ fileLabel: string;
+ newWidgetId: string;
+ mapDraftToRange: (draft: InlineCommentDraft) => LineRange;
+ onSave: (text: string, range?: LineRange) => void;
+ onCancel: () => void;
+ onEdit: (draft: InlineCommentDraft) => void;
+ onDelete: (draft: InlineCommentDraft) => void;
+}
+
+export function buildCodeMirrorCommentWidgets(options: CodeMirrorCommentWidgetsOptions): BlockWidgetDef[] {
+ const {
+ drafts,
+ editingDraftId,
+ commentText,
+ selection,
+ isDragging,
+ fileLabel,
+ newWidgetId,
+ mapDraftToRange,
+ onSave,
+ onCancel,
+ onEdit,
+ onDelete,
+ } = options;
+
+ const widgets: BlockWidgetDef[] = [];
+
+ for (const draft of drafts) {
+ const draftRange = mapDraftToRange(draft);
+ if (draft.id === editingDraftId) {
+ widgets.push({
+ afterLine: draftRange.end,
+ id: `edit-${draft.id}`,
+ content: (
+
+ ),
+ });
+ continue;
+ }
+
+ widgets.push({
+ afterLine: draftRange.end,
+ id: `card-${draft.id}`,
+ content: (
+ onEdit(draft)}
+ onDelete={() => onDelete(draft)}
+ />
+ ),
+ });
+ }
+
+ if (selection && !editingDraftId && !isDragging) {
+ const normalizedSelection = {
+ ...selection,
+ start: Math.min(selection.start, selection.end),
+ end: Math.max(selection.start, selection.end),
+ };
+
+ widgets.push({
+ afterLine: normalizedSelection.end,
+ id: newWidgetId,
+ content: (
+
+ ),
+ });
+ }
+
+ return widgets;
+}
diff --git a/ui/src/components/comments/InlineCommentCard.tsx b/ui/src/components/comments/InlineCommentCard.tsx
new file mode 100644
index 0000000..2ca822d
--- /dev/null
+++ b/ui/src/components/comments/InlineCommentCard.tsx
@@ -0,0 +1,119 @@
+import React, { useState } from 'react';
+import { RiMoreLine, RiDeleteBinLine, RiEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
+import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+
+interface InlineCommentCardProps {
+ draft: InlineCommentDraft;
+ onEdit: () => void;
+ onDelete: () => void;
+ className?: string;
+ maxWidth?: number;
+}
+
+export function InlineCommentCard({
+ draft,
+ onEdit,
+ onDelete,
+ className,
+ maxWidth,
+}: InlineCommentCardProps) {
+ const themeContext = useOptionalThemeSystem();
+ const currentTheme = themeContext?.currentTheme;
+ const [isOpen, setIsOpen] = useState(false);
+
+ // Check if content is long enough to warrant collapsing (rough estimate)
+ // In a real app we might measure line height, but length check is a good proxy for now
+ const isLongContent = draft.text.length > 150 || draft.text.split('\n').length > 3;
+
+ return (
+
+
+
+
+
+ {draft.fileLabel}
+
+ •
+ Lines {draft.startLine}-{draft.endLine}
+ {draft.side && ({draft.side}) }
+
+
+
+
+ {draft.text}
+
+
+ {isLongContent && (
+
+
+ {isOpen ? (
+ <>
+
+ Show less
+ >
+ ) : (
+ <>
+
+ Show more
+ >
+ )}
+
+
+ )}
+
+
+ {/* Used for animation purposes if we want to animate height */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit comment
+
+
+
+ Delete comment
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/comments/InlineCommentInput.tsx b/ui/src/components/comments/InlineCommentInput.tsx
new file mode 100644
index 0000000..5a93cd5
--- /dev/null
+++ b/ui/src/components/comments/InlineCommentInput.tsx
@@ -0,0 +1,179 @@
+import React, { useRef, useEffect } from 'react';
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { cn } from '@/lib/utils';
+import { useDeviceInfo } from '@/lib/device';
+
+export interface InlineCommentInputProps {
+ initialText?: string;
+ onSave: (text: string, range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => void;
+ onCancel: () => void;
+ fileLabel?: string;
+ lineRange?: { start: number; end: number; side?: 'additions' | 'deletions' };
+ isEditing?: boolean;
+ className?: string;
+ maxWidth?: number;
+}
+
+export function InlineCommentInput({
+ initialText = '',
+ onSave,
+ onCancel,
+ fileLabel,
+ lineRange,
+ isEditing = false,
+ className,
+ maxWidth,
+}: InlineCommentInputProps) {
+ const themeContext = useOptionalThemeSystem();
+ const currentTheme = themeContext?.currentTheme;
+ const { isMobile } = useDeviceInfo();
+ const [text, setText] = React.useState(initialText);
+ const textareaRef = useRef(null);
+
+ // Stable range snapshot to prevent race with selection clearing
+ const stableRangeRef = useRef(lineRange);
+ useEffect(() => {
+ if (lineRange) {
+ stableRangeRef.current = lineRange;
+ }
+ }, [lineRange]);
+
+ const normalizeRange = (range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => {
+ if (!range) return undefined;
+ const start = Math.min(range.start, range.end);
+ const end = Math.max(range.start, range.end);
+ return { ...range, start, end };
+ };
+
+ const displayRange = normalizeRange(lineRange);
+
+ // Focus on mount (desktop only) or when becoming visible
+ useEffect(() => {
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+
+ const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null;
+ const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY;
+ const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX;
+
+ if (isMobile) {
+ textarea.scrollIntoView({ behavior: 'auto', block: 'nearest' });
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
+ return;
+ }
+
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
+
+ const len = textarea.value.length;
+ try {
+ textarea.setSelectionRange(len, len);
+ } catch (err) {
+ void err;
+ }
+
+ requestAnimationFrame(() => {
+ if (scrollContainer) {
+ scrollContainer.scrollTop = prevScrollTop;
+ scrollContainer.scrollLeft = prevScrollLeft;
+ } else {
+ window.scrollTo({ top: prevScrollTop, left: prevScrollLeft });
+ }
+ });
+ }, [isMobile]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ e.preventDefault();
+ if (text.trim()) {
+ onSave(text, normalizeRange(stableRangeRef.current));
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ onCancel();
+ }
+ };
+
+ const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
+ // Stop propagation to prevent parent selection clearing before save
+ e.stopPropagation();
+ if (text.trim()) {
+ onSave(text, normalizeRange(stableRangeRef.current));
+ }
+ };
+
+ return (
+ e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+
+ {(fileLabel || lineRange) && (
+
+ {fileLabel && {fileLabel} }
+ {fileLabel && lineRange && • }
+ {displayRange && Lines {displayRange.start}-{displayRange.end} }
+
+ )}
+
+
setText(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Add a comment... (Cmd+Enter to save)"
+ className="min-h-[80px] text-sm resize-y"
+ style={{
+ backgroundColor: currentTheme?.colors?.surface?.subtle,
+ }}
+ />
+
+
+ e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ className="h-8 text-muted-foreground hover:text-foreground"
+ >
+ Cancel
+
+ e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ disabled={!text.trim()}
+ className="h-8 min-w-[80px]"
+ style={{
+ backgroundColor: currentTheme?.colors?.status?.success,
+ color: currentTheme?.colors?.status?.successForeground,
+ }}
+ >
+ {isEditing ? 'Save' : 'Comment'}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/comments/PierreDiffCommentOverlays.tsx b/ui/src/components/comments/PierreDiffCommentOverlays.tsx
new file mode 100644
index 0000000..73b8d1d
--- /dev/null
+++ b/ui/src/components/comments/PierreDiffCommentOverlays.tsx
@@ -0,0 +1,230 @@
+import React from 'react';
+import { createPortal } from 'react-dom';
+import type { SelectedLineRange } from '@pierre/diffs';
+import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
+import { InlineCommentCard } from './InlineCommentCard';
+import { InlineCommentInput } from './InlineCommentInput';
+import { toPierreAnnotationId } from './PierreDiffCommentUtils';
+
+interface PierreDiffCommentOverlaysProps {
+ diffRootRef: React.RefObject;
+ drafts: InlineCommentDraft[];
+ selection: SelectedLineRange | null;
+ editingDraftId: string | null;
+ commentText: string;
+ fileLabel: string;
+ onSave: (text: string, range?: SelectedLineRange) => void;
+ onCancel: () => void;
+ onEdit: (draft: InlineCommentDraft) => void;
+ onDelete: (draft: InlineCommentDraft) => void;
+}
+
+function parseCssWidth(value: string): number | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
+ const parsed = Number.parseFloat(trimmed);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ if (trimmed.endsWith('px')) {
+ const parsed = Number.parseFloat(trimmed.slice(0, -2));
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ return null;
+}
+
+function clampMaxWidth(value: number | null | undefined): number | undefined {
+ if (!value || value <= 0) return undefined;
+ return Math.max(200, Math.floor(value));
+}
+
+export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps) {
+ const {
+ diffRootRef,
+ drafts,
+ selection,
+ editingDraftId,
+ commentText,
+ fileLabel,
+ onSave,
+ onCancel,
+ onEdit,
+ onDelete,
+ } = props;
+
+ const [retryTick, setRetryTick] = React.useState(0);
+ const [fallbackMaxWidth, setFallbackMaxWidth] = React.useState(null);
+
+ const selectionAnnotationId = React.useMemo(() => {
+ if (!selection || editingDraftId) return null;
+ return toPierreAnnotationId({ type: 'new', selection });
+ }, [editingDraftId, selection]);
+
+ const expectedTargetIds = React.useMemo(() => {
+ const ids = drafts.map((draft) => toPierreAnnotationId({ type: draft.id === editingDraftId ? 'edit' : 'saved', draft }));
+ if (selectionAnnotationId) {
+ ids.push(selectionAnnotationId);
+ }
+ return ids;
+ }, [drafts, editingDraftId, selectionAnnotationId]);
+
+ const resolveTarget = React.useCallback((annotationId: string): HTMLElement | null => {
+ const wrapper = diffRootRef.current;
+ if (!wrapper) return null;
+
+ const host = wrapper.querySelector('diffs-container');
+ if (!(host instanceof HTMLElement)) return null;
+
+ const lightDomTarget = host.querySelector(`[data-annotation-id="${annotationId}"]`);
+ if (lightDomTarget instanceof HTMLElement) {
+ return lightDomTarget;
+ }
+
+ const shadowRoot = host.shadowRoot;
+ if (!shadowRoot) return null;
+
+ return shadowRoot.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement | null;
+ }, [diffRootRef]);
+
+ React.useEffect(() => {
+ if (expectedTargetIds.length === 0) return;
+
+ let cancelled = false;
+ let attempts = 0;
+ const maxAttempts = 12;
+
+ const checkTargets = () => {
+ if (cancelled) return;
+ const allResolved = expectedTargetIds.every((id) => Boolean(resolveTarget(id)));
+ if (allResolved || attempts >= maxAttempts) {
+ return;
+ }
+ attempts += 1;
+ requestAnimationFrame(() => {
+ if (cancelled) return;
+ setRetryTick((tick) => tick + 1);
+ checkTargets();
+ });
+ };
+
+ checkTargets();
+ return () => {
+ cancelled = true;
+ };
+ }, [expectedTargetIds, resolveTarget]);
+
+ React.useEffect(() => {
+ const root = diffRootRef.current;
+ if (!root) return;
+
+ const computeMaxWidth = () => {
+ const styles = getComputedStyle(root);
+ const cssWidth = parseCssWidth(styles.getPropertyValue('--oc-context-panel-width'));
+ const rootRect = root.getBoundingClientRect();
+ const measured = cssWidth ?? rootRect.width;
+ setFallbackMaxWidth(measured > 0 ? measured : null);
+ };
+
+ computeMaxWidth();
+
+ const observer = new ResizeObserver(() => {
+ computeMaxWidth();
+ });
+ observer.observe(root);
+
+ window.addEventListener('resize', computeMaxWidth);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener('resize', computeMaxWidth);
+ };
+ }, [diffRootRef]);
+
+ const resolveTargetMaxWidth = React.useCallback((target: HTMLElement): number | undefined => {
+ const root = diffRootRef.current;
+ const rootRect = root?.getBoundingClientRect();
+
+ const annotationContent = target.closest('[data-annotation-content]');
+ const contentRect = annotationContent instanceof HTMLElement
+ ? annotationContent.getBoundingClientRect()
+ : target.getBoundingClientRect();
+
+ const candidates = [contentRect.width];
+ if (rootRect) {
+ candidates.push(rootRect.right - contentRect.left);
+ }
+
+ const positiveCandidates = candidates.filter((value) => Number.isFinite(value) && value > 0);
+ if (positiveCandidates.length > 0) {
+ return clampMaxWidth(Math.min(...positiveCandidates));
+ }
+
+ return clampMaxWidth(fallbackMaxWidth);
+ }, [diffRootRef, fallbackMaxWidth]);
+
+ void retryTick;
+
+ return (
+ <>
+ {drafts.map((draft) => {
+ const id = toPierreAnnotationId({ type: draft.id === editingDraftId ? 'edit' : 'saved', draft });
+ const target = resolveTarget(id);
+ if (!target) return null;
+ const targetMaxWidth = resolveTargetMaxWidth(target);
+
+ if (draft.id === editingDraftId) {
+ return createPortal(
+ ,
+ target,
+ `draft-edit-${draft.id}`
+ );
+ }
+
+ return createPortal(
+ onEdit(draft)}
+ onDelete={() => onDelete(draft)}
+ maxWidth={targetMaxWidth}
+ />,
+ target,
+ `draft-card-${draft.id}`
+ );
+ })}
+
+ {selection && !editingDraftId && selectionAnnotationId && (() => {
+ const target = resolveTarget(selectionAnnotationId);
+ if (!target) return null;
+ const targetMaxWidth = resolveTargetMaxWidth(target);
+
+ return createPortal(
+ ,
+ target,
+ selectionAnnotationId
+ );
+ })()}
+ >
+ );
+}
diff --git a/ui/src/components/comments/PierreDiffCommentUtils.ts b/ui/src/components/comments/PierreDiffCommentUtils.ts
new file mode 100644
index 0000000..df1927d
--- /dev/null
+++ b/ui/src/components/comments/PierreDiffCommentUtils.ts
@@ -0,0 +1,52 @@
+import type { AnnotationSide, DiffLineAnnotation, SelectedLineRange } from '@pierre/diffs';
+import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
+
+export type PierreAnnotationData =
+ | { type: 'saved' | 'edit'; draft: InlineCommentDraft }
+ | { type: 'new'; selection: SelectedLineRange };
+
+export const toPierreAnnotationId = (meta: PierreAnnotationData): string => {
+ if (meta.type === 'new') {
+ const start = Math.min(meta.selection.start, meta.selection.end);
+ const end = Math.max(meta.selection.start, meta.selection.end);
+ const side = meta.selection.side ?? 'additions';
+ return `new-comment-${side}-${start}-${end}`;
+ }
+
+ return `draft-${meta.draft.id}`;
+};
+
+interface BuildPierreLineAnnotationsOptions {
+ drafts: InlineCommentDraft[];
+ editingDraftId: string | null;
+ selection: SelectedLineRange | null;
+}
+
+export const buildPierreLineAnnotations = (
+ options: BuildPierreLineAnnotationsOptions
+): DiffLineAnnotation[] => {
+ const { drafts, editingDraftId, selection } = options;
+ const annotations: DiffLineAnnotation[] = [];
+
+ for (const draft of drafts) {
+ const side: AnnotationSide = draft.side === 'original' ? 'deletions' : 'additions';
+ annotations.push({
+ lineNumber: draft.endLine,
+ side,
+ metadata: {
+ type: draft.id === editingDraftId ? 'edit' : 'saved',
+ draft,
+ },
+ });
+ }
+
+ if (selection && !editingDraftId) {
+ annotations.push({
+ lineNumber: Math.max(selection.start, selection.end),
+ side: selection.side ?? 'additions',
+ metadata: { type: 'new', selection },
+ });
+ }
+
+ return annotations;
+};
diff --git a/ui/src/components/comments/index.ts b/ui/src/components/comments/index.ts
new file mode 100644
index 0000000..264f8db
--- /dev/null
+++ b/ui/src/components/comments/index.ts
@@ -0,0 +1,6 @@
+export * from './InlineCommentCard';
+export * from './InlineCommentInput';
+export * from './useInlineCommentController';
+export * from './CodeMirrorCommentWidgets';
+export * from './PierreDiffCommentUtils';
+export * from './PierreDiffCommentOverlays';
diff --git a/ui/src/components/comments/useInlineCommentController.ts b/ui/src/components/comments/useInlineCommentController.ts
new file mode 100644
index 0000000..e70cbd5
--- /dev/null
+++ b/ui/src/components/comments/useInlineCommentController.ts
@@ -0,0 +1,154 @@
+import React from 'react';
+import { toast } from '@/components/ui';
+import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
+import { useSessionStore } from '@/stores/useSessionStore';
+
+type LineRangeBase = {
+ start: number;
+ end: number;
+};
+
+type StoreRange = {
+ startLine: number;
+ endLine: number;
+ side?: 'original' | 'modified';
+};
+
+interface UseInlineCommentControllerOptions {
+ source: InlineCommentSource;
+ fileLabel: string | null;
+ language: string;
+ getCodeForRange: (range: TRange) => string;
+ toStoreRange: (range: TRange) => StoreRange;
+ fromDraftRange: (draft: InlineCommentDraft) => TRange;
+}
+
+const normalizeStoreRange = (range: StoreRange): StoreRange => {
+ const startLine = Math.min(range.startLine, range.endLine);
+ const endLine = Math.max(range.startLine, range.endLine);
+ return {
+ ...range,
+ startLine,
+ endLine,
+ };
+};
+
+export const normalizeLineRange = (range: TRange): TRange => {
+ const start = Math.min(range.start, range.end);
+ const end = Math.max(range.start, range.end);
+ return {
+ ...range,
+ start,
+ end,
+ };
+};
+
+export function useInlineCommentController(
+ options: UseInlineCommentControllerOptions
+) {
+ const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
+
+ const currentSessionId = useSessionStore((state) => state.currentSessionId);
+ const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
+
+ const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
+ const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
+ const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
+ const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
+
+ const [selection, setSelection] = React.useState(null);
+ const [commentText, setCommentText] = React.useState('');
+ const [editingDraftId, setEditingDraftId] = React.useState(null);
+
+ const sessionKey = React.useMemo(() => {
+ return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
+ }, [currentSessionId, newSessionDraftOpen]);
+
+ const drafts = React.useMemo(() => {
+ if (!sessionKey || !fileLabel) return [];
+ const sessionDrafts = allDrafts[sessionKey] ?? [];
+ return sessionDrafts.filter((draft) => draft.source === source && draft.fileLabel === fileLabel);
+ }, [allDrafts, fileLabel, sessionKey, source]);
+
+ const reset = React.useCallback(() => {
+ setSelection(null);
+ setCommentText('');
+ setEditingDraftId(null);
+ }, []);
+
+ const cancel = React.useCallback(() => {
+ reset();
+ }, [reset]);
+
+ const startEdit = React.useCallback((draft: InlineCommentDraft) => {
+ const draftRange = normalizeLineRange(fromDraftRange(draft));
+ setSelection(draftRange);
+ setCommentText(draft.text);
+ setEditingDraftId(draft.id);
+ }, [fromDraftRange]);
+
+ const deleteDraft = React.useCallback((draft: InlineCommentDraft) => {
+ removeDraft(draft.sessionKey, draft.id);
+ if (editingDraftId === draft.id) {
+ reset();
+ }
+ }, [editingDraftId, removeDraft, reset]);
+
+ const saveComment = React.useCallback((textToSave: string, rangeOverride?: TRange) => {
+ const targetRange = rangeOverride ?? selection;
+ const trimmedText = textToSave.trim();
+ if (!targetRange || !trimmedText || !fileLabel) return;
+
+ if (!sessionKey) {
+ toast.error('Select a session to save comment');
+ return;
+ }
+
+ const normalizedRange = normalizeLineRange(targetRange);
+ const normalizedStoreRange = normalizeStoreRange(toStoreRange(normalizedRange));
+ const code = getCodeForRange(normalizedRange);
+
+ if (editingDraftId) {
+ updateDraft(sessionKey, editingDraftId, {
+ fileLabel,
+ startLine: normalizedStoreRange.startLine,
+ endLine: normalizedStoreRange.endLine,
+ side: normalizedStoreRange.side,
+ code,
+ language,
+ text: trimmedText,
+ });
+ } else {
+ addDraft({
+ sessionKey,
+ source,
+ fileLabel,
+ startLine: normalizedStoreRange.startLine,
+ endLine: normalizedStoreRange.endLine,
+ side: normalizedStoreRange.side,
+ code,
+ language,
+ text: trimmedText,
+ });
+ }
+
+ reset();
+ }, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, toStoreRange, updateDraft]);
+
+ return {
+ sessionKey,
+ drafts,
+ selection,
+ setSelection,
+ commentText,
+ setCommentText,
+ editingDraftId,
+ setEditingDraftId,
+ reset,
+ cancel,
+ startEdit,
+ deleteDraft,
+ saveComment,
+ fromDraftRange,
+ };
+}
diff --git a/ui/src/components/desktop/DesktopHostSwitcher.tsx b/ui/src/components/desktop/DesktopHostSwitcher.tsx
new file mode 100644
index 0000000..b24c791
--- /dev/null
+++ b/ui/src/components/desktop/DesktopHostSwitcher.tsx
@@ -0,0 +1,1389 @@
+import * as React from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ RiAddLine,
+ RiCheckLine,
+ RiCloudOffLine,
+ RiEarthLine,
+ RiLoader4Line,
+ RiMore2Line,
+ RiPencilLine,
+ RiPlug2Line,
+ RiRefreshLine,
+ RiServerLine,
+ RiSettings3Line,
+ RiShieldKeyholeLine,
+ RiStarFill,
+ RiStarLine,
+ RiDeleteBinLine,
+ RiWindowLine,
+} from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import { toast } from '@/components/ui';
+import { isTauriShell, isDesktopShell } from '@/lib/desktop';
+import { useUIStore } from '@/stores/useUIStore';
+import {
+ desktopHostProbe,
+ desktopHostsGet,
+ desktopHostsSet,
+ desktopOpenNewWindowAtUrl,
+ locationMatchesHost,
+ normalizeHostUrl,
+ redactSensitiveUrl,
+ type DesktopHost,
+ type HostProbeResult,
+} from '@/lib/desktopHosts';
+import {
+ desktopSshConnect,
+ desktopSshDisconnect,
+ desktopSshInstancesGet,
+ desktopSshStatus,
+ type DesktopSshInstanceStatus,
+} from '@/lib/desktopSsh';
+
+const LOCAL_HOST_ID = 'local';
+const SSH_CONNECT_TIMEOUT_MS = 90_000;
+const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
+
+type HostStatus = {
+ status: HostProbeResult['status'];
+ latencyMs: number;
+};
+
+const toNavigationUrl = (rawUrl: string): string => {
+ const normalized = normalizeHostUrl(rawUrl);
+ if (!normalized) {
+ return rawUrl.trim();
+ }
+
+ try {
+ const url = new URL(normalized);
+ if (!url.pathname.endsWith('/')) {
+ url.pathname = `${url.pathname}/`;
+ }
+ return url.toString();
+ } catch {
+ return normalized;
+ }
+};
+
+const getLocalOrigin = (): string => {
+ if (typeof window === 'undefined') return '';
+ return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
+};
+
+const makeId = (): string => {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+};
+
+const statusDotClass = (status: HostProbeResult['status'] | null): string => {
+ if (status === 'ok') return 'bg-status-success';
+ if (status === 'auth') return 'bg-status-warning';
+ if (status === 'unreachable') return 'bg-status-error';
+ return 'bg-muted-foreground/40';
+};
+
+const statusLabel = (status: HostProbeResult['status'] | null): string => {
+ if (status === 'ok') return 'Connected';
+ if (status === 'auth') return 'Auth required';
+ if (status === 'unreachable') return 'Unreachable';
+ return 'Unknown';
+};
+
+const statusIcon = (status: HostProbeResult['status'] | null) => {
+ if (status === 'ok') return ;
+ if (status === 'auth') return ;
+ if (status === 'unreachable') return ;
+ return ;
+};
+
+const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
+
+const sshPhaseLabel = (phase: DesktopSshInstanceStatus['phase'] | undefined): string => {
+ switch (phase) {
+ case 'ready':
+ return 'Ready';
+ case 'error':
+ return 'Error';
+ case 'degraded':
+ return 'Reconnecting';
+ case 'config_resolved':
+ return 'Resolving config';
+ case 'auth_check':
+ return 'Checking auth';
+ case 'master_connecting':
+ return 'Connecting SSH';
+ case 'remote_probe':
+ return 'Probing remote';
+ case 'installing':
+ return 'Installing';
+ case 'updating':
+ return 'Updating';
+ case 'server_detecting':
+ return 'Detecting server';
+ case 'server_starting':
+ return 'Starting server';
+ case 'forwarding':
+ return 'Forwarding ports';
+ default:
+ return 'Idle';
+ }
+};
+
+const sshPhaseToHostStatus = (
+ phase: DesktopSshInstanceStatus['phase'] | undefined,
+): HostProbeResult['status'] | null => {
+ if (!phase || phase === 'idle') return null;
+ if (phase === 'ready') return 'ok';
+ if (phase === 'error') return 'unreachable';
+ return 'auth';
+};
+
+const getSshStatusById = async (): Promise> => {
+ const statuses = await desktopSshStatus().catch(() => []);
+ const next: Record = {};
+ for (const status of statuses) {
+ next[status.id] = status;
+ }
+ return next;
+};
+
+const waitForSshReady = async (
+ id: string,
+ timeoutMs: number,
+ onUpdate: (status: DesktopSshInstanceStatus) => void,
+ shouldCancel?: () => boolean,
+): Promise => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (shouldCancel?.()) {
+ throw new Error(SSH_CONNECT_CANCELLED_ERROR);
+ }
+
+ const statuses = await desktopSshStatus(id).catch(() => []);
+ const status = statuses.find((item) => item.id === id);
+ if (status) {
+ onUpdate(status);
+ if (status.phase === 'ready') {
+ return status;
+ }
+ if (status.phase === 'error') {
+ throw new Error(status.detail || 'SSH connection failed');
+ }
+ }
+ await sleep(700);
+ }
+
+ if (shouldCancel?.()) {
+ throw new Error(SSH_CONNECT_CANCELLED_ERROR);
+ }
+
+ throw new Error('Timed out waiting for SSH connection');
+};
+
+const buildLocalHost = (): DesktopHost => ({
+ id: LOCAL_HOST_ID,
+ label: 'Local',
+ url: getLocalOrigin(),
+});
+
+const resolveCurrentHost = (hosts: DesktopHost[]) => {
+ const currentHref = typeof window === 'undefined' ? '' : window.location.href;
+ const localOrigin = getLocalOrigin();
+ const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
+ const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref;
+
+ if (currentHref && locationMatchesHost(currentHref, localOrigin)) {
+ return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
+ }
+
+ const match = hosts.find((h) => {
+ return currentHref ? locationMatchesHost(currentHref, h.url) : false;
+ });
+
+ if (match) {
+ return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
+ }
+
+ return {
+ id: 'custom',
+ label: redactSensitiveUrl(normalizedCurrent || 'Instance'),
+ url: normalizedCurrent,
+ };
+};
+
+type DesktopHostSwitcherDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ embedded?: boolean;
+ onHostSwitched?: () => void;
+};
+
+export function DesktopHostSwitcherDialog({
+ open,
+ onOpenChange,
+ embedded = false,
+ onHostSwitched,
+}: DesktopHostSwitcherDialogProps) {
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+
+ const [configHosts, setConfigHosts] = React.useState([]);
+ const [defaultHostId, setDefaultHostId] = React.useState(null);
+ const [statusById, setStatusById] = React.useState>({});
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [isProbing, setIsProbing] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [switchingHostId, setSwitchingHostId] = React.useState(null);
+ const [sshHostIds, setSshHostIds] = React.useState>({});
+ const [sshStatusesById, setSshStatusesById] = React.useState>({});
+ const [sshSwitchModal, setSshSwitchModal] = React.useState<{
+ open: boolean;
+ hostId: string | null;
+ hostLabel: string;
+ phase: DesktopSshInstanceStatus['phase'] | 'idle';
+ detail: string | null;
+ error: string | null;
+ }>({
+ open: false,
+ hostId: null,
+ hostLabel: '',
+ phase: 'idle',
+ detail: null,
+ error: null,
+ });
+ const [error, setError] = React.useState('');
+
+ const [editingId, setEditingId] = React.useState(null);
+ const [editLabel, setEditLabel] = React.useState('');
+ const [editUrl, setEditUrl] = React.useState('');
+
+ const [newLabel, setNewLabel] = React.useState('');
+ const [newUrl, setNewUrl] = React.useState('');
+ const [isAddFormOpen, setIsAddFormOpen] = React.useState(!embedded);
+ const sshSwitchTokenRef = React.useRef(0);
+
+ const allHosts = React.useMemo(() => {
+ const local = buildLocalHost();
+ const normalizedRemote = configHosts.map((h) => ({
+ ...h,
+ url: normalizeHostUrl(h.url) || h.url,
+ }));
+ return [local, ...normalizedRemote];
+ }, [configHosts]);
+
+ const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
+ const currentDefaultLabel = React.useMemo(() => {
+ const id = defaultHostId || LOCAL_HOST_ID;
+ return allHosts.find((h) => h.id === id)?.label || 'Local';
+ }, [allHosts, defaultHostId]);
+
+ const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
+ if (!isTauriShell()) return;
+ setIsSaving(true);
+ setError('');
+ try {
+ const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID);
+ await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId });
+ setConfigHosts(remote);
+ setDefaultHostId(nextDefaultHostId);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setIsSaving(false);
+ }
+ }, []);
+
+ const openRemoteInstancesSettings = React.useCallback(() => {
+ setSettingsPage('remote-instances');
+ setSettingsDialogOpen(true);
+ onOpenChange(false);
+ }, [onOpenChange, setSettingsDialogOpen, setSettingsPage]);
+
+ const refresh = React.useCallback(async () => {
+ if (!isTauriShell()) return;
+ setIsLoading(true);
+ setError('');
+ try {
+ const [cfg, sshCfg, sshStatusMap] = await Promise.all([
+ desktopHostsGet(),
+ desktopSshInstancesGet().catch(() => ({ instances: [] })),
+ getSshStatusById(),
+ ]);
+ const nextSshHostIds: Record = {};
+ for (const instance of sshCfg.instances) {
+ nextSshHostIds[instance.id] = true;
+ }
+ setConfigHosts(cfg.hosts || []);
+ setDefaultHostId(cfg.defaultHostId ?? null);
+ setSshHostIds(nextSshHostIds);
+ setSshStatusesById(sshStatusMap);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load');
+ setConfigHosts([]);
+ setDefaultHostId(null);
+ setSshHostIds({});
+ setSshStatusesById({});
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
+ if (!isTauriShell()) return;
+ setIsProbing(true);
+ try {
+ const results = await Promise.all(
+ hosts.map(async (h) => {
+ const url = normalizeHostUrl(h.url);
+ if (!url) {
+ return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
+ }
+ const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
+ return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
+ })
+ );
+ const next: Record = {};
+ for (const [id, val] of results) {
+ next[id] = val;
+ }
+ setStatusById(next);
+ } finally {
+ setIsProbing(false);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) {
+ setEditingId(null);
+ setEditLabel('');
+ setEditUrl('');
+ setNewLabel('');
+ setNewUrl('');
+ setIsAddFormOpen(!embedded);
+ setSwitchingHostId(null);
+ setSshSwitchModal({ open: false, hostId: null, hostLabel: '', phase: 'idle', detail: null, error: null });
+ setError('');
+ return;
+ }
+ void refresh();
+ }, [embedded, open, refresh]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ void probeAll(allHosts);
+ }, [open, allHosts, probeAll]);
+
+ React.useEffect(() => {
+ if (!open || !isTauriShell()) {
+ return;
+ }
+ let cancelled = false;
+ const run = async () => {
+ const statuses = await getSshStatusById();
+ if (!cancelled) {
+ setSshStatusesById(statuses);
+ }
+ };
+ void run();
+ const interval = window.setInterval(() => {
+ void run();
+ }, 1_500);
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [open]);
+
+ const handleSwitch = React.useCallback(async (host: DesktopHost) => {
+ const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
+ if (!origin) return;
+
+ const isSshHost = Boolean(sshHostIds[host.id]);
+
+ if (host.id !== LOCAL_HOST_ID && isSshHost && isTauriShell()) {
+ let existingStatus = sshStatusesById[host.id];
+ const latestStatus = await desktopSshStatus(host.id)
+ .then((items) => items.find((item) => item.id === host.id) || null)
+ .catch(() => null);
+ if (latestStatus) {
+ existingStatus = latestStatus;
+ setSshStatusesById((prev) => ({
+ ...prev,
+ [host.id]: latestStatus,
+ }));
+ }
+
+ const existingUrl = normalizeHostUrl(existingStatus?.localUrl || host.url || '');
+ if (existingStatus?.phase === 'ready' && existingUrl) {
+ const target = toNavigationUrl(existingUrl);
+ onHostSwitched?.();
+ window.location.assign(target);
+ return;
+ }
+
+ setSwitchingHostId(host.id);
+ const switchToken = sshSwitchTokenRef.current + 1;
+ sshSwitchTokenRef.current = switchToken;
+ setSshSwitchModal({
+ open: true,
+ hostId: host.id,
+ hostLabel: redactSensitiveUrl(host.label),
+ phase: 'master_connecting',
+ detail: null,
+ error: null,
+ });
+ try {
+ await desktopSshConnect(host.id);
+ if (switchToken !== sshSwitchTokenRef.current) {
+ return;
+ }
+
+ const readyStatus = await waitForSshReady(host.id, SSH_CONNECT_TIMEOUT_MS, (status) => {
+ setSshStatusesById((prev) => ({
+ ...prev,
+ [status.id]: status,
+ }));
+ setSshSwitchModal((prev) => ({
+ ...prev,
+ phase: status.phase,
+ detail: status.detail || null,
+ }));
+ }, () => switchToken !== sshSwitchTokenRef.current);
+
+ if (switchToken !== sshSwitchTokenRef.current) {
+ return;
+ }
+
+ const targetOrigin = normalizeHostUrl(readyStatus.localUrl || '') || origin;
+ const target = toNavigationUrl(targetOrigin);
+ onHostSwitched?.();
+ window.location.assign(target);
+ return;
+ } catch (err) {
+ if (switchToken !== sshSwitchTokenRef.current) {
+ return;
+ }
+
+ const message = err instanceof Error ? err.message : String(err);
+ if (message === SSH_CONNECT_CANCELLED_ERROR) {
+ return;
+ }
+
+ setSshSwitchModal((prev) => ({
+ ...prev,
+ error: message,
+ }));
+ toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
+ description: message,
+ });
+ return;
+ } finally {
+ if (switchToken === sshSwitchTokenRef.current) {
+ setSwitchingHostId(null);
+ }
+ }
+ }
+
+ if (host.id !== LOCAL_HOST_ID && isTauriShell()) {
+ setSwitchingHostId(host.id);
+ const probe = await desktopHostProbe(origin).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
+ setStatusById((prev) => ({
+ ...prev,
+ [host.id]: { status: probe.status, latencyMs: probe.latencyMs },
+ }));
+
+ if (probe.status === 'unreachable') {
+ toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`);
+ setSwitchingHostId(null);
+ return;
+ }
+ }
+
+ const target = toNavigationUrl(origin);
+ onHostSwitched?.();
+
+ try {
+ window.location.assign(target);
+ } catch {
+ window.location.href = target;
+ }
+ }, [onHostSwitched, sshHostIds, sshStatusesById]);
+
+ const beginEdit = React.useCallback((host: DesktopHost) => {
+ setEditingId(host.id);
+ setEditLabel(host.label);
+ setEditUrl(host.url);
+ setError('');
+ }, []);
+
+ const cancelEdit = React.useCallback(() => {
+ setEditingId(null);
+ setEditLabel('');
+ setEditUrl('');
+ }, []);
+
+ const commitEdit = React.useCallback(async () => {
+ if (!editingId) return;
+ if (editingId === LOCAL_HOST_ID) {
+ cancelEdit();
+ return;
+ }
+
+ const url = normalizeHostUrl(editUrl);
+ if (!url) {
+ setError('Invalid URL (must be http/https)');
+ return;
+ }
+
+ const label = (editLabel || redactSensitiveUrl(url)).trim();
+ const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
+ await persist(nextHosts, defaultHostId);
+ cancelEdit();
+ }, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
+
+ const addHost = React.useCallback(async () => {
+ const url = normalizeHostUrl(newUrl);
+ if (!url) {
+ setError('Invalid URL (must be http/https)');
+ return;
+ }
+ const label = (newLabel || redactSensitiveUrl(url)).trim();
+ const id = makeId();
+
+ const nextHosts = [{ id, label, url }, ...configHosts];
+ await persist(nextHosts, defaultHostId);
+ setNewLabel('');
+ setNewUrl('');
+ if (embedded) {
+ setIsAddFormOpen(false);
+ }
+ }, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist]);
+
+ const deleteHost = React.useCallback(async (id: string) => {
+ if (id === LOCAL_HOST_ID) return;
+ const nextHosts = configHosts.filter((h) => h.id !== id);
+ const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
+ await persist(nextHosts, nextDefault);
+ }, [configHosts, defaultHostId, persist]);
+
+ const setDefault = React.useCallback(async (id: string) => {
+ const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
+ await persist(configHosts, next);
+ }, [configHosts, persist]);
+
+ const openInNewWindow = React.useCallback((host: DesktopHost) => {
+ const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
+ if (!origin) return;
+ const target = toNavigationUrl(origin);
+ desktopOpenNewWindowAtUrl(target).catch((err: unknown) => {
+ toast.error('Failed to open new window', {
+ description: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }, []);
+
+ const switchToLocal = React.useCallback(() => {
+ sshSwitchTokenRef.current += 1;
+ setSwitchingHostId(null);
+ setSshSwitchModal((prev) => ({
+ ...prev,
+ open: false,
+ hostId: null,
+ error: null,
+ detail: null,
+ phase: 'idle',
+ }));
+ const localTarget = toNavigationUrl(getLocalOrigin());
+ onHostSwitched?.();
+ window.location.assign(localTarget);
+ }, [onHostSwitched]);
+
+ const cancelSshSwitch = React.useCallback(async () => {
+ const hostId = sshSwitchModal.hostId || switchingHostId;
+ sshSwitchTokenRef.current += 1;
+ setSwitchingHostId(null);
+ setSshSwitchModal({
+ open: false,
+ hostId: null,
+ hostLabel: '',
+ phase: 'idle',
+ detail: null,
+ error: null,
+ });
+
+ if (!hostId || hostId === LOCAL_HOST_ID || !isTauriShell()) {
+ return;
+ }
+
+ await desktopSshDisconnect(hostId).catch(() => {});
+ }, [sshSwitchModal.hostId, switchingHostId]);
+
+ const retrySshSwitch = React.useCallback(() => {
+ const hostId = sshSwitchModal.hostId;
+ if (!hostId) return;
+ const host = allHosts.find((item) => item.id === hostId);
+ if (!host) return;
+ void handleSwitch(host);
+ }, [allHosts, handleSwitch, sshSwitchModal.hostId]);
+
+ const connectSshHostInPlace = React.useCallback(async (host: DesktopHost) => {
+ if (!isTauriShell()) return;
+ setSwitchingHostId(host.id);
+ try {
+ await desktopSshConnect(host.id);
+ const readyStatus = await waitForSshReady(host.id, SSH_CONNECT_TIMEOUT_MS, (status) => {
+ setSshStatusesById((prev) => ({
+ ...prev,
+ [status.id]: status,
+ }));
+ });
+ if (readyStatus.phase === 'ready') {
+ toast.success(`SSH instance "${redactSensitiveUrl(host.label)}" connected`);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ if (message !== SSH_CONNECT_CANCELLED_ERROR) {
+ toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
+ description: message,
+ });
+ }
+ } finally {
+ setSwitchingHostId(null);
+ }
+ }, []);
+
+ if (!isDesktopShell()) {
+ return null;
+ }
+
+ const tauriAvailable = isTauriShell();
+
+ const content = (
+ <>
+ {embedded ? (
+
+
+
+ Current
+ {redactSensitiveUrl(current.label)}
+ •
+ Default
+ {redactSensitiveUrl(currentDefaultLabel)}
+
+
void probeAll(allHosts)}
+ disabled={!tauriAvailable || isLoading || isProbing}
+ aria-label="Refresh instances"
+ >
+
+
+
+
+ ) : (
+
+
+
+ Instance
+
+
+ Switch between Local and remote OpenChamber servers
+
+
+ )}
+
+ {!embedded && (
+
+
+ Current:
+ {redactSensitiveUrl(current.label)}
+ Current default:
+ {redactSensitiveUrl(currentDefaultLabel)}
+
+
+ void probeAll(allHosts)}
+ disabled={!tauriAvailable || isLoading || isProbing}
+ >
+
+ Refresh
+
+
+
+ )}
+
+ {tauriAvailable && (
+
+
+ Need SSH instances? Manage them in Settings.
+
+
+ Remote SSH
+
+
+
+ )}
+
+ {!tauriAvailable && (
+
+
+ Instance switcher is limited on this page. Use Local to recover.
+
+
+ )}
+
+
+
+ {isLoading ? (
+
Loading…
+ ) : (
+ allHosts.map((host) => {
+ const isLocal = host.id === LOCAL_HOST_ID;
+ const isSsh = Boolean(sshHostIds[host.id]);
+ const isActive = host.id === current.id;
+ const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
+ const status = statusById[host.id] || null;
+ const sshStatus = sshStatusesById[host.id] || null;
+ const statusKind = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (status?.status ?? null);
+ const isEditing = editingId === host.id;
+ const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
+ const displayLabel = redactSensitiveUrl(host.label);
+ const displayUrl = redactSensitiveUrl(effectiveUrl);
+
+ return (
+
+
void handleSwitch(host)}
+ disabled={switchingHostId === host.id}
+ aria-label={`Switch to ${displayLabel}`}
+ >
+
+
+
+
+ {displayLabel}
+
+ {isSsh && (
+
+ SSH
+
+ )}
+ {isActive && (
+ Current
+ )}
+
+ {statusIcon(statusKind)}
+
+ {isSsh ? sshPhaseLabel(sshStatus?.phase) : statusLabel(status?.status ?? null)}
+ {!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
+
+
+
+
+ {displayUrl}
+
+
+
+
+
+ {!isLocal && !isSsh && (
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ {
+ e.stopPropagation();
+ beginEdit(host);
+ }}
+ disabled={isSaving}
+ >
+
+ Edit
+
+ {
+ e.stopPropagation();
+ void deleteHost(host.id);
+ }}
+ className="text-destructive focus:text-destructive"
+ disabled={isSaving}
+ >
+
+ Delete
+
+
+
+ )}
+
+ {isLocal && (
+
+ )}
+
+ {isSsh && !isLocal && (
+ (sshStatus?.phase === 'idle' || !sshStatus?.phase) ? (
+
{
+ e.stopPropagation();
+ void connectSshHostInPlace(host);
+ }}
+ >
+ {switchingHostId === host.id ? : }
+ Connect
+
+ ) : (
+
+ )
+ )}
+
+
+
+ void setDefault(host.id)}
+ aria-label={isDefault ? 'Default instance' : 'Set as default'}
+ disabled={isSaving}
+ >
+ {isDefault ? : }
+
+
+
+ {isDefault ? 'Default' : 'Set as default'}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ openInNewWindow(host);
+ }}
+ disabled={statusKind === 'unreachable'}
+ aria-label="Open in new window"
+ >
+
+
+
+
+ {statusKind === 'unreachable' ? 'Instance unreachable' : 'Open in new window'}
+
+
+
+
+ );
+ })
+ )}
+
+
+
+ {tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
+
+
+
Edit instance
+
+
+ Cancel
+
+ void commitEdit()} disabled={isSaving}>
+ {isSaving ? : null}
+ Save
+
+
+
+
+ setEditLabel(e.target.value)}
+ placeholder="Label"
+ disabled={isSaving}
+ />
+ setEditUrl(e.target.value)}
+ placeholder="https://host:port"
+ disabled={isSaving}
+ />
+
+
+ )}
+
+ {embedded && !isAddFormOpen ? (
+
+ setIsAddFormOpen(true)}
+ disabled={!tauriAvailable || isSaving}
+ >
+
+ Add instance
+
+
+ ) : (
+
+
+
Add instance
+
+ {embedded && (
+ setIsAddFormOpen(false)}
+ disabled={isSaving}
+ >
+ Cancel
+
+ )}
+ void addHost()}
+ disabled={!tauriAvailable || isSaving || !newUrl.trim()}
+ >
+ {isSaving ? : null}
+ Add
+
+
+
+
+ setNewLabel(e.target.value)}
+ placeholder="Label (optional)"
+ disabled={!tauriAvailable || isSaving}
+ />
+ setNewUrl(e.target.value)}
+ placeholder="https://host:port"
+ disabled={!tauriAvailable || isSaving}
+ />
+
+
+ )}
+
+ {error && (
+ {error}
+ )}
+ >
+ );
+
+ const sshSwitchDialog = (
+ {
+ if (!nextOpen && switchingHostId) {
+ void cancelSshSwitch();
+ return;
+ }
+ setSshSwitchModal((prev) => ({
+ ...prev,
+ open: nextOpen,
+ ...(nextOpen ? {} : { hostId: null, error: null, detail: null, phase: 'idle' as const }),
+ }));
+ }}
+ >
+
+
+
+
+ Connecting to {sshSwitchModal.hostLabel || 'SSH instance'}
+
+
+ {sshSwitchModal.error
+ ? sshSwitchModal.error
+ : sshSwitchModal.detail || sshPhaseLabel(sshSwitchModal.phase)}
+
+
+ {sshSwitchModal.error ? (
+
+
+ Switch to Local
+
+
+ Retry
+
+
+ ) : null}
+
+
+ );
+
+ if (embedded) {
+ return (
+ <>
+
+ {content}
+
+ {sshSwitchDialog}
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {content}
+
+
+ {sshSwitchDialog}
+ >
+ );
+}
+
+type DesktopHostSwitcherButtonProps = {
+ headerIconButtonClass: string;
+};
+
+export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
+ const [open, setOpen] = React.useState(false);
+ const [label, setLabel] = React.useState('Local');
+ const [status, setStatus] = React.useState(null);
+ const attemptedDefaultSshConnectRef = React.useRef(false);
+ const [startupSshModal, setStartupSshModal] = React.useState<{
+ open: boolean;
+ hostId: string | null;
+ hostLabel: string;
+ error: string | null;
+ connecting: boolean;
+ }>({
+ open: false,
+ hostId: null,
+ hostLabel: '',
+ error: null,
+ connecting: false,
+ });
+
+ const connectDefaultSshInstance = React.useCallback(async (
+ hostId: string,
+ hostLabel: string,
+ options?: { showProgress?: boolean },
+ ): Promise => {
+ const showProgress = Boolean(options?.showProgress);
+ if (showProgress) {
+ setStartupSshModal({
+ open: true,
+ hostId,
+ hostLabel,
+ error: null,
+ connecting: true,
+ });
+ }
+
+ try {
+ await desktopSshConnect(hostId);
+ const ready = await waitForSshReady(hostId, 45_000, () => {});
+ const localUrl = normalizeHostUrl(ready.localUrl || '');
+ if (!localUrl) {
+ throw new Error('Connected but missing forwarded URL');
+ }
+ window.location.assign(toNavigationUrl(localUrl));
+ return true;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ setStartupSshModal({
+ open: true,
+ hostId,
+ hostLabel,
+ error: message,
+ connecting: false,
+ });
+ return false;
+ }
+ }, []);
+
+ const switchStartupToLocal = React.useCallback(async () => {
+ setStartupSshModal({
+ open: false,
+ hostId: null,
+ hostLabel: '',
+ error: null,
+ connecting: false,
+ });
+
+ await desktopHostsGet()
+ .then((cfg) => desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID }))
+ .catch(() => undefined);
+
+ window.location.assign(toNavigationUrl(getLocalOrigin()));
+ }, []);
+
+ const retryStartupSsh = React.useCallback(() => {
+ const hostId = startupSshModal.hostId;
+ if (!hostId) return;
+ void connectDefaultSshInstance(hostId, startupSshModal.hostLabel || 'SSH instance', {
+ showProgress: true,
+ });
+ }, [connectDefaultSshInstance, startupSshModal.hostId, startupSshModal.hostLabel]);
+
+ React.useEffect(() => {
+ if (!isTauriShell()) return;
+
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const cfg = await desktopHostsGet();
+ const local = buildLocalHost();
+ const all = [local, ...(cfg.hosts || [])];
+ const current = resolveCurrentHost(all);
+
+ if (
+ !attemptedDefaultSshConnectRef.current &&
+ current.id === LOCAL_HOST_ID &&
+ cfg.defaultHostId &&
+ cfg.defaultHostId !== LOCAL_HOST_ID
+ ) {
+ const sshCfg = await desktopSshInstancesGet().catch(() => ({ instances: [] }));
+ const defaultSsh = sshCfg.instances.find((instance) => instance.id === cfg.defaultHostId);
+ if (defaultSsh) {
+ attemptedDefaultSshConnectRef.current = true;
+ const hostLabel = redactSensitiveUrl(
+ defaultSsh.nickname?.trim() || defaultSsh.sshParsed?.destination || defaultSsh.id,
+ );
+ const connected = await connectDefaultSshInstance(cfg.defaultHostId, hostLabel);
+ if (connected || cancelled) {
+ return;
+ }
+ }
+ }
+
+ if (cancelled) return;
+ setLabel(redactSensitiveUrl(current.label || 'Instance'));
+ const normalized = normalizeHostUrl(current.url);
+ if (!normalized) {
+ setStatus(null);
+ return;
+ }
+ const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
+ if (cancelled) return;
+ setStatus(res.status);
+ } catch {
+ if (!cancelled) {
+ setLabel('Instance');
+ setStatus(null);
+ }
+ }
+ };
+
+ void run();
+ const interval = window.setInterval(() => {
+ void run();
+ }, 10_000);
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [connectDefaultSshInstance]);
+
+ if (!isDesktopShell()) {
+ return null;
+ }
+
+ const isCurrentlyLocal = locationMatchesHost(window.location.href, getLocalOrigin());
+
+ const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
+ ? window.location.hostname
+ : 'Instance';
+
+ const effectiveLabel = isCurrentlyLocal
+ ? 'Local'
+ : label === 'Local'
+ ? fallbackLabel
+ : label;
+ const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel);
+
+ return (
+ <>
+
+
+ setOpen(true)}
+ aria-label="Switch instance"
+ data-oc-host-switcher
+ className={cn(headerIconButtonClass, 'relative w-auto px-3')}
+ >
+
+
+ {safeEffectiveLabel}
+
+
+
+
+
+ Instance
+
+
+
+ {
+ if (!nextOpen && startupSshModal.connecting) {
+ return;
+ }
+ if (!nextOpen) {
+ setStartupSshModal((prev) => ({
+ ...prev,
+ open: false,
+ connecting: false,
+ }));
+ return;
+ }
+ setStartupSshModal((prev) => ({ ...prev, open: true }));
+ }}
+ >
+
+
+ Default SSH instance unavailable
+
+ {startupSshModal.connecting
+ ? `Connecting to ${startupSshModal.hostLabel || 'SSH instance'}...`
+ : startupSshModal.error || 'Failed to connect the default SSH instance.'}
+
+
+
+ void switchStartupToLocal()}
+ disabled={startupSshModal.connecting}
+ >
+ Switch to Local
+
+
+ {startupSshModal.connecting ? : null}
+ Retry
+
+
+
+
+ >
+ );
+}
+
+export function DesktopHostSwitcherInline() {
+ const [open, setOpen] = React.useState(false);
+
+ if (!isDesktopShell()) {
+ return null;
+ }
+
+ return (
+ <>
+ setOpen(true)}
+ >
+
+ Switch instance
+
+
+ >
+ );
+}
diff --git a/ui/src/components/desktop/OpenInAppButton.tsx b/ui/src/components/desktop/OpenInAppButton.tsx
new file mode 100644
index 0000000..82a0def
--- /dev/null
+++ b/ui/src/components/desktop/OpenInAppButton.tsx
@@ -0,0 +1,214 @@
+import React from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { toast } from '@/components/ui';
+import { copyTextToClipboard } from '@/lib/clipboard';
+import { cn } from '@/lib/utils';
+import { isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop';
+import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS } from '@/lib/openInApps';
+import { useOpenInAppsStore, type OpenInAppOption } from '@/stores/useOpenInAppsStore';
+import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
+
+const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAB+C9pSAAAACXBIWXMAABYlAAAWJQFJUiTwAAABnWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4yNTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MjU2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cl6wHhsAAAXaSURBVFgJ7VddbBRVFP5mdme6dOnu2tZawOAPjfxU+bGQYCRAsvw8qNGEQPTRJ0I0amL0wfjgA/HBR8KLD8YgDxJEUkVFxSYYTSRii6DQQCMGIQqlW7p0u+zMzo/fuTuzO9Nt0Td94CRn7pl7z5zznZ977y5wh/7jDGiz+T979qD5Ujbfd90xlll+stOF1uI40B1+4HhkjnZk9CgLQ9iXp2/BdcbgVc/h0sAgduywudJEMwLY9Of4ugtW5p3CpL7W1jTN88VmjdQYvnDKF1mczkYuNZLeCVg3X8fa9u+nqzUB2HRpdN2pSseRQknPoUL1Jo2ICTrPGcCzdwPdHENcAnicKRqcAk7cpL5J1r0JlAtPYV1XDETM/FtH3m19r+f5by+XjNX/xnmCX3/cCzydi4CKiC7lw+PArhGgoPPFq/6E0+9vwM6d5VBNpuv03cLNfeNTRh9KnJIiV2/PvSngycC5RD+dE5zb3g7s6QESzAZc2l6wuY9SnWIAxv10r81uU85Vt1FvtpEtlc/SMFUkUofeZ2IBta0DWDmXgkfbyTRz1qAYAMczOz3p1elOxYPyEllj421hdELViPO6Kudk3ia3UGe5ABDbvtnJZ52SdYmCZ3stdeexBabFdeAbYopEowtagVUZqFapBrtAGqpiVaFrGgyjZlrmTD5yEqoEJj4iFMuA62i6L3WPZkAiuHgarZ/vbWSBkTzO2rfTR4XOJVJhjfX44MBn+OTocVWbcF5MalxXPeVL6zYonoGo44YOtDI7qHC1lkL5nHnOc+tJRi3K6iygLNGMjt1A1XVV6iUzOvVtAvMlS2I/yBYlRf8MgA6szmXQ1jDfKhSgjft6DRtrkgarAiAw5nI9v2WDSn+Zxfd9DawGxIlPPQUg0A2HGABfEIYlCDU4+q0d8O+jRzHCCFYy+nu4BaeYAoksBCDrPYsXQQ6iitgiSQaS1FHHtMzFil4DpxTl4UhORSn4WOaaiGsbu4iFRkMnYQlEV0oSJQGQ4FyYgSRDjpqPZcCR6EOOWonIEsBqArAIQOMLzw0VXRRERF2VoA6Atk1+MzsASekMJYgaFEeHR4Cr85lNGntYzgKCYd/NSNIDCXr0ZJ2jwTsjSvEMzFQCCVmKHBRahn2DNb4rDRx8pnbXOOIg0JELLMHOF1AUkaRj1V8c2TookkMS83WK9QCVpRwtf5wCykQWRKDyJ44Ytc452QUV6inmN9IDIv/6y2+YLDuqTywBEHxv8rsoxQC4Fpf4cZ2pbJ4/huxXr0EvFmoRCrAIVymLQ3Eid0GJYPsPfISBLwdwi79YQnCqBNS7LQDP5qYSAKEDypOrX4WVWYLsFy+i9cwh6CUmUKIJI2Gq5cSbnLLw849D2Ld3L4olC1u3P0c1ow5Ozgixa3puWChONG1D3eLZUQOglvng+Vp5dBfseesx5/yHyI4cBTL3wsssRGs2g6/ppHijiMLoNSSMNHofy6Nn6SPsAR02nUoTtrDTSrdoi8CTni55rlOsCf1ypaDxlFMNU1epCV5XL6Y6dmOq+BeS48NIlq7Anpjg5dOFbPdDWLQyj/aubnUKSkMKi3NhkUd4kieYtbRbYS0bFAOQKI8NO363z1RJHmamtnlwhGksxV2w/gl29WRtm8kWtWUnRShLnQvXgDOXmLg2HzlvbDiyHD8Y517YP2i4FtueFPbB9FFqKcyobk4A5y7zquUFa7IXojyHoeXmAFcY755vaI6A56Xsofm/7+cmblBTpOldQ5vs3PJDVS+RVSAaus2SpJTO80t4NTNSOQfCDrtFkBevA0ME6HGvPdDpFlekzm7rf3nFQNRQEwBZTL9warObWfx21Uv1+fx1ERqVNampGoOHpF1tsdp07RnoGMxK1vT97rbK4IP6+Tc+fWXVsahaYGL6VO09d//GXHXr7jVeqmuppqU6ff4x0RO6lqRxgxHJpWKSlcw5eWfjq5rq/CdhaL5l6JWxjDc6bP7w5sn+/uMs2B36H2bgb6v9raK0+o9IAAAAAElFTkSuQmCC';
+const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAB+C9pSAAAACXBIWXMAABYlAAAWJQFJUiTwAAABnWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4yNTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MjU2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cl6wHhsAAAQzSURBVFgJ7VZNbBNHFH67Xv9RxwnBDqlUoQglcZK6qSIEJIQWAYJQoVY9IE5RTzn20FMvqdpDesq9B24+NdwthAJCkZChJg1JSOXYQIwQKQIaBdtENbs73t2+N8miGWOcpFHUHniyd97OvJ9v3nv7ZgDe038cAeVd/jOZjC94sKdfU+Bj24G9igpexwYPyiu2bauKqqqirkOTqmrjnIOyFsoyUKDocSCj/7mU7ujoMER5l68JYOFZ4YSiwPjd9O0jjx7ch1KhAJZVAcdx0LxDv3XetYKjggr4I4bzHo8G4aYmONjZBYf6+2dUzfd9PNowJajUZmef/PX5zcWl0rmvvnbQHrra+f/M+S+dqYXs2t3Hz09Ve5UicCmZ3NPb1Zv66btv+65dSULA64WGxkbw+Xx8V9XK9d4pWowxeFUqgW6acHroC/j5l0sLD/PZY98MDf3t6mouQ+On3X1H7/2e7rtOztHpgbY2+CAUgperq+D3+7cNgtLSEA7D0+VluDF5FS7cSff2HT56DF1dd/3KhQTWJ/lclsc8jIrk9IfRURgZGQEvRqNSWa8D2t1W/liXXK8Ro0i0lF0ExaPEXec0SgAqhrm3VCzwdS9GQNd1GBsbg0AgAIlEAlpbW7EYLVF/U56AagieiGwbuhERlSQApmEE8c/XKXxU0fF4HNowFfPz81Aul7edBjLGbeHITANsZga4g42HVAM2Y74KM/kSIQ/izgcHB2FiYgJmZmZ4MZpYULRG5PF4+Bx/2cLDxuhhYUqFLwGoWCaQEBGhNjAa4+Pj/J3SQA6pHpqbm/kcNitIJpOgaZIZvlbrQbZNJvcjSZOZDKhwRKLic4l2Pjc3B8FgkE+trKxAVUN0RWuOZNtCHyJJACj/bgREIZcnA9PT029SQM63unuywSOwUWOuTQmAhfmnlluPxIjUk6u1RrbJh0jyV0Ap2OZnJhrbjOcRqEqBBMDCAtltAORDJAkAVj2mWS5CUXinPDUx+oxFkgBYjO0qANu2wKoqQgkAfgW7C4AiYMmfoQSgwpjj7GYRUh/Q66SAmdisNxql227FfP1bXrRlVExdtCNHwDRLdPkgwmi8OUREhe3y1NLJFpEfbWMNvBRtSI2o+KqYi+zbx4NQwptMCO8E1HjEHYjKm/HknG5FZIsCG4lEoLS2lhP1JAB3bt1KH//s+GJPd3dPJpvlN5kwXiYIhHukisr1eAItXsm6YzGItrTcn5+dvS3qSQBSqVQhFouNnj039CsaCC7mcqDjgbNT6op1AtrU8Wo3Ojk5KaVAOptdR8PDwxf3t7SMvXjxvJNOPP31a35Krt8CXKl3j2SUDip/IAjRaBRaP9z/cHW18GMikbhcrVUTAAm1t7d/NDAwcDIUCvVqmtqkyLe3ajtvvTtg4x3SLpbLa3+kUr9N5fP55beE3k/8HyLwDx2/HIx7q3WfAAAAAElFTkSuQmCC';
+
+type OpenInAppOptionWithFallback = OpenInAppOption & {
+ fallbackIconDataUrl?: string;
+};
+
+const withFallbackIcon = (app: OpenInAppOption): OpenInAppOptionWithFallback => ({
+ ...app,
+ fallbackIconDataUrl: app.id === 'finder'
+ ? FINDER_DEFAULT_ICON_DATA_URL
+ : app.id === 'terminal'
+ ? TERMINAL_DEFAULT_ICON_DATA_URL
+ : undefined,
+});
+
+const AppIcon = ({
+ label,
+ iconDataUrl,
+ fallbackIconDataUrl,
+}: {
+ label: string;
+ iconDataUrl?: string;
+ fallbackIconDataUrl?: string;
+}) => {
+ const [failed, setFailed] = React.useState(false);
+ const initial = label.trim().slice(0, 1).toUpperCase() || '?';
+
+ const src = iconDataUrl || fallbackIconDataUrl;
+
+ if (src && !failed) {
+ return (
+ setFailed(true)}
+ />
+ );
+ }
+
+ return (
+
+ {initial}
+
+ );
+};
+
+type OpenInAppButtonProps = {
+ directory: string;
+ activeFilePath?: string | null;
+ className?: string;
+};
+
+export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenInAppButtonProps) => {
+ const selectedAppId = useOpenInAppsStore((state) => state.selectedAppId);
+ const availableApps = useOpenInAppsStore((state) => state.availableApps);
+ const isCacheStale = useOpenInAppsStore((state) => state.isCacheStale);
+ const isScanning = useOpenInAppsStore((state) => state.isScanning);
+ const initialize = useOpenInAppsStore((state) => state.initialize);
+ const loadInstalledApps = useOpenInAppsStore((state) => state.loadInstalledApps);
+ const selectApp = useOpenInAppsStore((state) => state.selectApp);
+
+ React.useEffect(() => {
+ initialize();
+ }, [initialize]);
+
+ const isDesktopLocal = isTauriShell() && isDesktopLocalOriginActive();
+
+ const selectedApp = React.useMemo(() => {
+ const known = availableApps.find((app) => app.id === selectedAppId)
+ ?? availableApps.find((app) => app.id === DEFAULT_OPEN_IN_APP_ID)
+ ?? availableApps[0]
+ ?? OPEN_IN_APPS[0];
+ if (known) {
+ return withFallbackIcon(known);
+ }
+ return withFallbackIcon(OPEN_IN_APPS[0]);
+ }, [availableApps, selectedAppId]);
+
+ if (!isDesktopLocal || !directory) {
+ return null;
+ }
+
+ if (availableApps.length === 0) {
+ return null;
+ }
+
+ const handleOpen = async (app: OpenInAppOption) => {
+ const opened = await openDesktopProjectInApp(directory, app.id, app.appName, activeFilePath);
+ if (!opened) {
+ await openDesktopPath(directory, app.appName);
+ }
+ };
+
+ const handleSelect = async (app: OpenInAppOption) => {
+ await selectApp(app.id);
+ await handleOpen(app);
+ };
+
+ const handleCopyPath = async () => {
+ const text = directory;
+ const result = await copyTextToClipboard(text);
+ if (!result.ok) {
+ return;
+ }
+ toast.success('Path copied to clipboard');
+ };
+
+ return (
+
+
void handleOpen(selectedApp)}
+ className={cn(
+ 'inline-flex h-full items-center gap-2 px-3 typography-ui-label font-medium',
+ 'text-foreground hover:bg-interactive-hover transition-colors'
+ )}
+ aria-label={`Open in ${selectedApp.label}`}
+ >
+
+
+ Open
+
+
+
+
+
+
+
+
+
+ void handleCopyPath()}>
+
+ Copy Path
+
+
+ {availableApps.map((app) => {
+ const appWithFallback = withFallbackIcon(app);
+ return (
+ void handleSelect(app)}
+ >
+
+ {app.label}
+ {selectedApp.id === app.id ? (
+
+ ) : null}
+
+ );
+ })}
+ {isCacheStale ? (
+ void loadInstalledApps(true)}
+ >
+
+ Refresh Apps
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/ui/src/components/icons/ArrowsMerge.tsx b/ui/src/components/icons/ArrowsMerge.tsx
new file mode 100644
index 0000000..2bf971c
--- /dev/null
+++ b/ui/src/components/icons/ArrowsMerge.tsx
@@ -0,0 +1,19 @@
+import type { SVGProps } from 'react';
+
+export function ArrowsMerge(props: SVGProps) {
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/components/icons/DiffIcon.tsx b/ui/src/components/icons/DiffIcon.tsx
new file mode 100644
index 0000000..dd46a0b
--- /dev/null
+++ b/ui/src/components/icons/DiffIcon.tsx
@@ -0,0 +1,27 @@
+import type { SVGProps } from 'react';
+
+interface DiffIconProps extends Omit, 'children'> {
+ size?: number | string;
+}
+
+/**
+ * Git merge/branch icon for the Diff tab.
+ */
+export function DiffIcon({ size, className, style, ...props }: DiffIconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/components/icons/FileTypeIcon.tsx b/ui/src/components/icons/FileTypeIcon.tsx
new file mode 100644
index 0000000..5acca7a
--- /dev/null
+++ b/ui/src/components/icons/FileTypeIcon.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import { getFileTypeIconHref } from '@/lib/fileTypeIcons';
+import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
+
+type FileTypeIconProps = {
+ filePath: string;
+ extension?: string;
+ className?: string;
+};
+
+export const FileTypeIcon: React.FC = ({ filePath, extension, className }) => {
+ const theme = useOptionalThemeSystem();
+ const variant = theme?.currentTheme.metadata.variant === 'light' ? 'light' : 'dark';
+ const iconHref = getFileTypeIconHref(filePath, { extension, themeVariant: variant });
+
+ return (
+
+
+
+ );
+};
diff --git a/ui/src/components/icons/McpIcon.tsx b/ui/src/components/icons/McpIcon.tsx
new file mode 100644
index 0000000..b1484cb
--- /dev/null
+++ b/ui/src/components/icons/McpIcon.tsx
@@ -0,0 +1,20 @@
+import type { SVGProps } from 'react';
+
+export function McpIcon(props: SVGProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/src/components/icons/StopIcon.tsx b/ui/src/components/icons/StopIcon.tsx
new file mode 100644
index 0000000..9b86898
--- /dev/null
+++ b/ui/src/components/icons/StopIcon.tsx
@@ -0,0 +1,20 @@
+import type { SVGProps } from 'react';
+
+export function StopIcon(props: SVGProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/src/components/layout/BottomTerminalDock.tsx b/ui/src/components/layout/BottomTerminalDock.tsx
new file mode 100644
index 0000000..6751155
--- /dev/null
+++ b/ui/src/components/layout/BottomTerminalDock.tsx
@@ -0,0 +1,193 @@
+import React from 'react';
+import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
+import { cn } from '@/lib/utils';
+import { useUIStore } from '@/stores/useUIStore';
+
+const BOTTOM_DOCK_MIN_HEIGHT = 180;
+const BOTTOM_DOCK_MAX_HEIGHT = 640;
+const BOTTOM_DOCK_COLLAPSE_THRESHOLD = 110;
+
+interface BottomTerminalDockProps {
+ isOpen: boolean;
+ isMobile: boolean;
+ children: React.ReactNode;
+}
+
+export const BottomTerminalDock: React.FC = ({ isOpen, isMobile, children }) => {
+ const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
+ const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
+ const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
+ const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
+ const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
+ const [fullscreenHeight, setFullscreenHeight] = React.useState(null);
+ const [isResizing, setIsResizing] = React.useState(false);
+ const dockRef = React.useRef(null);
+ const startYRef = React.useRef(0);
+ const startHeightRef = React.useRef(bottomTerminalHeight || 300);
+ const previousHeightRef = React.useRef(bottomTerminalHeight || 300);
+
+ const standardHeight = React.useMemo(
+ () => Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)),
+ [bottomTerminalHeight],
+ );
+
+ React.useEffect(() => {
+ if (!isOpen) {
+ setFullscreenHeight(null);
+ setIsResizing(false);
+ }
+ }, [isOpen]);
+
+ React.useEffect(() => {
+ if (isMobile || !isOpen || !isFullscreen) {
+ return;
+ }
+
+ const updateFullscreenHeight = () => {
+ const parentHeight = dockRef.current?.parentElement?.getBoundingClientRect().height;
+ if (!parentHeight || parentHeight <= 0) {
+ return;
+ }
+ const next = Math.round(parentHeight);
+ setFullscreenHeight((prev) => (prev === next ? prev : next));
+ };
+
+ updateFullscreenHeight();
+
+ const parent = dockRef.current?.parentElement;
+ if (!parent) {
+ return;
+ }
+
+ const observer = new ResizeObserver(updateFullscreenHeight);
+ observer.observe(parent);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [isFullscreen, isMobile, isOpen]);
+
+ React.useEffect(() => {
+ if (isMobile || !isResizing || isFullscreen) {
+ return;
+ }
+
+ const handlePointerMove = (event: PointerEvent) => {
+ const delta = startYRef.current - event.clientY;
+ const nextHeight = Math.min(
+ BOTTOM_DOCK_MAX_HEIGHT,
+ Math.max(BOTTOM_DOCK_MIN_HEIGHT, startHeightRef.current + delta)
+ );
+ setBottomTerminalHeight(nextHeight);
+ };
+
+ const handlePointerUp = () => {
+ setIsResizing(false);
+ const latestState = useUIStore.getState();
+ if (latestState.bottomTerminalHeight <= BOTTOM_DOCK_COLLAPSE_THRESHOLD) {
+ setBottomTerminalOpen(false);
+ }
+ };
+
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp, { once: true });
+
+ return () => {
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ };
+ }, [isFullscreen, isMobile, isResizing, setBottomTerminalHeight, setBottomTerminalOpen]);
+
+ if (isMobile) {
+ return null;
+ }
+
+ const appliedHeight = isOpen
+ ? (isFullscreen ? Math.max(standardHeight, fullscreenHeight ?? standardHeight) : standardHeight)
+ : 0;
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (!isOpen || isFullscreen) return;
+ setIsResizing(true);
+ startYRef.current = event.clientY;
+ startHeightRef.current = appliedHeight;
+ event.preventDefault();
+ };
+
+ const toggleFullscreen = () => {
+ if (!isOpen) return;
+
+ if (isFullscreen) {
+ setBottomTerminalExpanded(false);
+ const restoreHeight = Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, previousHeightRef.current));
+ setBottomTerminalHeight(restoreHeight);
+ return;
+ }
+
+ previousHeightRef.current = standardHeight;
+ setBottomTerminalExpanded(true);
+ };
+
+ return (
+
+ {isOpen && !isFullscreen && (
+
+ )}
+
+ {isOpen && (
+
+
+ {isFullscreen ?