Compare commits

..

16 Commits

Author SHA1 Message Date
viper151
a7e8b12ef4 fix: coderabbit nitpick comments 2026-03-06 15:53:11 +01:00
viper151
24430fa343 fix: lint errors and deleting plugin error on windows 2026-03-06 15:44:32 +01:00
Haileyesus
38accf6505 Merge branch 'main' into feat/plugin-system 2026-03-06 17:09:52 +03:00
Eric Blanquer
3950c0e47f feat: add full-text search across conversations (#482)
* feat: add full-text search across conversations in sidebar

Add a search mode toggle (Projects/Conversations) to the sidebar search bar.
In Conversations mode, search text content across all JSONL session files
with debounced API calls, highlighted snippets, and click-to-navigate results.

* fix: address PR review feedback - session summary tracking, search sequence invalidation, fallback navigation, SSE streaming

- Track session summaries per-session in a Map instead of file-scoped variable
- Increment searchSeqRef when clearing conversation search to invalidate in-flight requests
- Add fallback session navigation when session not loaded in sidebar paging
- Stream search results via SSE for progressive display with progress indicator

* feat(search): add Codex/Gemini search and scroll-to-message navigation

- Search now includes Codex sessions (JSONL from ~/.codex/sessions/) and
  Gemini sessions (in-memory via sessionManager) in addition to Claude
- Search results include provider info and display a provider badge
- Click handler resolves the correct provider instead of hardcoding claude
- Clicking a search result loads all messages and scrolls to the matched
  message with a highlight flash animation

* fix(search): Codex search path matching and scroll reliability

- Fix Codex search scanning all sessions for every project by checking
  session_meta cwd match BEFORE scanning messages (was inflating match
  count and hitting limit before reaching later projects)
- Fix Codex search missing user messages in response_item entries
  (role=user with input_text content parts)
- Fix scroll-to-message being overridden by initial scrollToBottom
  using searchScrollActiveRef to inhibit competing scroll effects
- Fix snippet matching using contiguous substring instead of
  filtered words (which created non-existent phrases)

* feat(search): add Gemini CLI session support for search and history viewing

Gemini CLI sessions stored in ~/.gemini/tmp/<project>/chats/*.json are now
indexed for conversation search and can be loaded for viewing. Previously
only sessions created through the UI (sessionManager) were searchable.

* fix(search): full-word matching and longer highlight flash

- Search now uses word boundaries (\b) instead of substring matching,
  so "hi" no longer matches "this"
- Highlight flash extended to 4s with thicker outline and subtle
  background tint for better visibility
2026-03-06 16:59:23 +03:00
simosmik
e80fd4b09b fix(plugins): prevent git arg injection, add repo URL detection 2026-03-06 12:09:25 +00:00
simosmik
1d62df68d6 fix: design changes to plugins settings tab 2026-03-06 12:01:11 +00:00
simosmik
0a3e22905f fix: coderabbit changes and new plugin name & repo 2026-03-06 11:50:01 +00:00
viper151
a09aa5f68e feat(plugins): add SVG icon support with authenticated inline rendering 2026-03-06 12:36:15 +01:00
Haileyesus
95ba61ea3e Merge branch 'main' into feat/plugin-system 2026-03-06 13:07:30 +03:00
Simos Mikelatos
6e4ea7f333 Update manifest.json 2026-03-06 10:20:56 +01:00
Simos Mikelatos
d299ab88a0 chore(release): v1.23.2 2026-03-06 01:51:03 +00:00
Simos Mikelatos
dcea8a329c fix: release it script 2026-03-06 01:38:08 +00:00
Simos Mikelatos
6d4cea0435 Potential fix for code scanning alert no. 312: Uncontrolled data used in path expression
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-06 01:16:52 +01:00
Simos Mikelatos
ba197cc286 Merge branch 'main' into feat/plugin-system
# Conflicts:
#	src/App.tsx
#	src/components/app/MobileNav.tsx
#	src/components/main-content/view/MainContent.tsx
#	src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
#	src/components/main-content/view/subcomponents/MainContentTitle.tsx
#	src/components/settings/view/SettingsMainTabs.tsx
2026-03-05 22:53:54 +00:00
Simos Mikelatos
b4169887ab feat: new plugin system 2026-03-05 22:51:27 +00:00
Haileyesus
844de26ada Refactor/shared and tasks components (#473)
* refactor: remove unused TasksSettings component

* refactor: migrate TodoList component to a new file with improved structure and normalization logic

* refactor: Move Tooltip and DarkModeToggle to shared/ui

* refactor: Move Tooltip and DarkModeToggle to shared/view/ui

* refactor: move GeminiLogo to llm-logo-provider and update imports

* refactor: remove unused GeminiStatus component

* refactor: move components in src/components/ui to src/shared/view/ui

* refactor: move ErrorBoundary component to main-content/view and update imports

* refactor: move VersionUpgradeModal to its own module

* refactor(wizard): rebuild project creation flow as modular TypeScript components

Replace the monolithic `ProjectCreationWizard.jsx` with a feature-based TS
implementation under `src/components/project-creation-wizard`, while preserving
existing behavior and improving readability, maintainability, and state isolation.

Why:
- The previous wizard mixed API logic, flow state, folder browsing, and UI in one file.
- Refactoring and testing were difficult due to tightly coupled concerns.
- We needed stronger type safety and localized component state.

What changed:
- Deleted:
  - `src/components/ProjectCreationWizard.jsx`
- Added new modular structure:
  - `src/components/project-creation-wizard/index.ts`
  - `src/components/project-creation-wizard/ProjectCreationWizard.tsx`
  - `src/components/project-creation-wizard/types.ts`
  - `src/components/project-creation-wizard/data/workspaceApi.ts`
  - `src/components/project-creation-wizard/hooks/useGithubTokens.ts`
  - `src/components/project-creation-wizard/utils/pathUtils.ts`
  - `src/components/project-creation-wizard/components/*`
    - `WizardProgress`, `WizardFooter`, `ErrorBanner`
    - `StepTypeSelection`, `StepConfiguration`, `StepReview`
    - `WorkspacePathField`, `GithubAuthenticationCard`, `FolderBrowserModal`
- Updated import usage:
  - `src/components/sidebar/view/subcomponents/SidebarModals.tsx`
    now imports from `../../../project-creation-wizard`.

Implementation details:
- Migrated wizard logic to TypeScript using `type` aliases only.
- Kept component prop types colocated in each component file.
- Split responsibilities by feature:
  - container/orchestration in `ProjectCreationWizard.tsx`
  - API/SSE and request parsing in `data/workspaceApi.ts`
  - GitHub token loading/caching behavior in `useGithubTokens`
  - path/URL helpers in `utils/pathUtils.ts`
- Localized UI-only state to child components:
  - folder browser modal state (current path, hidden folders, create-folder input)
  - path suggestion dropdown state with debounced lookup
- Preserved existing UX flows:
  - step navigation and validation
  - existing/new workspace modes
  - optional GitHub clone + auth modes
  - clone progress via SSE
  - folder browsing + folder creation
- Added focused comments for non-obvious logic (debounce, SSE auth constraint, path edge cases).

* refactor(quick-settings): migrate panel to typed feature-based modules

Refactor QuickSettingsPanel from a single JSX component into a modular TypeScript feature structure while preserving behavior and translations.

Highlights:
- Replace legacy src/components/QuickSettingsPanel.jsx with a typed entrypoint (src/components/QuickSettingsPanel.tsx).
- Introduce src/components/quick-settings-panel/ with clear separation of concerns:
  - view/: panel shell, header, handle, section wrappers, toggle rows, and content sections.
  - hooks/: drag interactions and whisper mode persistence.
  - constants.ts and types.ts for shared config and strict local typing.
- Move drag logic into useQuickSettingsDrag with explicit touch/mouse handling, drag threshold detection, click suppression after drag, position clamping, and localStorage persistence.
- Keep user-visible behavior intact:
  - same open/close panel interactions.
  - same mobile/desktop drag behavior and persisted handle position.
  - same quick preference toggles and wiring to useUiPreferences.
  - same hidden whisper section behavior and localStorage/event updates.
- Improve readability and maintainability by extracting repetitive setting rows and section scaffolding into reusable components.
- Add focused comments around non-obvious behavior (drag click suppression, touch scroll lock, hidden whisper section intent).
- Keep files small and reviewable (all new/changed files are under 300 lines).

Validation:
- npm run typecheck
- npm run build

* refactor(quick-settings-panel): restructure QuickSettingsPanel import and create index file

* refactor(shared): move shared ui components to share/view/ui without subfolders

* refactor(LanguageSelector): move LanguageSelector to shared UI components

* refactor(prd-editor): modularize PRD editor with typed feature modules

Break the legacy PRDEditor.jsx monolith into a feature-based TypeScript architecture under src/components/prd-editor while keeping behavior parity and readability.

Key changes:

- Replace PRDEditor.jsx with a typed orchestrator component and a compatibility export bridge at src/components/PRDEditor.tsx.

- Split responsibilities into dedicated hooks: document loading/init, existing PRD registry fetching, save workflow with overwrite detection, and keyboard shortcuts.

- Split UI into focused view components: header, editor/preview body, footer stats, loading state, generate-tasks modal, and overwrite-confirm modal.

- Move filename concerns into utility helpers (sanitize, extension handling, default naming) and centralize template/constants.

- Keep component-local state close to the UI that owns it (workspace controls/modal toggles), while shared workflow state remains in the feature container.

- Reuse the existing MarkdownPreview component for safer markdown rendering instead of ad-hoc HTML conversion.

- Update TaskMasterPanel integration to consume typed PRDEditor directly (remove any-cast) and pass isExisting metadata for correct overwrite behavior.

- Keep all new/changed files below 300 lines and add targeted comments where behavior needs clarification.

Validation:

- npm run typecheck

- npm run build

* refactor(TaskMasterPanel): update PRDEditor import path to match new structure

* refactor(TaskMaster): Remove unused TaskMasterSetupWizard and TaskMasterStatus components

* refactor(TaskDetail): remove unused TaskIndicator import

* refactor(task-master): migrate tasks to a typed feature module

- introduce a new feature-oriented TaskMaster domain under src/components/task-master

- add typed TaskMaster context/provider with explicit project, task, MCP, and loading state handling

- split task UI into focused components (panel, board, toolbar, content, card, detail modal, setup/help modals, banner)

- move task board filtering/sorting/kanban derivation into dedicated hooks and utilities

- relocate CreateTaskModal into the feature module and keep task views modular/readable

- remove legacy monolithic TaskList/TaskDetail/TaskCard files and route main task panel to the new feature panel

- replace contexts/TaskMasterContext.jsx with a typed contexts/TaskMasterContext.ts re-export to the feature context

- update MainContent project sync logic to compare by project name to avoid state churn

- validation: npm run typecheck, npm run build

* refactor(MobileNav): remove unused React import and TaskMasterContext

* refactor(auth): migrate login and setup flows to typed feature module

- Introduce a new feature-based auth module under src/components/auth with clear separation of concerns:\n  - context/AuthContext.tsx for session lifecycle, onboarding status checks, token persistence, and auth actions\n  - view/* components for loading, route guarding, form layout, input fields, and error display\n  - shared auth constants, utility helpers, and type aliases (no interfaces)\n- Convert login and setup UIs to TypeScript and keep form state local to each component for readability and component-level ownership\n- Add explicit API payload typing and safe JSON parsing helpers to improve resilience when backend responses are malformed or incomplete\n- Centralize error fallback handling for auth requests to reduce repeated logic

- Replace legacy auth entrypoints with the new feature module in app wiring:\n  - App now imports AuthProvider and ProtectedRoute from src/components/auth\n  - WebSocketContext, TaskMasterContext, and Onboarding now consume useAuth from the new typed auth context\n- Remove duplicated legacy auth screens (LoginForm.jsx, SetupForm.jsx, ProtectedRoute.jsx)\n- Keep backward compatibility by turning src/contexts/AuthContext.jsx into a thin re-export of the new provider/hook

Result: auth code now follows a feature/domain structure, is fully typed, easier to navigate, and cleaner to extend without touching unrelated UI areas.

* refactor(AppContent): update MobileNav import path and add MobileNav component

* refactor(DiffViewer): rename different diff viewers and place them in different components

* refactor(components): reorganize onboarding/provider auth/sidebar indicator into domain features

- Move onboarding out of root-level components into a dedicated feature module:
  - add src/components/onboarding/view/Onboarding.tsx
  - split onboarding UI into focused subcomponents:
    - OnboardingStepProgress
    - GitConfigurationStep
    - AgentConnectionsStep
    - AgentConnectionCard
  - add onboarding-local types and utils for provider status and validation helpers

- Move multi-provider login modal into a dedicated provider-auth feature:
  - add src/components/provider-auth/view/ProviderLoginModal.tsx
  - add src/components/provider-auth/types.ts
  - keep provider-specific command/title behavior and Gemini setup guidance
  - preserve compatibility for both onboarding flow and settings login flow

- Move TaskIndicator into the sidebar domain:
  - add src/components/sidebar/view/subcomponents/TaskIndicator.tsx
  - update SidebarProjectItem to consume local sidebar TaskIndicator

- Update integration points to the new structure:
  - ProtectedRoute now imports onboarding from onboarding feature
  - Settings now imports ProviderLoginModal directly (remove legacy cast wrapper)
  - git panel consumers now import shared GitDiffViewer by explicit name

- Rename git shared diff view to clearer domain naming:
  - replace shared DiffViewer with shared GitDiffViewer
  - update FileChangeItem and CommitHistoryItem imports accordingly

- Remove superseded root-level legacy components:
  - delete src/components/LoginModal.jsx
  - delete src/components/Onboarding.jsx
  - delete src/components/TaskIndicator.jsx
  - delete old src/components/git-panel/view/shared/DiffViewer.tsx

- Result:
  - clearer feature boundaries (auth vs onboarding vs provider-auth vs sidebar)
  - easier navigation and ownership by domain
  - preserved runtime behavior with improved readability and modularity

* refactor(MainContent): remove TaskMasterPanel import and relocate to task-master component

* fix: update import paths for Input component in FileTree and FileTreeNode

* refactor(FileTree): make file tree context menu a typescript component and move it inside the file tree view

* refactor(FileTree): remove unused ScrollArea import

* feat: setup eslint with typescript and react rules, add unused imports plugin

* fix: remove unused imports, functions, and types after discovering using `npm run lint`

* feat: setup eslint-plugin-react, react-refresh, import-x, and tailwindcss plugins with recommended rules and configurations

* chore: reformat files after running `npm run lint:fix`

* chore: add omments about eslint config plugin uses

* feat: add husky and lint-staged for pre-commit linting

* feat: setup commitlint with conventional config

* fix: i18n translations

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: viper151 <simosmik@gmail.com>
2026-03-05 23:47:58 +01:00
39 changed files with 3211 additions and 133 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "plugins/starter"]
path = plugins/starter
url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git

View File

@@ -1,6 +1,6 @@
{
"git": {
"commitMessage": "Release ${version}",
"commitMessage": "chore(release): v${version}",
"tagName": "v${version}",
"requireBranch": "main",
"requireCleanWorkingDir": true

View File

@@ -3,6 +3,30 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
### New Features
* add clickable overlay buttons for CLI prompts in Shell terminal ([#480](https://github.com/siteboon/claudecodeui/issues/480)) ([2444209](https://github.com/siteboon/claudecodeui/commit/2444209723701dda2b881cea2501b239e64e51c1)), closes [#427](https://github.com/siteboon/claudecodeui/issues/427)
* add terminal shortcuts panel for mobile ([#411](https://github.com/siteboon/claudecodeui/issues/411)) ([b0a3fdf](https://github.com/siteboon/claudecodeui/commit/b0a3fdf95ffdb961261194d10400267251e42f17))
* implement session rename with SQLite storage ([#413](https://github.com/siteboon/claudecodeui/issues/413)) ([198e3da](https://github.com/siteboon/claudecodeui/commit/198e3da89b353780f53a91888384da9118995e81)), closes [#72](https://github.com/siteboon/claudecodeui/issues/72) [#358](https://github.com/siteboon/claudecodeui/issues/358)
### Bug Fixes
* **chat:** finalize terminal lifecycle to prevent stuck processing/thinking UI ([#483](https://github.com/siteboon/claudecodeui/issues/483)) ([0590c5c](https://github.com/siteboon/claudecodeui/commit/0590c5c178f4791e2b039d525ecca4d220c3dcae))
* **codex-history:** prevent AGENTS.md/internal prompt leakage when reloading Codex sessions ([#488](https://github.com/siteboon/claudecodeui/issues/488)) ([64a96b2](https://github.com/siteboon/claudecodeui/commit/64a96b24f853acb802f700810b302f0f5cf00898))
* preserve pending permission requests across WebSocket reconnections ([#462](https://github.com/siteboon/claudecodeui/issues/462)) ([4ee88f0](https://github.com/siteboon/claudecodeui/commit/4ee88f0eb0c648b54b05f006c6796fb7b09b0fae))
* prevent React 18 batching from losing messages during session sync ([#461](https://github.com/siteboon/claudecodeui/issues/461)) ([688d734](https://github.com/siteboon/claudecodeui/commit/688d73477a50773e43c85addc96212aa6290aea5))
* release it script ([dcea8a3](https://github.com/siteboon/claudecodeui/commit/dcea8a329c7d68437e1e72c8c766cf33c74637e9))
### Styling
* improve UI for processing banner ([#477](https://github.com/siteboon/claudecodeui/issues/477)) ([2320e1d](https://github.com/siteboon/claudecodeui/commit/2320e1d74b59c65b5b7fc4fa8b05fd9208f4898c))
### Maintenance
* remove logging of received WebSocket messages in production ([#487](https://github.com/siteboon/claudecodeui/issues/487)) ([9193feb](https://github.com/siteboon/claudecodeui/commit/9193feb6dc83041f3c365204648a88468bdc001b))
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
### New Features

View File

@@ -1,22 +1,24 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
---
## Screenshots
<div align="center">
@@ -59,7 +61,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
## Quick Start

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.22.0",
"version": "1.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.22.0",
"version": "1.23.2",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -1909,7 +1909,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1926,7 +1925,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.22.0",
"version": "1.23.2",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",

1
plugins/starter Submodule

Submodule plugins/starter added at bfa6332810

View File

@@ -44,7 +44,7 @@ import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
@@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
@@ -389,6 +391,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -608,6 +613,51 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
}
});
// Search conversations content (SSE streaming)
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
const parsedLimit = Number.parseInt(String(req.query.limit), 10);
const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
if (query.length < 2) {
return res.status(400).json({ error: 'Query must be at least 2 characters' });
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
let closed = false;
const abortController = new AbortController();
req.on('close', () => { closed = true; abortController.abort(); });
try {
await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
if (closed) return;
if (projectResult) {
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
} else {
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
}
}, abortController.signal);
if (!closed) {
res.write(`event: done\ndata: {}\n\n`);
}
} catch (error) {
console.error('Error searching conversations:', error);
if (!closed) {
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
}
} finally {
if (!closed) {
res.end();
}
}
});
const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath;
if (inputPath === '~') {
@@ -2487,7 +2537,20 @@ async function startServer() {
// Start watching the projects folder for changes
await setupProjectsWatcher();
// Start server-side plugin processes for enabled plugins
startEnabledPluginServers().catch(err => {
console.error('[Plugins] Error during startup:', err.message);
});
});
// Clean up plugin processes on shutdown
const shutdownPlugins = () => {
stopAllPlugins();
process.exit(0);
};
process.on('SIGTERM', shutdownPlugins);
process.on('SIGINT', shutdownPlugins);
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);

View File

@@ -481,9 +481,13 @@ async function getProjects(progressCallback = null) {
}
applyCustomSessionNames(project.codexSessions, 'codex');
// Also fetch Gemini sessions for this project
// Also fetch Gemini sessions for this project (UI + CLI)
try {
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
const cliSessions = await getGeminiCliSessions(actualProjectDir);
const uiIds = new Set(uiSessions.map(s => s.id));
const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
project.geminiSessions = mergedGemini;
} catch (e) {
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
project.geminiSessions = [];
@@ -584,9 +588,12 @@ async function getProjects(progressCallback = null) {
}
applyCustomSessionNames(project.codexSessions, 'codex');
// Try to fetch Gemini sessions for manual projects too
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
try {
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
const cliSessions = await getGeminiCliSessions(actualProjectDir);
const uiIds = new Set(uiSessions.map(s => s.id));
project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
} catch (e) {
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
}
@@ -1862,6 +1869,675 @@ async function deleteCodexSession(sessionId) {
}
}
async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
const safeQuery = typeof query === 'string' ? query.trim() : '';
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
const results = [];
let totalMatches = 0;
const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery };
const isAborted = () => signal?.aborted === true;
const isSystemMessage = (textContent) => {
return typeof textContent === 'string' && (
textContent.startsWith('<command-name>') ||
textContent.startsWith('<command-message>') ||
textContent.startsWith('<command-args>') ||
textContent.startsWith('<local-command-stdout>') ||
textContent.startsWith('<system-reminder>') ||
textContent.startsWith('Caveat:') ||
textContent.startsWith('This session is being continued from a previous') ||
textContent.startsWith('Invalid API key') ||
textContent.includes('{"subtasks":') ||
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') ||
textContent === 'Warmup'
);
};
const extractText = (content) => {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter(part => part.type === 'text' && part.text)
.map(part => part.text)
.join(' ');
}
return '';
};
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordPatterns = words.map(w => new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u'));
const allWordsMatch = (textLower) => {
return wordPatterns.every(p => p.test(textLower));
};
const buildSnippet = (text, textLower, snippetLen = 150) => {
let firstIndex = -1;
let firstWordLen = 0;
for (const w of words) {
const re = new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u');
const m = re.exec(textLower);
if (m && (firstIndex === -1 || m.index < firstIndex)) {
firstIndex = m.index;
firstWordLen = w.length;
}
}
if (firstIndex === -1) firstIndex = 0;
const halfLen = Math.floor(snippetLen / 2);
let start = Math.max(0, firstIndex - halfLen);
let end = Math.min(text.length, firstIndex + halfLen + firstWordLen);
let snippet = text.slice(start, end).replace(/\n/g, ' ');
const prefix = start > 0 ? '...' : '';
const suffix = end < text.length ? '...' : '';
snippet = prefix + snippet + suffix;
const snippetLower = snippet.toLowerCase();
const highlights = [];
for (const word of words) {
const re = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'gu');
let match;
while ((match = re.exec(snippetLower)) !== null) {
highlights.push({ start: match.index, end: match.index + word.length });
}
}
highlights.sort((a, b) => a.start - b.start);
const merged = [];
for (const h of highlights) {
const last = merged[merged.length - 1];
if (last && h.start <= last.end) {
last.end = Math.max(last.end, h.end);
} else {
merged.push({ ...h });
}
}
return { snippet, highlights: merged };
};
try {
await fs.access(claudeDir);
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
const projectDirs = entries.filter(e => e.isDirectory());
let scannedProjects = 0;
const totalProjects = projectDirs.length;
for (const projectEntry of projectDirs) {
if (totalMatches >= safeLimit || isAborted()) break;
const projectName = projectEntry.name;
const projectDir = path.join(claudeDir, projectName);
const displayName = config[projectName]?.displayName
|| await generateDisplayName(projectName);
let files;
try {
files = await fs.readdir(projectDir);
} catch {
continue;
}
const jsonlFiles = files.filter(
file => file.endsWith('.jsonl') && !file.startsWith('agent-')
);
const projectResult = {
projectName,
projectDisplayName: displayName,
sessions: []
};
for (const file of jsonlFiles) {
if (totalMatches >= safeLimit || isAborted()) break;
const filePath = path.join(projectDir, file);
const sessionMatches = new Map();
const sessionSummaries = new Map();
const pendingSummaries = new Map();
const sessionLastMessages = new Map();
let currentSessionId = null;
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (totalMatches >= safeLimit || isAborted()) break;
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.sessionId) {
currentSessionId = entry.sessionId;
}
if (entry.type === 'summary' && entry.summary) {
const sid = entry.sessionId || currentSessionId;
if (sid) {
sessionSummaries.set(sid, entry.summary);
} else if (entry.leafUuid) {
pendingSummaries.set(entry.leafUuid, entry.summary);
}
}
// Apply pending summary via parentUuid
if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) {
const pending = pendingSummaries.get(entry.parentUuid);
if (pending) sessionSummaries.set(currentSessionId, pending);
}
// Track last user/assistant message for fallback title
if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) {
const role = entry.message.role;
if (role === 'user' || role === 'assistant') {
const text = extractText(entry.message.content);
if (text && !isSystemMessage(text)) {
if (!sessionLastMessages.has(currentSessionId)) {
sessionLastMessages.set(currentSessionId, {});
}
const msgs = sessionLastMessages.get(currentSessionId);
if (role === 'user') msgs.user = text;
else msgs.assistant = text;
}
}
}
if (!entry.message?.content) continue;
if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue;
if (entry.isApiErrorMessage) continue;
const text = extractText(entry.message.content);
if (!text || isSystemMessage(text)) continue;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', '');
if (!sessionMatches.has(sessionId)) {
sessionMatches.set(sessionId, []);
}
const matches = sessionMatches.get(sessionId);
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({
role: entry.message.role,
snippet,
highlights,
timestamp: entry.timestamp || null,
provider: 'claude',
messageUuid: entry.uuid || null
});
totalMatches++;
}
}
} catch {
continue;
}
for (const [sessionId, matches] of sessionMatches) {
projectResult.sessions.push({
sessionId,
provider: 'claude',
sessionSummary: sessionSummaries.get(sessionId) || (() => {
const msgs = sessionLastMessages.get(sessionId);
const lastMsg = msgs?.user || msgs?.assistant;
return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session';
})(),
matches
});
}
}
// Search Codex sessions for this project
try {
const actualProjectDir = await extractProjectDirectory(projectName);
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
await searchCodexSessionsForProject(
actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage,
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted
);
}
} catch {
// Skip codex search errors
}
// Search Gemini sessions for this project
try {
const actualProjectDir = await extractProjectDirectory(projectName);
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
await searchGeminiSessionsForProject(
actualProjectDir, projectResult, words, allWordsMatch,
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }
);
}
} catch {
// Skip gemini search errors
}
scannedProjects++;
if (projectResult.sessions.length > 0) {
results.push(projectResult);
if (onProjectResult) {
onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects });
}
} else if (onProjectResult && scannedProjects % 10 === 0) {
onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects });
}
}
} catch {
// claudeDir doesn't exist
}
return { results, totalMatches, query: safeQuery };
}
async function searchCodexSessionsForProject(
projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage,
buildSnippet, limit, getTotalMatches, addMatches, isAborted
) {
const normalizedProjectPath = normalizeComparablePath(projectPath);
if (!normalizedProjectPath) return;
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
try {
await fs.access(codexSessionsDir);
} catch {
return;
}
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
for (const filePath of jsonlFiles) {
if (getTotalMatches() >= limit || isAborted()) break;
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
// First pass: read session_meta to check project path match
let sessionMeta = null;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'session_meta' && entry.payload) {
sessionMeta = entry.payload;
break;
}
} catch { continue; }
}
// Skip sessions that don't belong to this project
if (!sessionMeta) continue;
const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd);
if (sessionProjectPath !== normalizedProjectPath) continue;
// Second pass: re-read file to find matching messages
const fileStream2 = fsSync.createReadStream(filePath);
const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity });
let lastUserMessage = null;
const matches = [];
for await (const line of rl2) {
if (getTotalMatches() >= limit || isAborted()) break;
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
let text = null;
let role = null;
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) {
text = entry.payload.message;
role = 'user';
lastUserMessage = text;
} else if (entry.type === 'response_item' && entry.payload?.type === 'message') {
const contentParts = entry.payload.content || [];
if (entry.payload.role === 'user') {
text = contentParts
.filter(p => p.type === 'input_text' && p.text)
.map(p => p.text)
.join(' ');
role = 'user';
if (text) lastUserMessage = text;
} else if (entry.payload.role === 'assistant') {
text = contentParts
.filter(p => p.type === 'output_text' && p.text)
.map(p => p.text)
.join(' ');
role = 'assistant';
}
}
if (!text || !role) continue;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' });
addMatches(1);
}
}
if (matches.length > 0) {
projectResult.sessions.push({
sessionId: sessionMeta.id,
provider: 'codex',
sessionSummary: lastUserMessage
? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage)
: 'Codex Session',
matches
});
}
} catch {
continue;
}
}
}
async function searchGeminiSessionsForProject(
projectPath, projectResult, words, allWordsMatch,
buildSnippet, limit, getTotalMatches, addMatches
) {
// 1) Search in-memory sessions (created via UI)
for (const [sessionId, session] of sessionManager.sessions) {
if (getTotalMatches() >= limit) break;
if (session.projectPath !== projectPath) continue;
const matches = [];
for (const msg of session.messages) {
if (getTotalMatches() >= limit) break;
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const text = typeof msg.content === 'string' ? msg.content
: Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ')
: '';
if (!text) continue;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({
role: msg.role, snippet, highlights,
timestamp: msg.timestamp ? msg.timestamp.toISOString() : null,
provider: 'gemini'
});
addMatches(1);
}
}
if (matches.length > 0) {
const firstUserMsg = session.messages.find(m => m.role === 'user');
const summary = firstUserMsg?.content
? (typeof firstUserMsg.content === 'string'
? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content)
: 'Gemini Session')
: 'Gemini Session';
projectResult.sessions.push({
sessionId,
provider: 'gemini',
sessionSummary: summary,
matches
});
}
}
// 2) Search Gemini CLI sessions on disk (~/.gemini/tmp/<project>/chats/*.json)
const normalizedProjectPath = normalizeComparablePath(projectPath);
if (!normalizedProjectPath) return;
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
try {
await fs.access(geminiTmpDir);
} catch {
return;
}
const trackedSessionIds = new Set();
for (const [sid] of sessionManager.sessions) {
trackedSessionIds.add(sid);
}
let projectDirs;
try {
projectDirs = await fs.readdir(geminiTmpDir);
} catch {
return;
}
for (const projectDir of projectDirs) {
if (getTotalMatches() >= limit) break;
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
let projectRoot;
try {
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
} catch {
continue;
}
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const chatFile of chatFiles) {
if (getTotalMatches() >= limit) break;
if (!chatFile.endsWith('.json')) continue;
try {
const filePath = path.join(chatsDir, chatFile);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
if (!session.messages || !Array.isArray(session.messages)) continue;
const cliSessionId = session.sessionId || chatFile.replace('.json', '');
if (trackedSessionIds.has(cliSessionId)) continue;
const matches = [];
let firstUserText = null;
for (const msg of session.messages) {
if (getTotalMatches() >= limit) break;
const role = msg.type === 'user' ? 'user'
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
: null;
if (!role) continue;
let text = '';
if (typeof msg.content === 'string') {
text = msg.content;
} else if (Array.isArray(msg.content)) {
text = msg.content
.filter(p => p.text)
.map(p => p.text)
.join(' ');
}
if (!text) continue;
if (role === 'user' && !firstUserText) firstUserText = text;
const textLower = text.toLowerCase();
if (!allWordsMatch(textLower)) continue;
if (matches.length < 2) {
const { snippet, highlights } = buildSnippet(text, textLower);
matches.push({
role, snippet, highlights,
timestamp: msg.timestamp || null,
provider: 'gemini'
});
addMatches(1);
}
}
if (matches.length > 0) {
const summary = firstUserText
? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText)
: 'Gemini CLI Session';
projectResult.sessions.push({
sessionId: cliSessionId,
provider: 'gemini',
sessionSummary: summary,
matches
});
}
} catch {
continue;
}
}
}
}
async function getGeminiCliSessions(projectPath) {
const normalizedProjectPath = normalizeComparablePath(projectPath);
if (!normalizedProjectPath) return [];
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
try {
await fs.access(geminiTmpDir);
} catch {
return [];
}
const sessions = [];
let projectDirs;
try {
projectDirs = await fs.readdir(geminiTmpDir);
} catch {
return [];
}
for (const projectDir of projectDirs) {
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
let projectRoot;
try {
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
} catch {
continue;
}
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const chatFile of chatFiles) {
if (!chatFile.endsWith('.json')) continue;
try {
const filePath = path.join(chatsDir, chatFile);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
if (!session.messages || !Array.isArray(session.messages)) continue;
const sessionId = session.sessionId || chatFile.replace('.json', '');
const firstUserMsg = session.messages.find(m => m.type === 'user');
let summary = 'Gemini CLI Session';
if (firstUserMsg) {
const text = Array.isArray(firstUserMsg.content)
? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')
: (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');
if (text) {
summary = text.length > 50 ? text.substring(0, 50) + '...' : text;
}
}
sessions.push({
id: sessionId,
summary,
messageCount: session.messages.length,
lastActivity: session.lastUpdated || session.startTime || null,
provider: 'gemini'
});
} catch {
continue;
}
}
}
return sessions.sort((a, b) =>
new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
);
}
async function getGeminiCliSessionMessages(sessionId) {
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
let projectDirs;
try {
projectDirs = await fs.readdir(geminiTmpDir);
} catch {
return [];
}
for (const projectDir of projectDirs) {
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
let chatFiles;
try {
chatFiles = await fs.readdir(chatsDir);
} catch {
continue;
}
for (const chatFile of chatFiles) {
if (!chatFile.endsWith('.json')) continue;
try {
const filePath = path.join(chatsDir, chatFile);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
const fileSessionId = session.sessionId || chatFile.replace('.json', '');
if (fileSessionId !== sessionId) continue;
return (session.messages || []).map(msg => {
const role = msg.type === 'user' ? 'user'
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
: msg.type;
let content = '';
if (typeof msg.content === 'string') {
content = msg.content;
} else if (Array.isArray(msg.content)) {
content = msg.content.filter(p => p.text).map(p => p.text).join('\n');
}
return {
type: 'message',
message: { role, content },
timestamp: msg.timestamp || null
};
});
} catch {
continue;
}
}
}
return [];
}
export {
getProjects,
getSessions,
@@ -1878,5 +2554,8 @@ export {
clearProjectDirectoryCache,
getCodexSessions,
getCodexSessionMessages,
deleteCodexSession
deleteCodexSession,
getGeminiCliSessions,
getGeminiCliSessionMessages,
searchConversations
};

View File

@@ -1,6 +1,7 @@
import express from 'express';
import sessionManager from '../sessionManager.js';
import { sessionNamesDb } from '../database/db.js';
import { getGeminiCliSessionMessages } from '../projects.js';
const router = express.Router();
@@ -12,7 +13,12 @@ router.get('/sessions/:sessionId/messages', async (req, res) => {
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
}
const messages = sessionManager.getSessionMessages(sessionId);
let messages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (messages.length === 0) {
messages = await getGeminiCliSessionMessages(sessionId);
}
res.json({
success: true,

273
server/routes/plugins.js Normal file
View File

@@ -0,0 +1,273 @@
import express from 'express';
import path from 'path';
import http from 'http';
import mime from 'mime-types';
import fs from 'fs';
import {
scanPlugins,
getPluginsConfig,
getPluginsDir,
savePluginsConfig,
getPluginDir,
resolvePluginAssetPath,
installPluginFromGit,
updatePluginFromGit,
uninstallPlugin,
} from '../utils/plugin-loader.js';
import {
startPluginServer,
stopPluginServer,
getPluginPort,
isPluginRunning,
} from '../utils/plugin-process-manager.js';
const router = express.Router();
// GET / — List all installed plugins (includes server running status)
router.get('/', (req, res) => {
try {
const plugins = scanPlugins().map(p => ({
...p,
serverRunning: p.server ? isPluginRunning(p.name) : false,
}));
res.json({ plugins });
} catch (err) {
res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
}
});
// GET /:name/manifest — Get single plugin manifest
router.get('/:name/manifest', (req, res) => {
try {
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) {
return res.status(404).json({ error: 'Plugin not found' });
}
res.json(plugin);
} catch (err) {
res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
}
});
// GET /:name/assets/* — Serve plugin static files
router.get('/:name/assets/*', (req, res) => {
const pluginName = req.params.name;
const assetPath = req.params[0];
if (!assetPath) {
return res.status(400).json({ error: 'No asset path specified' });
}
const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
if (!resolvedPath) {
return res.status(404).json({ error: 'Asset not found' });
}
const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
fs.createReadStream(resolvedPath).pipe(res);
});
// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
router.put('/:name/enable', async (req, res) => {
try {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: '"enabled" must be a boolean' });
}
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) {
return res.status(404).json({ error: 'Plugin not found' });
}
const config = getPluginsConfig();
config[req.params.name] = { ...config[req.params.name], enabled };
savePluginsConfig(config);
// Start or stop the plugin server as needed
if (plugin.server) {
if (enabled && !isPluginRunning(plugin.name)) {
const pluginDir = getPluginDir(plugin.name);
if (pluginDir) {
try {
await startPluginServer(plugin.name, pluginDir, plugin.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
}
}
} else if (!enabled && isPluginRunning(plugin.name)) {
stopPluginServer(plugin.name);
}
}
res.json({ success: true, name: req.params.name, enabled });
} catch (err) {
res.status(500).json({ error: 'Failed to update plugin', details: err.message });
}
});
// POST /install — Install plugin from git URL
router.post('/install', async (req, res) => {
try {
const { url } = req.body;
if (!url || typeof url !== 'string') {
return res.status(400).json({ error: '"url" is required and must be a string' });
}
// Basic URL validation
if (!url.startsWith('https://') && !url.startsWith('git@')) {
return res.status(400).json({ error: 'URL must start with https:// or git@' });
}
const manifest = await installPluginFromGit(url);
// Auto-start the server if the plugin has one (enabled by default)
if (manifest.server) {
const pluginDir = getPluginDir(manifest.name);
if (pluginDir) {
try {
await startPluginServer(manifest.name, pluginDir, manifest.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
}
}
}
res.json({ success: true, plugin: manifest });
} catch (err) {
res.status(400).json({ error: 'Failed to install plugin', details: err.message });
}
});
// POST /:name/update — Pull latest from git (restarts server if running)
router.post('/:name/update', async (req, res) => {
try {
const pluginName = req.params.name;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const wasRunning = isPluginRunning(pluginName);
if (wasRunning) {
stopPluginServer(pluginName);
}
const manifest = await updatePluginFromGit(pluginName);
// Restart server if it was running before the update
if (wasRunning && manifest.server) {
const pluginDir = getPluginDir(pluginName);
if (pluginDir) {
try {
await startPluginServer(pluginName, pluginDir, manifest.server);
} catch (err) {
console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
}
}
}
res.json({ success: true, plugin: manifest });
} catch (err) {
res.status(400).json({ error: 'Failed to update plugin', details: err.message });
}
});
// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
router.all('/:name/rpc/*', async (req, res) => {
const pluginName = req.params.name;
const rpcPath = req.params[0] || '';
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
let port = getPluginPort(pluginName);
if (!port) {
// Lazily start the plugin server if it exists and is enabled
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === pluginName);
if (!plugin || !plugin.server) {
return res.status(503).json({ error: 'Plugin server is not running' });
}
if (!plugin.enabled) {
return res.status(503).json({ error: 'Plugin is disabled' });
}
const pluginDir = path.join(getPluginsDir(), plugin.dirName);
try {
port = await startPluginServer(pluginName, pluginDir, plugin.server);
} catch (err) {
return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
}
}
// Inject configured secrets as headers
const config = getPluginsConfig();
const pluginConfig = config[pluginName] || {};
const secrets = pluginConfig.secrets || {};
const headers = {
'content-type': req.headers['content-type'] || 'application/json',
};
// Add per-plugin secrets as X-Plugin-Secret-* headers
for (const [key, value] of Object.entries(secrets)) {
headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
}
// Reconstruct query string
const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
const options = {
hostname: '127.0.0.1',
port,
path: `/${rpcPath}${qs}`,
method: req.method,
headers,
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
res.status(502).json({ error: 'Plugin server error', details: err.message });
});
// Forward body (already parsed by express JSON middleware, so re-stringify)
if (req.body && Object.keys(req.body).length > 0) {
const bodyStr = JSON.stringify(req.body);
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
proxyReq.write(bodyStr);
}
proxyReq.end();
});
// DELETE /:name — Uninstall plugin (stops server first)
router.delete('/:name', async (req, res) => {
try {
const pluginName = req.params.name;
// Validate name format to prevent path traversal
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
// Stop server and wait for the process to fully exit before deleting files
if (isPluginRunning(pluginName)) {
await stopPluginServer(pluginName);
}
await uninstallPlugin(pluginName);
res.json({ success: true, name: pluginName });
} catch (err) {
res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
}
});
export default router;

View File

@@ -0,0 +1,356 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { spawn } from 'child_process';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
const ALLOWED_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab'];
export function getPluginsDir() {
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
return PLUGINS_DIR;
}
export function getPluginsConfig() {
try {
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
}
} catch {
// Corrupted config, start fresh
}
return {};
}
export function savePluginsConfig(config) {
const dir = path.dirname(PLUGINS_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2));
}
export function validateManifest(manifest) {
if (!manifest || typeof manifest !== 'object') {
return { valid: false, error: 'Manifest must be a JSON object' };
}
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!manifest[field] || typeof manifest[field] !== 'string') {
return { valid: false, error: `Missing or invalid required field: ${field}` };
}
}
// Sanitize name — only allow alphanumeric, hyphens, underscores
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
}
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
}
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
}
return { valid: true };
}
export function scanPlugins() {
const pluginsDir = getPluginsDir();
const config = getPluginsConfig();
const plugins = [];
let entries;
try {
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
} catch {
return plugins;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const validation = validateManifest(manifest);
if (!validation.valid) {
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
continue;
}
// Try to read git remote URL
let repoUrl = null;
try {
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
if (fs.existsSync(gitConfigPath)) {
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
const match = gitConfig.match(/url\s*=\s*(.+)/);
if (match) {
repoUrl = match[1].trim().replace(/\.git$/, '');
// Convert SSH URLs to HTTPS
if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
}
}
}
} catch { /* ignore */ }
plugins.push({
name: manifest.name,
displayName: manifest.displayName,
version: manifest.version || '0.0.0',
description: manifest.description || '',
author: manifest.author || '',
icon: manifest.icon || 'Puzzle',
type: manifest.type || 'module',
slot: manifest.slot || 'tab',
entry: manifest.entry,
server: manifest.server || null,
permissions: manifest.permissions || [],
enabled: config[manifest.name]?.enabled !== false, // enabled by default
dirName: entry.name,
repoUrl,
});
} catch (err) {
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
}
}
return plugins;
}
export function getPluginDir(name) {
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === name);
if (!plugin) return null;
return path.join(getPluginsDir(), plugin.dirName);
}
export function resolvePluginAssetPath(name, assetPath) {
const pluginDir = getPluginDir(name);
if (!pluginDir) return null;
const resolved = path.resolve(pluginDir, assetPath);
// Prevent path traversal — resolved path must be within plugin directory
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
return null;
}
if (!fs.existsSync(resolved)) return null;
return resolved;
}
export function installPluginFromGit(url) {
return new Promise((resolve, reject) => {
if (typeof url !== 'string' || !url.trim()) {
return reject(new Error('Invalid URL: must be a non-empty string'));
}
if (url.startsWith('-')) {
return reject(new Error('Invalid URL: must not start with "-"'));
}
// Extract repo name from URL for directory name
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
const repoName = urlClean.split('/').pop();
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
return reject(new Error('Could not determine a valid directory name from the URL'));
}
const pluginsDir = getPluginsDir();
const targetDir = path.resolve(pluginsDir, repoName);
// Ensure the resolved target directory stays within the plugins directory
if (!targetDir.startsWith(pluginsDir + path.sep)) {
return reject(new Error('Invalid plugin directory path'));
}
if (fs.existsSync(targetDir)) {
return reject(new Error(`Plugin directory "${repoName}" already exists`));
}
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
const cleanupTemp = () => {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
};
const finalize = (manifest) => {
try {
fs.renameSync(tempDir, targetDir);
} catch (err) {
cleanupTemp();
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
}
resolve(manifest);
};
const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
gitProcess.on('close', (code) => {
if (code !== 0) {
cleanupTemp();
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
}
// Validate manifest exists
const manifestPath = path.join(tempDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
cleanupTemp();
return reject(new Error('Cloned repository does not contain a manifest.json'));
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
cleanupTemp();
return reject(new Error('manifest.json is not valid JSON'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
cleanupTemp();
return reject(new Error(`Invalid manifest: ${validation.error}`));
}
// Run npm install if package.json exists.
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
const packageJsonPath = path.join(tempDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: tempDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
cleanupTemp();
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
}
finalize(manifest);
});
npmProcess.on('error', (err) => {
cleanupTemp();
reject(err);
});
} else {
finalize(manifest);
}
});
gitProcess.on('error', (err) => {
cleanupTemp();
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
}
export function updatePluginFromGit(name) {
return new Promise((resolve, reject) => {
const pluginDir = getPluginDir(name);
if (!pluginDir) {
return reject(new Error(`Plugin "${name}" not found`));
}
// Only fast-forward to avoid silent divergence
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
gitProcess.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
}
// Re-validate manifest after update
const manifestPath = path.join(pluginDir, 'manifest.json');
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return reject(new Error('manifest.json is not valid JSON after update'));
}
const validation = validateManifest(manifest);
if (!validation.valid) {
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
}
// Re-run npm install if package.json exists
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
cwd: pluginDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
npmProcess.on('close', (npmCode) => {
if (npmCode !== 0) {
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
}
resolve(manifest);
});
npmProcess.on('error', (err) => reject(err));
} else {
resolve(manifest);
}
});
gitProcess.on('error', (err) => {
reject(new Error(`Failed to spawn git: ${err.message}`));
});
});
}
export async function uninstallPlugin(name) {
const pluginDir = getPluginDir(name);
if (!pluginDir) {
throw new Error(`Plugin "${name}" not found`);
}
// On Windows, file handles may be released slightly after process exit.
// Retry a few times with a short delay before giving up.
const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 500;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
fs.rmSync(pluginDir, { recursive: true, force: true });
break;
} catch (err) {
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
} else {
throw err;
}
}
}
// Remove from config
const config = getPluginsConfig();
delete config[name];
savePluginsConfig(config);
}

View File

@@ -0,0 +1,171 @@
import { spawn } from 'child_process';
import path from 'path';
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
// Map<pluginName, { process, port }>
const runningPlugins = new Map();
/**
* Start a plugin's server subprocess.
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
* to stdout within 10 seconds.
*/
export function startPluginServer(name, pluginDir, serverEntry) {
return new Promise((resolve, reject) => {
if (runningPlugins.has(name)) {
return resolve(runningPlugins.get(name).port);
}
const serverPath = path.join(pluginDir, serverEntry);
// Restricted env — only essentials, no host secrets
const pluginProcess = spawn('node', [serverPath], {
cwd: pluginDir,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let resolved = false;
let stdout = '';
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
pluginProcess.kill();
reject(new Error('Plugin server did not report ready within 10 seconds'));
}
}, 10000);
pluginProcess.stdout.on('data', (data) => {
if (resolved) return;
stdout += data.toString();
// Look for the JSON ready line
const lines = stdout.split('\n');
for (const line of lines) {
try {
const msg = JSON.parse(line.trim());
if (msg.ready && typeof msg.port === 'number') {
clearTimeout(timeout);
resolved = true;
runningPlugins.set(name, { process: pluginProcess, port: msg.port });
pluginProcess.on('exit', () => {
runningPlugins.delete(name);
});
console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
resolve(msg.port);
}
} catch {
// Not JSON yet, keep buffering
}
}
});
pluginProcess.stderr.on('data', (data) => {
console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
});
pluginProcess.on('error', (err) => {
clearTimeout(timeout);
if (!resolved) {
resolved = true;
reject(new Error(`Failed to start plugin server: ${err.message}`));
}
});
pluginProcess.on('exit', (code) => {
clearTimeout(timeout);
runningPlugins.delete(name);
if (!resolved) {
resolved = true;
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
}
});
});
}
/**
* Stop a plugin's server subprocess.
* Returns a Promise that resolves when the process has fully exited.
*/
export function stopPluginServer(name) {
const entry = runningPlugins.get(name);
if (!entry) return Promise.resolve();
return new Promise((resolve) => {
const cleanup = () => {
clearTimeout(forceKillTimer);
runningPlugins.delete(name);
resolve();
};
entry.process.once('exit', cleanup);
entry.process.kill('SIGTERM');
// Force kill after 5 seconds if still running
const forceKillTimer = setTimeout(() => {
if (runningPlugins.has(name)) {
entry.process.kill('SIGKILL');
cleanup();
}
}, 5000);
console.log(`[Plugins] Server stopped for "${name}"`);
});
}
/**
* Get the port a running plugin server is listening on.
*/
export function getPluginPort(name) {
return runningPlugins.get(name)?.port ?? null;
}
/**
* Check if a plugin's server is running.
*/
export function isPluginRunning(name) {
return runningPlugins.has(name);
}
/**
* Stop all running plugin servers (called on host shutdown).
*/
export function stopAllPlugins() {
const stops = [];
for (const [name] of runningPlugins) {
stops.push(stopPluginServer(name));
}
return Promise.all(stops);
}
/**
* Start servers for all enabled plugins that have a server entry.
* Called once on host server boot.
*/
export async function startEnabledPluginServers() {
const plugins = scanPlugins();
const config = getPluginsConfig();
for (const plugin of plugins) {
if (!plugin.server) continue;
if (config[plugin.name]?.enabled === false) continue;
const pluginDir = getPluginDir(plugin.name);
if (!pluginDir) continue;
try {
await startPluginServer(plugin.name, pluginDir, plugin.server);
} catch (err) {
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
}
}
}

View File

@@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -14,8 +15,9 @@ export default function App() {
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<PluginsProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
@@ -24,8 +26,9 @@ export default function App() {
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</TaskMasterProvider>
</TasksSettingsProvider>
</PluginsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -1,8 +1,13 @@
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
@@ -12,38 +17,38 @@ type MobileNavProps = {
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef(null);
const navItems = [
{
id: 'chat',
icon: MessageSquare,
label: 'Chat',
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
label: 'Shell',
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
label: 'Files',
onClick: () => setActiveTab('files')
},
{
id: 'git',
icon: GitBranch,
label: 'Git',
onClick: () => setActiveTab('git')
},
...(shouldShowTasksTab ? [{
id: 'tasks',
icon: ClipboardCheck,
label: 'Tasks',
onClick: () => setActiveTab('tasks')
}] : [])
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
const isPluginActive = activeTab.startsWith('plugin:');
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e) => {
if (moreRef.current && !moreRef.current.contains(e.target)) {
setMoreOpen(false);
}
};
document.addEventListener('pointerdown', handleTap);
return () => document.removeEventListener('pointerdown', handleTap);
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name) => {
setActiveTab(`plugin:${name}`);
setMoreOpen(false);
};
const coreItems = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
];
return (
@@ -53,17 +58,17 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
>
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
{navItems.map((item) => {
{coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onClick={() => setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
setActiveTab(item.id);
}}
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
? 'text-primary'
@@ -85,6 +90,62 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
</button>
);
})}
{/* "More" button — only shown when there are enabled plugins */}
{hasPlugins && (
<div ref={moreRef} className="relative flex-1">
<button
onClick={() => setMoreOpen((v) => !v)}
onTouchStart={(e) => {
e.preventDefault();
setMoreOpen((v) => !v);
}}
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${
isPluginActive || moreOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label="More plugins"
aria-expanded={moreOpen}
>
{(isPluginActive && !moreOpen) && (
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)}
<Ellipsis
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isPluginActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
More
</span>
</button>
{/* Popover menu */}
{moreOpen && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
{enabledPlugins.map((p) => {
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
const isActive = activeTab === `plugin:${p.name}`;
return (
<button
key={p.name}
onClick={() => selectPlugin(p.name)}
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${
isActive
? 'bg-primary/8 text-primary'
: 'text-foreground hover:bg-muted/60'
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="truncate">{p.displayName}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -82,6 +82,8 @@ export function useChatSessionState({
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
const searchScrollActiveRef = useRef(false);
const isLoadingSessionRef = useRef(false);
const isLoadingMoreRef = useRef(false);
const allMessagesLoadedRef = useRef(false);
@@ -301,12 +303,14 @@ export function useChatSessionState({
const isInitialLoadRef = useRef(true);
useEffect(() => {
pendingInitialScrollRef.current = true;
if (!searchScrollActiveRef.current) {
pendingInitialScrollRef.current = true;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
}
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
prevSessionMessagesLengthRef.current = 0;
isInitialLoadRef.current = true;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
setIsUserScrolledUp(false);
}, [selectedProject?.name, selectedSession?.id]);
@@ -321,9 +325,11 @@ export function useChatSessionState({
}
pendingInitialScrollRef.current = false;
setTimeout(() => {
scrollToBottom();
}, 200);
if (!searchScrollActiveRef.current) {
setTimeout(() => {
scrollToBottom();
}, 200);
}
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
useEffect(() => {
@@ -502,13 +508,28 @@ export function useChatSessionState({
selectedSession,
]);
// Detect search navigation target from selectedSession object reference change
// This must be a separate effect because the loading effect depends on selectedSession?.id
// which doesn't change when clicking a search result for the already-loaded session
useEffect(() => {
const session = selectedSession as Record<string, unknown> | null;
const targetSnippet = session?.__searchTargetSnippet;
const targetTimestamp = session?.__searchTargetTimestamp;
if (typeof targetSnippet === 'string' && targetSnippet) {
searchScrollActiveRef.current = true;
setSearchTarget({
snippet: targetSnippet,
timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined,
});
}
}, [selectedSession]);
useEffect(() => {
if (selectedSession?.id) {
pendingViewSessionRef.current = null;
}
}, [pendingViewSessionRef, selectedSession?.id]);
useEffect(() => {
// Only sync sessionMessages to chatMessages when:
// 1. Not currently loading (to avoid overwriting user's just-sent message)
@@ -533,6 +554,110 @@ export function useChatSessionState({
}
}, [chatMessages, selectedProject]);
// Scroll to search target message after messages are loaded
useEffect(() => {
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
const target = searchTarget;
// Clear immediately to prevent re-triggering
setSearchTarget(null);
const scrollToTarget = async () => {
// Always load all messages when navigating from search
// (hasMoreMessages may not be set yet due to race with loading effect)
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'cursor') {
try {
const response = await (api.sessionMessages as any)(
selectedProject.name,
selectedSession.id,
null,
0,
sessionProvider,
);
if (response.ok) {
const data = await response.json();
const allMessages = data.messages || data;
setSessionMessages(Array.isArray(allMessages) ? allMessages : []);
setHasMoreMessages(false);
setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0);
messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0;
setVisibleMessageCount(Infinity);
setAllMessagesLoaded(true);
allMessagesLoadedRef.current = true;
// Wait for messages to render after state update
await new Promise(resolve => setTimeout(resolve, 300));
}
} catch {
// Fall through and scroll in current messages
}
}
}
setVisibleMessageCount(Infinity);
// Retry finding the element in the DOM until React finishes rendering all messages
const findAndScroll = (retriesLeft: number) => {
const container = scrollContainerRef.current;
if (!container) return;
let targetElement: Element | null = null;
// Match by snippet text content (most reliable)
if (target.snippet) {
const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim();
// Use a contiguous substring from the snippet (don't filter words, it breaks matching)
const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim();
if (searchPhrase.length >= 10) {
const messageElements = container.querySelectorAll('.chat-message');
for (const el of messageElements) {
const text = (el.textContent || '').toLowerCase();
if (text.includes(searchPhrase)) {
targetElement = el;
break;
}
}
}
}
// Fallback to timestamp matching
if (!targetElement && target.timestamp) {
const targetDate = new Date(target.timestamp).getTime();
const messageElements = container.querySelectorAll('[data-message-timestamp]');
let closestDiff = Infinity;
for (const el of messageElements) {
const ts = el.getAttribute('data-message-timestamp');
if (!ts) continue;
const diff = Math.abs(new Date(ts).getTime() - targetDate);
if (diff < closestDiff) {
closestDiff = diff;
targetElement = el;
}
}
}
if (targetElement) {
targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
targetElement.classList.add('search-highlight-flash');
setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000);
searchScrollActiveRef.current = false;
} else if (retriesLeft > 0) {
setTimeout(() => findAndScroll(retriesLeft - 1), 200);
} else {
searchScrollActiveRef.current = false;
}
};
// Start polling after a short delay to let React begin rendering
setTimeout(() => findAndScroll(15), 150);
};
scrollToTarget();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
setTokenBudget(null);
@@ -588,6 +713,10 @@ export function useChatSessionState({
return;
}
if (searchScrollActiveRef.current) {
return;
}
if (autoScrollToBottom) {
if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 50);

View File

@@ -61,20 +61,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
isSubagentContainer,
subagentState
}) => {
// Route subagent containers to dedicated component
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result;
@@ -94,7 +80,20 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}, [displayConfig, parsedData, onFileOpen]);
// Keep hooks above this guard so hook call order stays stable across renders.
// Route subagent containers to dedicated component (after hooks to keep call order stable)
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
if (!displayConfig) return null;
if (displayConfig.type === 'one-line') {

View File

@@ -96,6 +96,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
return (
<div
ref={messageRef}
data-message-timestamp={message.timestamp || undefined}
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
>
{message.type === 'user' ? (

View File

@@ -3,6 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
@@ -158,6 +159,16 @@ function MainContent({
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
{activeTab.startsWith('plugin:') && (
<div className="h-full overflow-hidden">
<PluginTabContent
pluginName={activeTab.replace('plugin:', '')}
selectedProject={selectedProject}
selectedSession={selectedSession}
/>
</div>
)}
</div>
<EditorSidebar

View File

@@ -3,6 +3,8 @@ import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/PluginIcon';
type MainContentTabSwitcherProps = {
activeTab: AppTab;
@@ -10,20 +12,32 @@ type MainContentTabSwitcherProps = {
shouldShowTasksTab: boolean;
};
type TabDefinition = {
type BuiltInTab = {
kind: 'builtin';
id: AppTab;
labelKey: string;
icon: LucideIcon;
};
const BASE_TABS: TabDefinition[] = [
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
type PluginTab = {
kind: 'plugin';
id: AppTab;
label: string;
pluginName: string;
iconFile: string;
};
type TabDefinition = BuiltInTab | PluginTab;
const BASE_TABS: BuiltInTab[] = [
{ kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
];
const TASKS_TAB: TabDefinition = {
const TASKS_TAB: BuiltInTab = {
kind: 'builtin',
id: 'tasks',
labelKey: 'tabs.tasks',
icon: ClipboardCheck,
@@ -35,17 +49,30 @@ export default function MainContentTabSwitcher({
shouldShowTasksTab,
}: MainContentTabSwitcherProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
const pluginTabs: PluginTab[] = plugins
.filter((p) => p.enabled)
.map((p) => ({
kind: 'plugin',
id: `plugin:${p.name}` as AppTab,
label: p.displayName,
pluginName: p.name,
iconFile: p.icon,
}));
const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs];
return (
<div className="inline-flex items-center gap-[2px] rounded-lg bg-muted/60 p-[3px]">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.id === activeTab;
const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;
return (
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
<Tooltip key={tab.id} content={displayLabel} position="bottom">
<button
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-[5px] text-sm font-medium transition-all duration-150 ${
@@ -54,8 +81,16 @@ export default function MainContentTabSwitcher({
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
{tab.kind === 'builtin' ? (
<tab.icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
) : (
<PluginIcon
pluginName={tab.pluginName}
iconFile={tab.iconFile}
className="flex h-3.5 w-3.5 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
/>
)}
<span className="hidden lg:inline">{displayLabel}</span>
</button>
</Tooltip>
);

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
type MainContentTitleProps = {
activeTab: AppTab;
@@ -9,7 +10,11 @@ type MainContentTitleProps = {
shouldShowTasksTab: boolean;
};
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {
if (activeTab.startsWith('plugin:') && pluginDisplayName) {
return pluginDisplayName;
}
if (activeTab === 'files') {
return t('mainContent.projectFiles');
}
@@ -40,6 +45,11 @@ export default function MainContentTitle({
shouldShowTasksTab,
}: MainContentTitleProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
const pluginDisplayName = activeTab.startsWith('plugin:')
? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName
: undefined;
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
const showChatNewSession = activeTab === 'chat' && !selectedSession;
@@ -68,7 +78,7 @@ export default function MainContentTitle({
) : (
<div className="min-w-0">
<h2 className="text-sm font-semibold leading-tight text-foreground">
{getTabTitle(activeTab, shouldShowTasksTab, t)}
{getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
</h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
</div>

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { authenticatedFetch } from '../../utils/api';
type Props = {
pluginName: string;
iconFile: string;
className?: string;
};
// Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>();
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`;
const [svg, setSvg] = useState<string | null>(svgCache.get(url) ?? null);
useEffect(() => {
if (svgCache.has(url)) return;
authenticatedFetch(url)
.then((r) => r.text())
.then((text) => {
if (text.trimStart().startsWith('<svg')) {
svgCache.set(url, text);
setSvg(text);
}
})
.catch(() => {});
}, [url]);
if (!svg) return <span className={className} />;
return (
<span
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -0,0 +1,441 @@
import { useState } from 'react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Plugin } from '../../contexts/PluginsContext';
import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="relative inline-flex cursor-pointer select-none items-center">
<input
type="checkbox"
className="peer sr-only"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<div
className={`
relative h-5 w-9 rounded-full bg-muted transition-colors
duration-200 after:absolute
after:left-[2px] after:top-[2px] after:h-4 after:w-4
after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200
after:content-[''] peer-checked:bg-emerald-500
peer-checked:after:translate-x-4
`}
/>
</label>
);
}
/* ─── Server Dot ────────────────────────────────────────────────────────── */
function ServerDot({ running }: { running: boolean }) {
if (!running) return null;
return (
<span className="relative flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
running
</span>
</span>
);
}
/* ─── Plugin Card ───────────────────────────────────────────────────────── */
type PluginCardProps = {
plugin: Plugin;
index: number;
onToggle: (enabled: boolean) => void;
onUpdate: () => void;
onUninstall: () => void;
updating: boolean;
confirmingUninstall: boolean;
onCancelUninstall: () => void;
updateError: string | null;
};
function PluginCard({
plugin,
index,
onToggle,
onUpdate,
onUninstall,
updating,
confirmingUninstall,
onCancelUninstall,
updateError,
}: PluginCardProps) {
const accentColor = plugin.enabled
? 'bg-emerald-500'
: 'bg-muted-foreground/20';
return (
<div
className="relative flex overflow-hidden rounded-lg border border-border bg-card transition-opacity duration-200"
style={{
opacity: plugin.enabled ? 1 : 0.65,
animationDelay: `${index * 40}ms`,
}}
>
{/* Left accent bar */}
<div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />
<div className="min-w-0 flex-1 p-4">
{/* Header row */}
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-foreground/80">
<PluginIcon
pluginName={plugin.name}
iconFile={plugin.icon}
className="h-5 w-5 [&>svg]:h-full [&>svg]:w-full"
/>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{plugin.displayName}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
v{plugin.version}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{plugin.slot}
</span>
<ServerDot running={!!plugin.serverRunning} />
</div>
{plugin.description && (
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{plugin.description}
</p>
)}
<div className="mt-1 flex items-center gap-3">
{plugin.author && (
<span className="text-xs text-muted-foreground/60">
{plugin.author}
</span>
)}
{plugin.repoUrl && (
<a
href={plugin.repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
<span className="max-w-[200px] truncate">
{plugin.repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '')}
</span>
</a>
)}
</div>
</div>
</div>
{/* Controls */}
<div className="flex flex-shrink-0 items-center gap-2">
<button
onClick={onUpdate}
disabled={updating}
title="Pull latest from git"
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{updating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</button>
<button
onClick={onUninstall}
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
className={`rounded p-1.5 transition-colors ${
confirmingUninstall
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
: 'text-muted-foreground hover:bg-muted hover:text-red-500'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
</div>
</div>
{/* Confirm uninstall banner */}
{confirmingUninstall && (
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
<span className="text-sm text-red-600 dark:text-red-400">
Remove <span className="font-semibold">{plugin.displayName}</span>? This cannot be undone.
</span>
<div className="flex gap-1.5">
<button
onClick={onCancelUninstall}
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Cancel
</button>
<button
onClick={onUninstall}
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
>
Remove
</button>
</div>
</div>
)}
{/* Update error */}
{updateError && (
<div className="mt-2 flex items-center gap-1.5 text-sm text-red-500">
<ServerCrash className="h-3.5 w-3.5 flex-shrink-0" />
<span>{updateError}</span>
</div>
)}
</div>
</div>
);
}
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
return (
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<BarChart3 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
Project Stats
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
starter
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
tab
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
File counts, lines of code, file-type breakdown, and recent activity for your project.
</p>
<a
href={STARTER_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-starter
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? 'Installing…' : 'Install'}
</button>
</div>
</div>
</div>
);
}
/* ─── Main Component ────────────────────────────────────────────────────── */
export default function PluginSettingsTab() {
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
usePlugins();
const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false);
const [installingStarter, setInstallingStarter] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null);
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
const handleUpdate = async (name: string) => {
setUpdatingPlugin(name);
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
const result = await updatePlugin(name);
if (!result.success) {
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' }));
}
setUpdatingPlugin(null);
};
const handleInstall = async () => {
if (!gitUrl.trim()) return;
setInstalling(true);
setInstallError(null);
const result = await installPlugin(gitUrl.trim());
if (result.success) {
setGitUrl('');
} else {
setInstallError(result.error || 'Installation failed');
}
setInstalling(false);
};
const handleInstallStarter = async () => {
setInstallingStarter(true);
setInstallError(null);
const result = await installPlugin(STARTER_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || 'Installation failed');
}
setInstallingStarter(false);
};
const handleUninstall = async (name: string) => {
if (confirmUninstall !== name) {
setConfirmUninstall(name);
return;
}
await uninstallPlugin(name);
setConfirmUninstall(null);
};
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="mb-1 text-base font-semibold text-foreground">
Plugins
</h3>
<p className="text-sm text-muted-foreground">
Extend the interface with custom plugins. Install from{' '}
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
git
</code>{' '}
or drop a folder in{' '}
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
~/.claude-code-ui/plugins/
</code>
</p>
</div>
{/* Install from Git — compact */}
<div className="flex items-center gap-0 overflow-hidden rounded-lg border border-border bg-card">
<span className="flex-shrink-0 pl-3 pr-1 text-muted-foreground/40">
<GitBranch className="h-3.5 w-3.5" />
</span>
<input
type="text"
value={gitUrl}
onChange={(e) => {
setGitUrl(e.target.value);
setInstallError(null);
}}
placeholder="https://github.com/user/my-plugin"
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall();
}}
/>
<button
onClick={handleInstall}
disabled={installing || !gitUrl.trim()}
className="flex-shrink-0 border-l border-border bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-30"
>
{installing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Install'
)}
</button>
</div>
{installError && (
<p className="-mt-4 text-sm text-red-500">{installError}</p>
)}
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
<span>
Only install plugins whose source code you have reviewed or from authors you trust.
</span>
</p>
{/* Starter plugin suggestion — above the list */}
{!loading && !hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{/* Plugin List */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Scanning plugins
</div>
) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No plugins installed</p>
) : (
<div className="space-y-2">
{plugins.map((plugin, index) => (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugin === plugin.name}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
))}
</div>
)}
{/* Build your own */}
<div className="flex items-center justify-between gap-4 border-t border-border/50 pt-2">
<div className="flex min-w-0 items-center gap-2">
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
<span className="text-xs text-muted-foreground/60">
Build your own plugin
</span>
</div>
<div className="flex flex-shrink-0 items-center gap-3">
<a
href={STARTER_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
Starter <ExternalLink className="h-2.5 w-2.5" />
</a>
<span className="text-muted-foreground/20">·</span>
<a
href="https://cloudcli.ai/docs/plugin-overview"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
Docs <ExternalLink className="h-2.5 w-2.5" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { authenticatedFetch } from '../../utils/api';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../types/app';
type PluginTabContentProps = {
pluginName: string;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
};
type PluginContext = {
theme: 'dark' | 'light';
project: { name: string; path: string } | null;
session: { id: string; title: string } | null;
};
function buildContext(
isDarkMode: boolean,
selectedProject: Project | null,
selectedSession: ProjectSession | null,
): PluginContext {
return {
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
: null,
session: selectedSession
? { id: selectedSession.id, title: selectedSession.title }
: null,
};
}
export default function PluginTabContent({
pluginName,
selectedProject,
selectedSession,
}: PluginTabContentProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { isDarkMode } = useTheme();
const { plugins } = usePlugins();
// Stable refs so effects don't need context values in their dep arrays
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
const moduleRef = useRef<any>(null);
const plugin = plugins.find(p => p.name === pluginName);
// Keep contextRef current and notify the mounted plugin on every context change
useEffect(() => {
const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
contextRef.current = ctx;
for (const cb of contextCallbacksRef.current) {
try { cb(ctx); } catch { /* plugin error — ignore */ }
}
}, [isDarkMode, selectedProject, selectedSession]);
useEffect(() => {
if (!containerRef.current) return;
let active = true;
const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js';
(async () => {
try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text();
const blob = new Blob([jsText], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// @vite-ignore
const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
if (!active || !containerRef.current) return;
moduleRef.current = mod;
const api = {
get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacksRef.current.add(cb);
return () => contextCallbacksRef.current.delete(cb);
},
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
const cleanPath = String(path).replace(/^\//, '');
const res = await authenticatedFetch(
`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
{
method: method || 'GET',
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
},
);
if (!res.ok) throw new Error(`RPC error ${res.status}`);
return res.json();
},
};
await mod.mount?.(container, api);
} catch (err) {
if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
}
}
})();
return () => {
active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacksRef.current.clear();
moduleRef.current = null;
};
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
}

View File

@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';

View File

@@ -10,6 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import type { SettingsProps } from '../types/types';
@@ -165,6 +166,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
<CredentialsSettingsTab />
</div>
)}
{activeTab === 'plugins' && (
<div className="space-y-6 md:space-y-8">
<PluginSettingsTab />
</div>
)}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { GitBranch, Key } from 'lucide-react';
import { GitBranch, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types';
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
type MainTabConfig = {
id: SettingsMainTab;
labelKey: string;
labelKey?: string;
label?: string;
icon?: typeof GitBranch;
};
@@ -19,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -44,7 +46,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
}`}
>
{Icon && <Icon className="mr-2 inline h-4 w-4" />}
{t(tab.labelKey)}
{tab.labelKey ? t(tab.labelKey) : tab.label}
</button>
);
})}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type React from 'react';
import type { TFunction } from 'i18next';
import { api } from '../../../utils/api';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
@@ -19,6 +20,44 @@ import {
sortProjects,
} from '../utils/utils';
type SnippetHighlight = {
start: number;
end: number;
};
type ConversationMatch = {
role: string;
snippet: string;
highlights: SnippetHighlight[];
timestamp: string | null;
provider?: string;
messageUuid?: string | null;
};
type ConversationSession = {
sessionId: string;
sessionSummary: string;
provider?: string;
matches: ConversationMatch[];
};
type ConversationProjectResult = {
projectName: string;
projectDisplayName: string;
sessions: ConversationSession[];
};
export type ConversationSearchResults = {
results: ConversationProjectResult[];
totalMatches: number;
query: string;
};
export type SearchProgress = {
scannedProjects: number;
totalProjects: number;
};
type UseSidebarControllerArgs = {
projects: Project[];
selectedProject: Project | null;
@@ -71,6 +110,13 @@ export function useSidebarController({
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
const [showVersionModal, setShowVersionModal] = useState(false);
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchSeqRef = useRef(0);
const eventSourceRef = useRef<EventSource | null>(null);
const isSidebarCollapsed = !isMobile && !sidebarVisible;
@@ -140,6 +186,116 @@ export function useSidebarController({
};
}, []);
// Debounced conversation search with SSE streaming
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
const query = searchFilter.trim();
if (searchMode !== 'conversations' || query.length < 2) {
searchSeqRef.current += 1;
setConversationResults(null);
setSearchProgress(null);
setIsSearching(false);
return;
}
setIsSearching(true);
const seq = ++searchSeqRef.current;
searchTimeoutRef.current = setTimeout(() => {
if (seq !== searchSeqRef.current) return;
const url = api.searchConversationsUrl(query);
const es = new EventSource(url);
eventSourceRef.current = es;
const accumulated: ConversationProjectResult[] = [];
let totalMatches = 0;
es.addEventListener('result', (evt) => {
if (seq !== searchSeqRef.current) { es.close(); return; }
try {
const data = JSON.parse(evt.data) as {
projectResult: ConversationProjectResult;
totalMatches: number;
scannedProjects: number;
totalProjects: number;
};
accumulated.push(data.projectResult);
totalMatches = data.totalMatches;
setConversationResults({ results: [...accumulated], totalMatches, query });
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
} catch {
// Ignore malformed SSE data
}
});
es.addEventListener('progress', (evt) => {
if (seq !== searchSeqRef.current) { es.close(); return; }
try {
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
totalMatches = data.totalMatches;
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
} catch {
// Ignore malformed SSE data
}
});
es.addEventListener('done', () => {
if (seq !== searchSeqRef.current) { es.close(); return; }
es.close();
eventSourceRef.current = null;
setIsSearching(false);
setSearchProgress(null);
if (accumulated.length === 0) {
setConversationResults({ results: [], totalMatches: 0, query });
}
});
es.addEventListener('error', () => {
if (seq !== searchSeqRef.current) { es.close(); return; }
es.close();
eventSourceRef.current = null;
setIsSearching(false);
setSearchProgress(null);
if (accumulated.length === 0) {
setConversationResults({ results: [], totalMatches: 0, query });
}
});
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [searchFilter, searchMode]);
const handleTouchClick = useCallback(
(callback: () => void) =>
(event: React.TouchEvent<HTMLElement>) => {
const target = event.target as HTMLElement;
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
return;
}
event.preventDefault();
event.stopPropagation();
callback();
},
[],
);
const toggleProject = useCallback((projectName: string) => {
setExpandedProjects((prev) => {
const next = new Set<string>();
@@ -466,6 +622,21 @@ export function useSidebarController({
setEditingName,
setEditingSession,
setEditingSessionName,
searchMode,
setSearchMode,
conversationResults,
isSearching,
searchProgress,
clearConversationResults: useCallback(() => {
searchSeqRef.current += 1;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsSearching(false);
setSearchProgress(null);
setConversationResults(null);
}, []),
setSearchFilter,
setDeleteConfirmation,
setSessionDeleteConfirmation,

View File

@@ -60,6 +60,12 @@ function Sidebar({
editingSession,
editingSessionName,
searchFilter,
searchMode,
setSearchMode,
conversationResults,
isSearching,
searchProgress,
clearConversationResults,
deletingProjects,
deleteConfirmation,
sessionDeleteConfirmation,
@@ -220,6 +226,37 @@ function Sidebar({
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
onClearSearchFilter={() => setSearchFilter('')}
searchMode={searchMode}
onSearchModeChange={(mode: 'projects' | 'conversations') => {
setSearchMode(mode);
if (mode === 'projects') clearConversationResults();
}}
conversationResults={conversationResults}
isSearching={isSearching}
searchProgress={searchProgress}
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
const resolvedProvider = (provider || 'claude') as SessionProvider;
const project = projects.find(p => p.name === projectName);
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
const sessionObj = {
id: sessionId,
__provider: resolvedProvider,
__projectName: projectName,
...searchTarget,
};
if (project) {
handleProjectSelect(project);
const sessions = getProjectSessions(project);
const existing = sessions.find(s => s.id === sessionId);
if (existing) {
handleSessionClick({ ...existing, ...searchTarget }, projectName);
} else {
handleSessionClick(sessionObj, projectName);
}
} else {
handleSessionClick(sessionObj, projectName);
}
}}
onRefresh={() => {
void refreshProjects();
}}

View File

@@ -1,11 +1,40 @@
import { type ReactNode } from 'react';
import { Folder, MessageSquare, Search } from 'lucide-react';
import type { TFunction } from 'i18next';
import { ScrollArea } from '../../../../shared/view/ui';
import type { Project } from '../../../../types/app';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
import SidebarFooter from './SidebarFooter';
import SidebarHeader from './SidebarHeader';
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
type SearchMode = 'projects' | 'conversations';
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
const parts: ReactNode[] = [];
let cursor = 0;
for (const h of highlights) {
if (h.start > cursor) {
parts.push(snippet.slice(cursor, h.start));
}
parts.push(
<mark key={h.start} className="rounded-sm bg-yellow-200 px-0.5 text-foreground dark:bg-yellow-800">
{snippet.slice(h.start, h.end)}
</mark>
);
cursor = h.end;
}
if (cursor < snippet.length) {
parts.push(snippet.slice(cursor));
}
return (
<span className="text-xs leading-relaxed text-muted-foreground">
{parts}
</span>
);
}
type SidebarContentProps = {
isPWA: boolean;
isMobile: boolean;
@@ -14,6 +43,12 @@ type SidebarContentProps = {
searchFilter: string;
onSearchFilterChange: (value: string) => void;
onClearSearchFilter: () => void;
searchMode: SearchMode;
onSearchModeChange: (mode: SearchMode) => void;
conversationResults: ConversationSearchResults | null;
isSearching: boolean;
searchProgress: SearchProgress | null;
onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
onRefresh: () => void;
isRefreshing: boolean;
onCreateProject: () => void;
@@ -35,6 +70,12 @@ export default function SidebarContent({
searchFilter,
onSearchFilterChange,
onClearSearchFilter,
searchMode,
onSearchModeChange,
conversationResults,
isSearching,
searchProgress,
onConversationResultClick,
onRefresh,
isRefreshing,
onCreateProject,
@@ -47,6 +88,9 @@ export default function SidebarContent({
projectListProps,
t,
}: SidebarContentProps) {
const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2;
const hasPartialResults = conversationResults && conversationResults.results.length > 0;
return (
<div
className="flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none"
@@ -60,6 +104,8 @@ export default function SidebarContent({
searchFilter={searchFilter}
onSearchFilterChange={onSearchFilterChange}
onClearSearchFilter={onClearSearchFilter}
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
onCreateProject={onCreateProject}
@@ -68,7 +114,103 @@ export default function SidebarContent({
/>
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
<SidebarProjectList {...projectListProps} />
{showConversationSearch ? (
isSearching && !hasPartialResults ? (
<div className="px-4 py-12 text-center md:py-8">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
{searchProgress && (
<p className="mt-1 text-xs text-muted-foreground/60">
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
</p>
)}
</div>
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
<div className="px-4 py-12 text-center md:py-8">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
<Search className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">{t('search.noResults')}</h3>
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
</div>
) : hasPartialResults ? (
<div className="space-y-3 px-2">
<div className="flex items-center justify-between px-1">
<p className="text-xs text-muted-foreground">
{t('search.matches', { count: conversationResults.totalMatches })}
</p>
{isSearching && searchProgress && (
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
<p className="text-[10px] text-muted-foreground/60">
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
</p>
</div>
)}
</div>
{isSearching && searchProgress && (
<div className="mx-1 h-0.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/60 transition-all duration-300"
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
/>
</div>
)}
{conversationResults.results.map((projectResult) => (
<div key={projectResult.projectName} className="space-y-1">
<div className="flex items-center gap-1.5 px-1 py-1">
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium text-foreground">
{projectResult.projectDisplayName}
</span>
</div>
{projectResult.sessions.map((session) => (
<button
key={`${projectResult.projectName}-${session.sessionId}`}
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50"
onClick={() => onConversationResultClick(
projectResult.projectName,
session.sessionId,
session.provider || session.matches[0]?.provider || 'claude',
session.matches[0]?.timestamp,
session.matches[0]?.snippet
)}
>
<div className="mb-1 flex items-center gap-1.5">
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs font-medium text-foreground">
{session.sessionSummary}
</span>
{session.provider && session.provider !== 'claude' && (
<span className="flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] uppercase text-muted-foreground">
{session.provider}
</span>
)}
</div>
<div className="space-y-1 pl-4">
{session.matches.map((match, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
{match.role === 'user' ? 'U' : 'A'}
</span>
<HighlightedSnippet
snippet={match.snippet}
highlights={match.highlights}
/>
</div>
))}
</div>
</button>
))}
</div>
))}
</div>
) : null
) : (
<SidebarProjectList {...projectListProps} />
)}
</ScrollArea>
<SidebarFooter

View File

@@ -1,7 +1,10 @@
import { FolderPlus, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils';
type SearchMode = 'projects' | 'conversations';
type SidebarHeaderProps = {
isPWA: boolean;
@@ -11,6 +14,8 @@ type SidebarHeaderProps = {
searchFilter: string;
onSearchFilterChange: (value: string) => void;
onClearSearchFilter: () => void;
searchMode: SearchMode;
onSearchModeChange: (mode: SearchMode) => void;
onRefresh: () => void;
isRefreshing: boolean;
onCreateProject: () => void;
@@ -26,6 +31,8 @@ export default function SidebarHeader({
searchFilter,
onSearchFilterChange,
onClearSearchFilter,
searchMode,
onSearchModeChange,
onRefresh,
isRefreshing,
onCreateProject,
@@ -101,23 +108,55 @@ export default function SidebarHeader({
{/* Search bar */}
{projectsCount > 0 && !isLoading && (
<div className="relative mt-2.5">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<div className="mt-2.5 space-y-2">
{/* Search mode toggle */}
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
onClick={onClearSearchFilter}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<X className="h-3 w-3 text-muted-foreground" />
<Folder className="h-3 w-3" />
{t('search.modeProjects')}
</button>
)}
<button
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
</div>
)}
</div>
@@ -162,23 +201,54 @@ export default function SidebarHeader({
{/* Mobile search */}
{projectsCount > 0 && !isLoading && (
<div className="relative mt-2.5">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<div className="mt-2.5 space-y-2">
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
onClick={onClearSearchFilter}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
<Folder className="h-3 w-3" />
{t('search.modeProjects')}
</button>
)}
<button
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
)}
</div>

View File

@@ -0,0 +1,157 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { authenticatedFetch } from '../utils/api';
export type Plugin = {
name: string;
displayName: string;
version: string;
description: string;
author: string;
icon: string;
type: 'react' | 'module';
slot: 'tab';
entry: string;
server: string | null;
permissions: string[];
enabled: boolean;
serverRunning: boolean;
dirName: string;
repoUrl: string | null;
};
type PluginsContextValue = {
plugins: Plugin[];
loading: boolean;
pluginsError: string | null;
refreshPlugins: () => Promise<void>;
installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>;
};
const PluginsContext = createContext<PluginsContextValue | null>(null);
export function usePlugins() {
const context = useContext(PluginsContext);
if (!context) {
throw new Error('usePlugins must be used within a PluginsProvider');
}
return context;
}
export function PluginsProvider({ children }: { children: ReactNode }) {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [loading, setLoading] = useState(true);
const [pluginsError, setPluginsError] = useState<string | null>(null);
const refreshPlugins = useCallback(async () => {
try {
const res = await authenticatedFetch('/api/plugins');
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins || []);
setPluginsError(null);
} else {
let errorMessage = `Failed to fetch plugins (${res.status})`;
try {
const data = await res.json();
errorMessage = data.details || data.error || errorMessage;
} catch {
errorMessage = res.statusText || errorMessage;
}
setPluginsError(errorMessage);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch plugins';
setPluginsError(message);
console.error('[Plugins] Failed to fetch plugins:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshPlugins();
}, [refreshPlugins]);
const installPlugin = useCallback(async (url: string) => {
try {
const res = await authenticatedFetch('/api/plugins/install', {
method: 'POST',
body: JSON.stringify({ url }),
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Install failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
}
}, [refreshPlugins]);
const uninstallPlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Uninstall failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
}
}, [refreshPlugins]);
const updatePlugin = useCallback(async (name: string) => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
method: 'POST',
});
const data = await res.json();
if (res.ok) {
await refreshPlugins();
return { success: true };
}
return { success: false, error: data.details || data.error || 'Update failed' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
}
}, [refreshPlugins]);
const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => {
try {
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
let errorMessage = `Toggle failed (${res.status})`;
try {
const data = await res.json();
errorMessage = data.details || data.error || errorMessage;
} catch {
// response body wasn't JSON, use status text
errorMessage = res.statusText || errorMessage;
}
return { success: false, error: errorMessage };
}
await refreshPlugins();
return { success: true, error: null };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' };
}
}, [refreshPlugins]);
return (
<PluginsContext.Provider value={{ plugins, loading, pluginsError, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
{children}
</PluginsContext.Provider>
);
}

View File

@@ -106,10 +106,14 @@ const isUpdateAdditive = (
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
};
const readPersistedTab = (): AppTab => {
try {
const stored = localStorage.getItem('activeTab');
if (stored && VALID_TABS.has(stored)) {
if (stored && isValidTab(stored)) {
return stored as AppTab;
}
} catch {

View File

@@ -46,7 +46,8 @@
"editSessionName": "Manually edit session name",
"deleteSession": "Delete this session permanently",
"save": "Save",
"cancel": "Cancel"
"cancel": "Cancel",
"clearSearch": "Clear search"
},
"navigation": {
"chat": "Chat",
@@ -103,6 +104,18 @@
"version": {
"updateAvailable": "Update available"
},
"search": {
"modeProjects": "Projects",
"modeConversations": "Conversations",
"conversationsPlaceholder": "Search in conversations...",
"searching": "Searching...",
"noResults": "No results found",
"tryDifferentQuery": "Try a different search query",
"matches_one": "{{count}} match",
"matches_other": "{{count}} matches",
"projectsScanned_one": "{{count}} project scanned",
"projectsScanned_other": "{{count}} projects scanned"
},
"deleteConfirmation": {
"deleteProject": "Delete Project",
"deleteSession": "Delete Session",

View File

@@ -46,7 +46,8 @@
"editSessionName": "手动编辑会话名称",
"deleteSession": "永久删除此会话",
"save": "保存",
"cancel": "取消"
"cancel": "取消",
"clearSearch": "清除搜索"
},
"navigation": {
"chat": "聊天",
@@ -103,6 +104,18 @@
"version": {
"updateAvailable": "有可用更新"
},
"search": {
"modeProjects": "项目",
"modeConversations": "对话",
"conversationsPlaceholder": "搜索对话内容...",
"searching": "搜索中...",
"noResults": "未找到结果",
"tryDifferentQuery": "尝试不同的搜索词",
"matches_one": "{{count}} 个匹配",
"matches_other": "{{count}} 个匹配",
"projectsScanned_one": "{{count}} 个项目已扫描",
"projectsScanned_other": "{{count}} 个项目已扫描"
},
"deleteConfirmation": {
"deleteProject": "删除项目",
"deleteSession": "删除会话",

View File

@@ -904,4 +904,23 @@
summary svg[class*="transition-transform"] {
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Search result highlight flash */
.search-highlight-flash {
animation: search-flash 4s ease-out;
}
@keyframes search-flash {
0%, 50% {
outline: 3px solid hsl(var(--primary));
outline-offset: 4px;
border-radius: 8px;
background-color: hsl(var(--primary) / 0.06);
}
100% {
outline: 3px solid transparent;
outline-offset: 4px;
background-color: transparent;
}
}
}

View File

@@ -1,6 +1,6 @@
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
export interface ProjectSession {
id: string;

View File

@@ -6,4 +6,10 @@ declare global {
refreshProjects?: () => void | Promise<void>;
openSettings?: (tab?: string) => void;
}
interface EventSourceEventMap {
result: MessageEvent;
progress: MessageEvent;
done: MessageEvent;
}
}

View File

@@ -94,6 +94,12 @@ export const api = {
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE',
}),
searchConversationsUrl: (query, limit = 50) => {
const token = localStorage.getItem('auth-token');
const params = new URLSearchParams({ q: query, limit: String(limit) });
if (token) params.set('token', token);
return `/api/search/conversations?${params.toString()}`;
},
createProject: (path) =>
authenticatedFetch('/api/projects/create', {
method: 'POST',