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>
This commit is contained in:
Haileyesus
2026-03-06 01:47:58 +03:00
committed by GitHub
parent 8d28438fe7
commit 844de26ada
254 changed files with 14571 additions and 9347 deletions

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

3
commitlint.config.js Normal file
View File

@@ -0,0 +1,3 @@
export default {
extends: ["@commitlint/config-conventional"],
};

102
eslint.config.js Normal file
View File

@@ -0,0 +1,102 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import importX from "eslint-plugin-import-x";
import tailwindcss from "eslint-plugin-tailwindcss";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**", "public/**"],
},
{
files: ["src/**/*.{ts,tsx,js,jsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
plugins: {
react,
"react-hooks": reactHooks, // for following React rules such as dependencies in hooks, keys in lists, etc.
"react-refresh": reactRefresh, // for Vite HMR compatibility
"import-x": importX, // for import order/sorting. It also detercts circular dependencies and duplicate imports.
tailwindcss, // for detecting invalid Tailwind classnames and enforcing classname order
"unused-imports": unusedImports, // for detecting unused imports
},
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
settings: {
react: { version: "detect" },
},
rules: {
// --- Unused imports/vars ---
"unused-imports/no-unused-imports": "warn",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
// --- React ---
"react/jsx-key": "warn",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "warn",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-unknown-property": "warn",
"react/react-in-jsx-scope": "off",
// --- React Hooks ---
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
// --- React Refresh (Vite HMR) ---
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
// --- Import ordering & hygiene ---
"import-x/no-duplicates": "warn",
"import-x/order": [
"warn",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
],
"newlines-between": "never",
},
],
// --- Tailwind CSS ---
"tailwindcss/classnames-order": "warn",
"tailwindcss/no-contradicting-classname": "warn",
"tailwindcss/no-unnecessary-arbitrary-value": "warn",
// --- Disabled base rules ---
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-require-imports": "off",
"no-case-declarations": "off",
"no-control-regex": "off",
"no-useless-escape": "off",
},
}
);

4428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,10 +30,13 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"start": "npm run build && npm run server", "start": "npm run build && npm run server",
"release": "./release.sh", "release": "./release.sh",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js" "postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky"
}, },
"keywords": [ "keywords": [
"claude code", "claude code",
@@ -103,6 +106,9 @@
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5", "@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
@@ -111,12 +117,26 @@
"auto-changelog": "^2.5.0", "auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^9.39.3",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"node-gyp": "^10.0.0", "node-gyp": "^10.0.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"release-it": "^19.0.5", "release-it": "^19.0.5",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.0.4" "vite": "^7.0.4"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint"
} }
} }

View File

@@ -1,11 +1,10 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext'; import { WebSocketProvider } from './contexts/WebSocketContext';
import ProtectedRoute from './components/ProtectedRoute';
import AppContent from './components/app/AppContent'; import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js'; import i18n from './i18n/config.js';

View File

@@ -1,88 +0,0 @@
import React from 'react';
import { X, Sparkles } from 'lucide-react';
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create AI-Generated Task</h3>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* AI-First Approach */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
💡 Pro Tip: Ask Claude Code Directly!
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
You can simply ask Claude Code in the chat to create tasks for you.
The AI assistant will automatically generate detailed tasks with research-backed insights.
</p>
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3 mb-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example:</p>
<p className="text-sm text-gray-900 dark:text-white font-mono">
"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach."
</p>
</div>
<p className="text-xs text-blue-700 dark:text-blue-300">
<strong>This runs:</strong> <code className="bg-blue-100 dark:bg-blue-900/50 px-1 rounded text-xs">
task-master add-task --prompt="Implement user profile image uploads using Cloudinary" --research
</code>
</p>
</div>
</div>
</div>
{/* Learn More Link */}
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
For more examples and advanced usage patterns:
</p>
<a
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-medium"
>
View TaskMaster Documentation
</a>
</div>
{/* Footer */}
<div className="pt-4">
<button
onClick={onClose}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Got it, I'll ask Claude Code directly
</button>
</div>
</div>
</div>
</div>
);
};
export default CreateTaskModal;

View File

@@ -1,41 +0,0 @@
import React from 'react';
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
if (!diff) {
return (
<div className="p-4 text-center text-muted-foreground text-sm">
No diff available
</div>
);
}
const renderDiffLine = (line, index) => {
const isAddition = line.startsWith('+') && !line.startsWith('+++');
const isDeletion = line.startsWith('-') && !line.startsWith('---');
const isHeader = line.startsWith('@@');
return (
<div
key={index}
className={`font-mono text-xs px-3 py-0.5 ${
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
} ${
isAddition ? 'bg-green-50 dark:bg-green-950/50 text-green-700 dark:text-green-300' :
isDeletion ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' :
isHeader ? 'bg-primary/5 text-primary' :
'text-muted-foreground/70'
}`}
>
{line}
</div>
);
};
return (
<div className="diff-viewer">
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
</div>
);
}
export default DiffViewer;

View File

@@ -1,77 +0,0 @@
import React, { useCallback, useState } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{showDetails && error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{error.toString()}
{componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={resetErrorBoundary}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
const [componentStack, setComponentStack] = useState(null);
const handleError = useCallback((error, errorInfo) => {
console.error('ErrorBoundary caught an error:', error, errorInfo);
setComponentStack(errorInfo?.componentStack || null);
}, []);
const handleReset = useCallback(() => {
setComponentStack(null);
onRetry?.();
}, [onRetry]);
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
<ErrorFallback
error={error}
resetErrorBoundary={resetErrorBoundary}
showDetails={showDetails}
componentStack={componentStack}
/>
), [showDetails, componentStack]);
return (
<ReactErrorBoundary
fallbackRender={renderFallback}
onError={handleError}
onReset={handleReset}
resetKeys={resetKeys}
>
{children}
</ReactErrorBoundary>
);
}
export default ErrorBoundary;

View File

@@ -1,312 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FileText,
FolderPlus,
Pencil,
Trash2,
Copy,
Download,
RefreshCw
} from 'lucide-react';
import { cn } from '../lib/utils';
/**
* FileContextMenu Component
* Right-click context menu for file/directory operations
*/
const FileContextMenu = ({
children,
item,
onRename,
onDelete,
onNewFile,
onNewFolder,
onRefresh,
onCopyPath,
onDownload,
isLoading = false,
className = ''
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const triggerRef = useRef(null);
const isDirectory = item?.type === 'directory';
const isFile = item?.type === 'file';
const isBackground = !item; // Clicked on empty space
// Handle right-click
const handleContextMenu = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
// Adjust position if menu would go off screen
const menuWidth = 200;
const menuHeight = 300;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > window.innerWidth) {
adjustedX = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
adjustedY = window.innerHeight - menuHeight - 10;
}
setPosition({ x: adjustedX, y: adjustedY });
setIsOpen(true);
}, []);
// Close menu
const closeMenu = useCallback(() => {
setIsOpen(false);
}, []);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
closeMenu();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeMenu();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, closeMenu]);
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
if (!menuItems || menuItems.length === 0) return;
const currentIndex = Array.from(menuItems).findIndex(
(item) => item === document.activeElement
);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
menuItems[nextIndex]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
menuItems[prevIndex]?.focus();
break;
case 'Enter':
case ' ':
if (document.activeElement?.hasAttribute('role', 'menuitem')) {
e.preventDefault();
document.activeElement.click();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Handle action click
const handleAction = (action, ...args) => {
closeMenu();
action?.(...args);
};
// Menu item component
const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => (
<button
role="menuitem"
tabIndex={disabled ? -1 : 0}
disabled={disabled || isLoading}
onClick={() => handleAction(onClick)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
disabled
? 'opacity-50 cursor-not-allowed'
: danger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none'
)}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground font-mono">{shortcut}</span>
)}
</button>
);
// Menu divider
const MenuDivider = () => (
<div className="h-px bg-border my-1 mx-2" />
);
// Build menu items based on context
const renderMenuItems = () => {
if (isFile) {
return (
<>
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
if (isDirectory) {
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.(item.path)}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.(item.path)}
/>
<MenuDivider />
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
// Background context (empty space)
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.('')}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.('')}
/>
<MenuDivider />
<MenuItem
icon={RefreshCw}
label={t('fileTree.context.refresh', 'Refresh')}
onClick={onRefresh}
/>
</>
);
};
return (
<>
{/* Trigger element */}
<div
ref={triggerRef}
onContextMenu={handleContextMenu}
className={cn('contents', className)}
>
{children}
</div>
{/* Context menu portal */}
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 9999
}}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t('fileTree.context.loading', 'Loading...')}
</span>
</div>
) : (
renderMenuItems()
)}
</div>
)}
</>
);
};
export default FileContextMenu;

View File

@@ -1,90 +0,0 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function GeminiStatus({ status, onAbort, isLoading }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
}, 1000);
return () => clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(timer);
}, [isLoading]);
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
return (
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
<div className="flex-1">
<div className="flex items-center gap-3">
{/* Animated spinner */}
<span className={cn(
"text-xl transition-all duration-500",
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
)}>
{currentSpinner}
</span>
{/* Status text - first line */}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{statusText}...</span>
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="hidden sm:inline">Stop</span>
</button>
)}
</div>
</div>
);
}
export default GeminiStatus;

View File

@@ -1,112 +0,0 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
const { t } = useTranslation('auth');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError(t('errors.requiredFields'));
return;
}
setIsLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">{t('login.title')}</h1>
<p className="text-muted-foreground mt-2">
{t('login.description')}
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
{t('login.username')}
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('login.placeholders.username')}
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
{t('login.password')}
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('login.placeholders.password')}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? t('login.loading') : t('login.submit')}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter your credentials to access Claude Code UI
</p>
</div>
</div>
</div>
</div>
);
};
export default LoginForm;

View File

@@ -1,153 +0,0 @@
import { X, ExternalLink, KeyRound } from 'lucide-react';
import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication
*
* @param {Object} props
* @param {boolean} props.isOpen - Whether the modal is visible
* @param {Function} props.onClose - Callback when modal is closed
* @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/
function LoginModal({
isOpen,
onClose,
provider = 'claude',
project,
onComplete,
customCommand,
isAuthenticated = false,
isOnboarding = false
}) {
if (!isOpen) return null;
const getCommand = () => {
if (customCommand) return customCommand;
switch (provider) {
case 'claude':
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
case 'gemini':
// No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json`
return 'gemini status';
default:
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
}
};
const getTitle = () => {
switch (provider) {
case 'claude':
return 'Claude CLI Login';
case 'cursor':
return 'Cursor CLI Login';
case 'codex':
return 'Codex CLI Login';
case 'gemini':
return 'Gemini CLI Configuration';
default:
return 'CLI Login';
}
};
const handleComplete = (exitCode) => {
if (onComplete) {
onComplete(exitCode);
}
// Keep modal open so users can read login output and close explicitly.
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{getTitle()}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close login modal"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{provider === 'gemini' ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
Setup Gemini API Access
</h4>
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
</p>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
<ol className="space-y-4">
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
>
Google AI Studio <ExternalLink className="w-3 h-3" />
</a>
</div>
</li>
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
gemini config set api_key YOUR_KEY
</code>
</div>
</li>
</ol>
</div>
<button
onClick={onClose}
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Done
</button>
</div>
) : (
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
)}
</div>
</div>
</div>
);
}
export default LoginModal;

View File

@@ -1,695 +0,0 @@
import React, { useState } from 'react';
import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause, ChevronDown, ChevronUp, Plus, FileText, Settings, X, Terminal, Eye, Play, Zap, Target } from 'lucide-react';
import { cn } from '../lib/utils';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { api } from '../utils/api';
import Shell from './shell/view/Shell';
import TaskDetail from './TaskDetail';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
const { nextTask, tasks, currentProject, isLoadingTasks, projectTaskMaster, refreshTasks, refreshProjects } = useTaskMaster();
const [showDetails, setShowDetails] = useState(false);
const [showTaskOptions, setShowTaskOptions] = useState(false);
const [showCreateTaskModal, setShowCreateTaskModal] = useState(false);
const [showTemplateSelector, setShowTemplateSelector] = useState(false);
const [showCLI, setShowCLI] = useState(false);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Handler functions
const handleInitializeTaskMaster = async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const response = await api.taskmaster.init(currentProject.name);
if (response.ok) {
await refreshProjects();
setShowTaskOptions(false);
} else {
const error = await response.json();
console.error('Failed to initialize TaskMaster:', error);
alert(`Failed to initialize TaskMaster: ${error.message}`);
}
} catch (error) {
console.error('Error initializing TaskMaster:', error);
alert('Error initializing TaskMaster. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleCreateManualTask = () => {
setShowCreateTaskModal(true);
setShowTaskOptions(false);
};
const handleParsePRD = () => {
setShowTemplateSelector(true);
setShowTaskOptions(false);
};
// Don't show if no project or still loading
if (!currentProject || isLoadingTasks) {
return null;
}
let bannerContent;
// Show setup message only if no tasks exist AND TaskMaster is not configured
if ((!tasks || tasks.length === 0) && !projectTaskMaster?.hasTaskmaster) {
bannerContent = (
<div className={cn(
'bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4',
className
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<List className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
TaskMaster AI is not configured
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowTaskOptions(!showTaskOptions)}
className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center gap-1"
>
<Settings className="w-3 h-3" />
Initialize TaskMaster AI
</button>
</div>
</div>
{showTaskOptions && (
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
{!projectTaskMaster?.hasTaskmaster && (
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/50 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
🎯 What is TaskMaster?
</h4>
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<p> <strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks</p>
<p> <strong>PRD Templates:</strong> Generate tasks from Product Requirements Documents</p>
<p> <strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
<p> <strong>Progress Visualization:</strong> Kanban boards and detailed task analytics</p>
<p> <strong>CLI Integration:</strong> Use taskmaster commands for advanced workflows</p>
</div>
</div>
)}
<div className="flex flex-col gap-2">
{!projectTaskMaster?.hasTaskmaster ? (
<button
className="text-xs px-3 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 rounded transition-colors text-left flex items-center gap-2"
onClick={() => setShowCLI(true)}
>
<Terminal className="w-3 h-3" />
Initialize TaskMaster
</button>
) : (
<>
<div className="mb-2 p-2 bg-green-50 dark:bg-green-900/30 rounded text-xs text-green-800 dark:text-green-200">
<strong>Add more tasks:</strong> Create additional tasks manually or generate them from a PRD template
</div>
<button
className="text-xs px-3 py-2 bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
onClick={handleCreateManualTask}
disabled={isLoading}
>
<Plus className="w-3 h-3" />
Create a new task manually
</button>
<button
className="text-xs px-3 py-2 bg-purple-100 dark:bg-purple-900 hover:bg-purple-200 dark:hover:bg-purple-800 text-purple-800 dark:text-purple-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
onClick={handleParsePRD}
disabled={isLoading}
>
<FileText className="w-3 h-3" />
{isLoading ? 'Parsing...' : 'Generate tasks from PRD template'}
</button>
</>
)}
</div>
</div>
)}
</div>
);
} else if (nextTask) {
// Show next task if available
bannerContent = (
<div className={cn(
'bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4',
className
)}>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center flex-shrink-0">
<Target className="w-3 h-3 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Task {nextTask.id}</span>
{nextTask.priority === 'high' && (
<div className="w-4 h-4 rounded bg-red-100 dark:bg-red-900/50 flex items-center justify-center" title="High Priority">
<Zap className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</div>
)}
{nextTask.priority === 'medium' && (
<div className="w-4 h-4 rounded bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center" title="Medium Priority">
<Flag className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
</div>
)}
{nextTask.priority === 'low' && (
<div className="w-4 h-4 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center" title="Low Priority">
<Circle className="w-2.5 h-2.5 text-gray-400 dark:text-gray-500" />
</div>
)}
</div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 line-clamp-1">
{nextTask.title}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onStartTask?.()}
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium transition-colors shadow-sm flex items-center gap-1"
>
<Play className="w-3 h-3" />
Start Task
</button>
<button
onClick={() => setShowTaskDetail(true)}
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
title="View task details"
>
<Eye className="w-3 h-3" />
</button>
{onShowAllTasks && (
<button
onClick={onShowAllTasks}
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
title="View all tasks"
>
<List className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
} else if (tasks && tasks.length > 0) {
// Show completion message only if there are tasks and all are done
const completedTasks = tasks.filter(task => task.status === 'done').length;
const totalTasks = tasks.length;
bannerContent = (
<div className={cn(
'bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4',
className
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
{completedTasks === totalTasks ? "All done! 🎉" : "No pending tasks"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400">
{completedTasks}/{totalTasks}
</span>
<button
onClick={onShowAllTasks}
className="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
>
Review
</button>
</div>
</div>
</div>
);
} else {
// TaskMaster is configured but no tasks exist - don't show anything in chat
bannerContent = null;
}
return (
<>
{bannerContent}
{/* Create Task Modal */}
{showCreateTaskModal && (
<CreateTaskModal
currentProject={currentProject}
onClose={() => setShowCreateTaskModal(false)}
onTaskCreated={() => {
refreshTasks();
setShowCreateTaskModal(false);
}}
/>
)}
{/* Template Selector Modal */}
{showTemplateSelector && (
<TemplateSelector
currentProject={currentProject}
onClose={() => setShowTemplateSelector(false)}
onTemplateApplied={() => {
refreshTasks();
setShowTemplateSelector(false);
}}
/>
)}
{/* TaskMaster CLI Setup Modal */}
{showCLI && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl h-[600px] flex flex-col">
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">TaskMaster Setup</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Interactive CLI for {currentProject?.displayName}</p>
</div>
</div>
<button
onClick={() => setShowCLI(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Terminal Container */}
<div className="flex-1 p-4">
<div className="h-full bg-black rounded-lg overflow-hidden">
<Shell
selectedProject={currentProject}
selectedSession={null}
isActive={true}
initialCommand="npx task-master init"
isPlainShell={true}
/>
</div>
</div>
{/* Modal Footer */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
TaskMaster initialization will start automatically
</div>
<button
onClick={() => setShowCLI(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
{/* Task Detail Modal */}
{showTaskDetail && nextTask && (
<TaskDetail
task={nextTask}
isOpen={showTaskDetail}
onClose={() => setShowTaskDetail(false)}
onStatusChange={() => refreshTasks?.()}
onTaskClick={null} // Disable dependency navigation in NextTaskBanner for now
/>
)}
</>
);
};
// Simple Create Task Modal Component
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium',
useAI: false,
prompt: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!currentProject) return;
setIsSubmitting(true);
try {
const taskData = formData.useAI
? { prompt: formData.prompt, priority: formData.priority }
: { title: formData.title, description: formData.description, priority: formData.priority };
const response = await api.taskmaster.addTask(currentProject.name, taskData);
if (response.ok) {
onTaskCreated();
} else {
const error = await response.json();
console.error('Failed to create task:', error);
alert(`Failed to create task: ${error.message}`);
}
} catch (error) {
console.error('Error creating task:', error);
alert('Error creating task. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Task</h3>
<button
onClick={onClose}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<input
type="checkbox"
checked={formData.useAI}
onChange={(e) => setFormData(prev => ({ ...prev, useAI: e.target.checked }))}
/>
Use AI to generate task details
</label>
</div>
{formData.useAI ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Task Description (AI will generate details)
</label>
<textarea
value={formData.prompt}
onChange={(e) => setFormData(prev => ({ ...prev, prompt: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
rows="3"
placeholder="Describe what you want to accomplish..."
required
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Task Title
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Enter task title..."
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
rows="3"
placeholder="Describe the task..."
required
/>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority
</label>
<select
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
disabled={isSubmitting || (formData.useAI && !formData.prompt.trim()) || (!formData.useAI && (!formData.title.trim() || !formData.description.trim()))}
>
{isSubmitting ? 'Creating...' : 'Create Task'}
</button>
</div>
</form>
</div>
</div>
);
};
// Template Selector Modal Component
const TemplateSelector = ({ currentProject, onClose, onTemplateApplied }) => {
const [templates, setTemplates] = useState([]);
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [customizations, setCustomizations] = useState({});
const [fileName, setFileName] = useState('prd.txt');
const [isLoading, setIsLoading] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [step, setStep] = useState('select'); // 'select', 'customize', 'generate'
useEffect(() => {
const loadTemplates = async () => {
try {
const response = await api.taskmaster.getTemplates();
if (response.ok) {
const data = await response.json();
setTemplates(data.templates);
}
} catch (error) {
console.error('Error loading templates:', error);
} finally {
setIsLoading(false);
}
};
loadTemplates();
}, []);
const handleSelectTemplate = (template) => {
setSelectedTemplate(template);
// Find placeholders in template content
const placeholders = template.content.match(/\[([^\]]+)\]/g) || [];
const uniquePlaceholders = [...new Set(placeholders.map(p => p.slice(1, -1)))];
const initialCustomizations = {};
uniquePlaceholders.forEach(placeholder => {
initialCustomizations[placeholder] = '';
});
setCustomizations(initialCustomizations);
setStep('customize');
};
const handleApplyTemplate = async () => {
if (!selectedTemplate || !currentProject) return;
setIsApplying(true);
try {
// Apply template
const applyResponse = await api.taskmaster.applyTemplate(currentProject.name, {
templateId: selectedTemplate.id,
fileName,
customizations
});
if (!applyResponse.ok) {
const error = await applyResponse.json();
throw new Error(error.message || 'Failed to apply template');
}
// Parse PRD to generate tasks
const parseResponse = await api.taskmaster.parsePRD(currentProject.name, {
fileName,
numTasks: 10
});
if (!parseResponse.ok) {
const error = await parseResponse.json();
throw new Error(error.message || 'Failed to generate tasks');
}
setStep('generate');
setTimeout(() => {
onTemplateApplied();
}, 2000);
} catch (error) {
console.error('Error applying template:', error);
alert(`Error: ${error.message}`);
setIsApplying(false);
}
};
if (isLoading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading templates...</span>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{step === 'select' ? 'Select PRD Template' :
step === 'customize' ? 'Customize Template' :
'Generating Tasks'}
</h3>
<button
onClick={onClose}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
{step === 'select' && (
<div className="space-y-3">
{templates.map((template) => (
<div
key={template.id}
className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
onClick={() => handleSelectTemplate(template)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white">{template.name}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{template.description}</p>
<span className="inline-block text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded mt-2">
{template.category}
</span>
</div>
<ArrowRight className="w-4 h-4 text-gray-400 mt-1" />
</div>
</div>
))}
</div>
)}
{step === 'customize' && selectedTemplate && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
File Name
</label>
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="prd.txt"
/>
</div>
{Object.keys(customizations).length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Customize Template
</label>
<div className="space-y-3">
{Object.entries(customizations).map(([key, value]) => (
<div key={key}>
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
</label>
<input
type="text"
value={value}
onChange={(e) => setCustomizations(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder={`Enter ${key.toLowerCase()}`}
/>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<button
onClick={() => setStep('select')}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
Back
</button>
<button
onClick={handleApplyTemplate}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded disabled:opacity-50"
disabled={isApplying}
>
{isApplying ? 'Applying...' : 'Apply & Generate Tasks'}
</button>
</div>
</div>
)}
{step === 'generate' && (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Template Applied Successfully!
</h4>
<p className="text-gray-600 dark:text-gray-400">
Your PRD has been created and tasks are being generated...
</p>
</div>
)}
</div>
</div>
);
};
export default NextTaskBanner;

View File

@@ -1,567 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext';
import { IS_PLATFORM } from '../constants/config';
const Onboarding = ({ onComplete }) => {
const [currentStep, setCurrentStep] = useState(0);
const [gitName, setGitName] = useState('');
const [gitEmail, setGitEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [cursorAuthStatus, setCursorAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [codexAuthStatus, setCodexAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const { user } = useAuth();
const prevActiveLoginProviderRef = useRef(undefined);
useEffect(() => {
loadGitConfig();
}, []);
const loadGitConfig = async () => {
try {
const response = await authenticatedFetch('/api/user/git-config');
if (response.ok) {
const data = await response.json();
if (data.gitName) setGitName(data.gitName);
if (data.gitEmail) setGitEmail(data.gitEmail);
}
} catch (error) {
console.error('Error loading git config:', error);
}
};
useEffect(() => {
const prevProvider = prevActiveLoginProviderRef.current;
prevActiveLoginProviderRef.current = activeLoginProvider;
const isInitialMount = prevProvider === undefined;
const isModalClosing = prevProvider !== null && activeLoginProvider === null;
if (isInitialMount || isModalClosing) {
checkClaudeAuthStatus();
checkCursorAuthStatus();
checkCodexAuthStatus();
checkGeminiAuthStatus();
}
}, [activeLoginProvider]);
const checkProviderAuthStatus = async (provider, setter) => {
try {
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (response.ok) {
const data = await response.json();
setter({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setter({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error(`Error checking ${provider} auth status:`, error);
setter({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
const handleClaudeLogin = () => setActiveLoginProvider('claude');
const handleCursorLogin = () => setActiveLoginProvider('cursor');
const handleCodexLogin = () => setActiveLoginProvider('codex');
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
const handleLoginComplete = (exitCode) => {
if (exitCode === 0) {
if (activeLoginProvider === 'claude') {
checkClaudeAuthStatus();
} else if (activeLoginProvider === 'cursor') {
checkCursorAuthStatus();
} else if (activeLoginProvider === 'codex') {
checkCodexAuthStatus();
} else if (activeLoginProvider === 'gemini') {
checkGeminiAuthStatus();
}
}
};
const handleNextStep = async () => {
setError('');
// Step 0: Git config validation and submission
if (currentStep === 0) {
if (!gitName.trim() || !gitEmail.trim()) {
setError('Both git name and email are required');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(gitEmail)) {
setError('Please enter a valid email address');
return;
}
setIsSubmitting(true);
try {
// Save git config to backend (which will also apply git config --global)
const response = await authenticatedFetch('/api/user/git-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitName, gitEmail })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to save git configuration');
}
setCurrentStep(currentStep + 1);
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
return;
}
setCurrentStep(currentStep + 1);
};
const handlePrevStep = () => {
setError('');
setCurrentStep(currentStep - 1);
};
const handleFinish = async () => {
setIsSubmitting(true);
setError('');
try {
const response = await authenticatedFetch('/api/user/complete-onboarding', {
method: 'POST'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to complete onboarding');
}
if (onComplete) {
onComplete();
}
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
const steps = [
{
title: 'Git Configuration',
description: 'Set up your git identity for commits',
icon: GitBranch,
required: true
},
{
title: 'Connect Agents',
description: 'Connect your AI coding assistants',
icon: LogIn,
required: false
}
];
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<GitBranch className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Git Configuration</h2>
<p className="text-muted-foreground">
Configure your git identity to ensure proper attribution for your commits
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="gitName" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
<User className="w-4 h-4" />
Git Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="gitName"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="John Doe"
required
disabled={isSubmitting}
/>
<p className="mt-1 text-xs text-muted-foreground">
This will be used as: git config --global user.name
</p>
</div>
<div>
<label htmlFor="gitEmail" className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
<Mail className="w-4 h-4" />
Git Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="gitEmail"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="john@example.com"
required
disabled={isSubmitting}
/>
<p className="mt-1 text-xs text-muted-foreground">
This will be used as: git config --global user.email
</p>
</div>
</div>
</div>
);
case 1:
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-foreground mb-2">Connect Your AI Agents</h2>
<p className="text-muted-foreground">
Login to one or more AI coding assistants. All are optional.
</p>
</div>
{/* Agent Cards Grid */}
<div className="space-y-3">
{/* Claude */}
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="claude" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Claude Code
{claudeAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{claudeAuthStatus.loading ? 'Checking...' :
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!claudeAuthStatus.authenticated && !claudeAuthStatus.loading && (
<button
onClick={handleClaudeLogin}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
{/* Cursor */}
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="cursor" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Cursor
{cursorAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{cursorAuthStatus.loading ? 'Checking...' :
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!cursorAuthStatus.authenticated && !cursorAuthStatus.loading && (
<button
onClick={handleCursorLogin}
className="bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
{/* Codex */}
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="codex" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
OpenAI Codex
{codexAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{codexAuthStatus.loading ? 'Checking...' :
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!codexAuthStatus.authenticated && !codexAuthStatus.loading && (
<button
onClick={handleCodexLogin}
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
{/* Gemini */}
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Gemini
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{geminiAuthStatus.loading ? 'Checking...' :
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
<button
onClick={handleGeminiLogin}
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground pt-2">
<p>You can configure these later in Settings.</p>
</div>
</div>
);
default:
return null;
}
};
const isStepValid = () => {
switch (currentStep) {
case 0:
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
case 1:
return true;
default:
return false;
}
};
return (
<>
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-2xl">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<React.Fragment key={index}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
'bg-background border-border text-muted-foreground'
}`}>
{index < currentStep ? (
<Check className="w-6 h-6" />
) : typeof step.icon === 'function' ? (
<step.icon />
) : (
<step.icon className="w-6 h-6" />
)}
</div>
<div className="mt-2 text-center">
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.title}
</p>
{step.required && (
<span className="text-xs text-red-500">Required</span>
)}
</div>
</div>
{index < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Main Card */}
<div className="bg-card rounded-lg shadow-lg border border-border p-8">
{renderStepContent()}
{/* Error Message */}
{error && (
<div className="mt-6 p-4 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{/* Navigation Buttons */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-border">
<button
onClick={handlePrevStep}
disabled={currentStep === 0 || isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<div className="flex items-center gap-3">
{currentStep < steps.length - 1 ? (
<button
onClick={handleNextStep}
disabled={!isStepValid() || isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
Next
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
) : (
<button
onClick={handleFinish}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors duration-200"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Completing...
</>
) : (
<>
<Check className="w-4 h-4" />
Complete Setup
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
{activeLoginProvider && (
<LoginModal
isOpen={!!activeLoginProvider}
onClose={() => setActiveLoginProvider(null)}
provider={activeLoginProvider}
project={selectedProject}
onComplete={handleLoginComplete}
isOnboarding={true}
/>
)}
</>
);
};
export default Onboarding;

View File

@@ -1,871 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { X, Save, Download, Maximize2, Minimize2, Eye, FileText, Sparkles, AlertTriangle } from 'lucide-react';
import { cn } from '../lib/utils';
import { api, authenticatedFetch } from '../utils/api';
const PRDEditor = ({
file,
onClose,
projectPath,
project, // Add project object
initialContent = '',
isNewFile = false,
onSave
}) => {
const [content, setContent] = useState(initialContent);
const [loading, setLoading] = useState(!isNewFile);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [saveSuccess, setSaveSuccess] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
const [wordWrap, setWordWrap] = useState(true); // Default to true for markdown
const [fileName, setFileName] = useState('');
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [existingPRDs, setExistingPRDs] = useState([]);
const editorRef = useRef(null);
const PRD_TEMPLATE = `# Product Requirements Document - Example Project
## 1. Overview
**Product Name:** AI-Powered Task Manager
**Version:** 1.0
**Date:** 2024-12-27
**Author:** Development Team
This document outlines the requirements for building an AI-powered task management application that integrates with development workflows and provides intelligent task breakdown and prioritization.
## 2. Objectives
- Create an intuitive task management system that works seamlessly with developer tools
- Provide AI-powered task generation from high-level requirements
- Enable real-time collaboration and progress tracking
- Integrate with popular development environments (VS Code, Cursor, etc.)
### Success Metrics
- User adoption rate > 80% within development teams
- Task completion rate improvement of 25%
- Time-to-delivery reduction of 15%
## 3. User Stories
### Core Functionality
- As a project manager, I want to create PRDs that automatically generate detailed tasks so I can save time on project planning
- As a developer, I want to see my next task clearly highlighted so I can maintain focus
- As a team lead, I want to track progress across multiple projects so I can provide accurate status updates
- As a developer, I want tasks to be broken down into implementable subtasks so I can work more efficiently
### AI Integration
- As a user, I want to describe a feature in natural language and get detailed implementation tasks so I can start working immediately
- As a project manager, I want the AI to analyze task complexity and suggest appropriate time estimates
- As a developer, I want intelligent task prioritization based on dependencies and deadlines
### Collaboration
- As a team member, I want to see real-time updates when tasks are completed so I can coordinate my work
- As a stakeholder, I want to view project progress through intuitive dashboards
- As a developer, I want to add implementation notes to tasks for future reference
## 4. Functional Requirements
### Task Management
- Create, edit, and delete tasks with rich metadata (priority, status, dependencies, estimates)
- Hierarchical task structure with subtasks and sub-subtasks
- Real-time status updates and progress tracking
- Dependency management with circular dependency detection
- Bulk operations (move, update status, assign)
### AI Features
- Natural language PRD parsing to generate structured tasks
- Intelligent task breakdown with complexity analysis
- Automated subtask generation with implementation details
- Smart dependency suggestion
- Progress prediction based on historical data
### Integration Features
- VS Code/Cursor extension for in-editor task management
- Git integration for linking commits to tasks
- API for third-party tool integration
- Webhook support for external notifications
- CLI tool for command-line task management
### User Interface
- Responsive web application (desktop and mobile)
- Multiple view modes (Kanban, list, calendar)
- Dark/light theme support
- Drag-and-drop task organization
- Advanced filtering and search capabilities
- Keyboard shortcuts for power users
## 5. Technical Requirements
### Frontend
- React.js with TypeScript for type safety
- Modern UI framework (Tailwind CSS)
- State management (Context API or Redux)
- Real-time updates via WebSockets
- Progressive Web App (PWA) support
- Accessibility compliance (WCAG 2.1 AA)
### Backend
- Node.js with Express.js framework
- RESTful API design with OpenAPI documentation
- Real-time communication via Socket.io
- Background job processing
- Rate limiting and security middleware
### AI Integration
- Integration with multiple AI providers (OpenAI, Anthropic, etc.)
- Fallback model support
- Context-aware prompt engineering
- Token usage optimization
- Model response caching
### Database
- Primary: PostgreSQL for relational data
- Cache: Redis for session management and real-time features
- Full-text search capabilities
- Database migrations and seeding
- Backup and recovery procedures
### Infrastructure
- Docker containerization
- Cloud deployment (AWS/GCP/Azure)
- Auto-scaling capabilities
- Monitoring and logging (structured logging)
- CI/CD pipeline with automated testing
## 6. Non-Functional Requirements
### Performance
- Page load time < 2 seconds
- API response time < 500ms for 95% of requests
- Support for 1000+ concurrent users
- Efficient handling of large task lists (10,000+ tasks)
### Security
- JWT-based authentication with refresh tokens
- Role-based access control (RBAC)
- Data encryption at rest and in transit
- Regular security audits and penetration testing
- GDPR and privacy compliance
### Reliability
- 99.9% uptime SLA
- Graceful error handling and recovery
- Data backup every 6 hours with point-in-time recovery
- Disaster recovery plan with RTO < 4 hours
### Scalability
- Horizontal scaling for both frontend and backend
- Database read replicas for query optimization
- CDN for static asset delivery
- Microservices architecture for future expansion
## 7. User Experience Design
### Information Architecture
- Intuitive navigation with breadcrumbs
- Context-aware menus and actions
- Progressive disclosure of complex features
- Consistent design patterns throughout
### Interaction Design
- Smooth animations and transitions
- Immediate feedback for user actions
- Undo/redo functionality for critical operations
- Smart defaults and auto-save features
### Visual Design
- Modern, clean interface with plenty of whitespace
- Consistent color scheme and typography
- Clear visual hierarchy with proper contrast ratios
- Iconography that supports comprehension
## 8. Integration Requirements
### Development Tools
- VS Code extension with task panel and quick actions
- Cursor IDE integration with AI task suggestions
- Terminal CLI for command-line workflow
- Browser extension for web-based tools
### Third-Party Services
- GitHub/GitLab integration for issue sync
- Slack/Discord notifications
- Calendar integration (Google Calendar, Outlook)
- Time tracking tools (Toggl, Harvest)
### APIs and Webhooks
- RESTful API with comprehensive documentation
- GraphQL endpoint for complex queries
- Webhook system for external integrations
- SDK development for major programming languages
## 9. Implementation Phases
### Phase 1: Core MVP (8-10 weeks)
- Basic task management (CRUD operations)
- Simple AI task generation
- Web interface with essential features
- User authentication and basic permissions
### Phase 2: Enhanced Features (6-8 weeks)
- Advanced AI features (complexity analysis, subtask generation)
- Real-time collaboration
- Mobile-responsive design
- Integration with one development tool (VS Code)
### Phase 3: Enterprise Features (4-6 weeks)
- Advanced user management and permissions
- API and webhook system
- Performance optimization
- Comprehensive testing and security audit
### Phase 4: Ecosystem Expansion (4-6 weeks)
- Additional tool integrations
- Mobile app development
- Advanced analytics and reporting
- Third-party marketplace preparation
## 10. Risk Assessment
### Technical Risks
- AI model reliability and cost management
- Real-time synchronization complexity
- Database performance with large datasets
- Integration complexity with multiple tools
### Business Risks
- User adoption in competitive market
- AI provider dependency
- Data privacy and security concerns
- Feature scope creep and timeline delays
### Mitigation Strategies
- Implement robust error handling and fallback systems
- Develop comprehensive testing strategy
- Create detailed documentation and user guides
- Establish clear project scope and change management process
## 11. Success Criteria
### Development Milestones
- Alpha version with core features completed
- Beta version with selected user group feedback
- Production-ready version with full feature set
- Post-launch iterations based on user feedback
### Business Metrics
- User engagement and retention rates
- Task completion and productivity metrics
- Customer satisfaction scores (NPS > 50)
- Revenue targets and subscription growth
## 12. Appendices
### Glossary
- **PRD**: Product Requirements Document
- **AI**: Artificial Intelligence
- **CRUD**: Create, Read, Update, Delete
- **API**: Application Programming Interface
- **CI/CD**: Continuous Integration/Continuous Deployment
### References
- Industry best practices for task management
- AI integration patterns and examples
- Security and compliance requirements
- Performance benchmarking data
---
**Document Control:**
- Version: 1.0
- Last Updated: December 27, 2024
- Next Review: January 15, 2025
- Approved By: Product Owner, Technical Lead`;
// Initialize filename and load content
useEffect(() => {
const initializeEditor = async () => {
// Set initial filename
if (file?.name) {
setFileName(file.name.replace(/\.(txt|md)$/, '')); // Remove extension for editing
} else if (isNewFile) {
// Generate default filename based on current date
const now = new Date();
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD
setFileName(`prd-${dateStr}`);
}
// Load content
if (isNewFile) {
setContent(PRD_TEMPLATE);
setLoading(false);
return;
}
// If content is directly provided (for existing PRDs loaded from API)
if (file.content) {
setContent(file.content);
setLoading(false);
return;
}
// Fallback to loading from file path (legacy support)
try {
setLoading(true);
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content || PRD_TEMPLATE);
} catch (error) {
console.error('Error loading PRD file:', error);
setContent(`# Error Loading PRD\n\nError: ${error.message}\n\nFile: ${file?.name || 'New PRD'}\nPath: ${file?.path || 'Not saved yet'}\n\n${PRD_TEMPLATE}`);
} finally {
setLoading(false);
}
};
initializeEditor();
}, [file, projectPath, isNewFile]);
// Fetch existing PRDs to check for conflicts
useEffect(() => {
const fetchExistingPRDs = async () => {
if (!project?.name) {
console.log('No project name available:', project);
return;
}
try {
console.log('Fetching PRDs for project:', project.name);
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
if (response.ok) {
const data = await response.json();
console.log('Fetched existing PRDs:', data.prds);
setExistingPRDs(data.prds || []);
} else {
console.log('Failed to fetch PRDs:', response.status, response.statusText);
}
} catch (error) {
console.error('Error fetching existing PRDs:', error);
}
};
fetchExistingPRDs();
}, [project?.name]);
const handleSave = async () => {
if (!content.trim()) {
alert('Please add content before saving.');
return;
}
if (!fileName.trim()) {
alert('Please provide a filename for the PRD.');
return;
}
// Check if file already exists
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
const existingFile = existingPRDs.find(prd => prd.name === fullFileName);
console.log('Save check:', {
fullFileName,
existingPRDs,
existingFile,
isExisting: file?.isExisting,
fileObject: file,
shouldShowModal: existingFile && !file?.isExisting
});
if (existingFile && !file?.isExisting) {
console.log('Showing overwrite confirmation modal');
// Show confirmation modal for overwrite
setShowOverwriteConfirm(true);
return;
}
await performSave();
};
const performSave = async () => {
setSaving(true);
try {
// Ensure filename has .txt extension
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(project?.name)}`, {
method: 'POST',
body: JSON.stringify({
fileName: fullFileName,
content
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Save failed: ${response.status}`);
}
// Show success feedback
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
// Update existing PRDs list
const response2 = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
if (response2.ok) {
const data = await response2.json();
setExistingPRDs(data.prds || []);
}
// Call the onSave callback if provided (for UI updates)
if (onSave) {
await onSave();
}
} catch (error) {
console.error('Error saving PRD:', error);
alert(`Error saving PRD: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const downloadFileName = fileName ? `${fileName}.txt` : 'prd.txt';
a.download = downloadFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleGenerateTasks = async () => {
if (!content.trim()) {
alert('Please add content to the PRD before generating tasks.');
return;
}
// Show AI-first modal instead of simple confirm
setShowGenerateModal(true);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
// Simple markdown to HTML converter for preview
const renderMarkdown = (markdown) => {
return markdown
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/^\- (.*$)/gim, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/gims, '<ul>$1</ul>')
.replace(/\n\n/gim, '</p><p>')
.replace(/^(?!<[h|u|l])(.*$)/gim, '<p>$1</p>')
.replace(/<\/ul>\s*<ul>/gim, '');
};
if (loading) {
return (
<div className="fixed inset-0 z-[200] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center bg-white dark:bg-gray-900">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading PRD...</span>
</div>
</div>
</div>
);
}
return (
<div className={`fixed inset-0 z-[200] ${
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={cn(
'bg-white dark:bg-gray-900 shadow-2xl flex flex-col',
'w-full h-full md:rounded-lg md:shadow-2xl',
isFullscreen
? 'md:w-full md:h-full md:rounded-none'
: 'md:w-full md:max-w-6xl md:h-[85vh] md:max-h-[85vh]'
)}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-white" />
</div>
<div className="min-w-0 flex-1">
{/* Mobile: Stack filename and tags vertically for more space */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0">
{/* Filename input row - full width on mobile */}
<div className="flex items-center gap-1 min-w-0 flex-1">
<div className="flex items-center min-w-0 flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-2 focus-within:ring-2 focus-within:ring-purple-500 focus-within:border-purple-500 dark:focus-within:ring-purple-400 dark:focus-within:border-purple-400">
<input
type="text"
value={fileName}
onChange={(e) => {
// Remove invalid filename characters
const sanitizedValue = e.target.value.replace(/[<>:"/\\|?*]/g, '');
setFileName(sanitizedValue);
}}
className="font-medium text-gray-900 dark:text-white bg-transparent border-none outline-none min-w-0 flex-1 text-base sm:text-sm placeholder-gray-400 dark:placeholder-gray-500"
placeholder="Enter PRD filename"
maxLength={100}
/>
<span className="text-sm sm:text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-1">.txt</span>
</div>
<button
onClick={() => document.querySelector('input[placeholder="Enter PRD filename"]')?.focus()}
className="p-1 text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
title="Click to edit filename"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
{/* Tags row - moves to second line on mobile for more filename space */}
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 px-2 py-1 rounded whitespace-nowrap">
📋 PRD
</span>
{isNewFile && (
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 px-2 py-1 rounded whitespace-nowrap">
New
</span>
)}
</div>
</div>
{/* Description - smaller on mobile */}
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate mt-1">
Product Requirements Document
</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
<button
onClick={() => setPreviewMode(!previewMode)}
className={cn(
'p-2 md:p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800',
'min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center',
previewMode
? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
)}
title={previewMode ? 'Switch to edit mode' : 'Preview markdown'}
>
<Eye className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={() => setWordWrap(!wordWrap)}
className={cn(
'p-2 md:p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800',
'min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center',
wordWrap
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
)}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Toggle theme"
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download PRD"
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={handleGenerateTasks}
disabled={!content.trim()}
className={cn(
'px-3 py-2 rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors text-sm font-medium',
'bg-purple-600 hover:bg-purple-700 text-white',
'min-h-[44px] md:min-h-0'
)}
title="Generate tasks from PRD content"
>
<Sparkles className="w-4 h-4" />
<span className="hidden md:inline">Generate Tasks</span>
</button>
<button
onClick={handleSave}
disabled={saving}
className={cn(
'px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors',
'min-h-[44px] md:min-h-0',
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-purple-600 hover:bg-purple-700'
)}
>
{saveSuccess ? (
<>
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
</>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save PRD'}</span>
</>
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
>
<X className="w-6 h-6 md:w-4 md:h-4" />
</button>
</div>
</div>
{/* Editor/Preview Content */}
<div className="flex-1 overflow-hidden">
{previewMode ? (
<div className="h-full overflow-y-auto p-6 prose prose-gray dark:prose-invert max-w-none">
<div
className="markdown-preview"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
) : (
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
markdown(),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: '14px',
height: '100%',
}}
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Words: {content.split(/\s+/).filter(word => word.length > 0).length}</span>
<span>Format: Markdown</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
</div>
</div>
</div>
{/* Generate Tasks Modal */}
{showGenerateModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Generate Tasks from PRD</h3>
</div>
<button
onClick={() => setShowGenerateModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* AI-First Approach */}
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-purple-900 dark:text-purple-100 mb-2">
💡 Pro Tip: Ask Claude Code Directly!
</h4>
<p className="text-sm text-purple-800 dark:text-purple-200 mb-3">
You can simply ask Claude Code in the chat to parse your PRD and generate tasks.
The AI assistant will automatically save your PRD and create detailed tasks with implementation details.
</p>
<div className="bg-white dark:bg-gray-800 rounded border border-purple-200 dark:border-purple-700 p-3 mb-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
<p className="text-xs text-gray-900 dark:text-white font-mono">
"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}. Can you help me parse it and set up the initial tasks?"
</p>
</div>
<p className="text-xs text-purple-700 dark:text-purple-300">
<strong>This will:</strong> Save your PRD, analyze its content, and generate structured tasks with subtasks, dependencies, and implementation details.
</p>
</div>
</div>
</div>
{/* Learn More Link */}
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
For more examples and advanced usage patterns:
</p>
<a
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 underline font-medium"
>
View TaskMaster Documentation
</a>
</div>
{/* Footer */}
<div className="pt-4">
<button
onClick={() => setShowGenerateModal(false)}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Got it, I'll ask Claude Code directly
</button>
</div>
</div>
</div>
</div>
)}
{/* Overwrite Confirmation Modal */}
{showOverwriteConfirm && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowOverwriteConfirm(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-gray-700">
<div className="p-6">
<div className="flex items-center mb-4">
<div className="p-2 rounded-full mr-3 bg-yellow-100 dark:bg-yellow-900">
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
File Already Exists
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
A PRD file named "{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}" already exists.
Do you want to overwrite it with the current content?
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setShowOverwriteConfirm(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
setShowOverwriteConfirm(false);
await performSave();
}}
className="px-4 py-2 text-sm text-white bg-yellow-600 hover:bg-yellow-700 rounded-md flex items-center space-x-2 transition-colors"
>
<Save className="w-4 h-4" />
<span>Overwrite</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default PRDEditor;

View File

@@ -1,875 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const { t } = useTranslation();
// Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
// Form state
const [workspacePath, setWorkspacePath] = useState('');
const [githubUrl, setGithubUrl] = useState('');
const [selectedGithubToken, setSelectedGithubToken] = useState('');
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
const [newGithubToken, setNewGithubToken] = useState('');
// UI state
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState(null);
const [availableTokens, setAvailableTokens] = useState([]);
const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
const [cloneProgress, setCloneProgress] = useState('');
// Load available GitHub tokens when needed
useEffect(() => {
if (step === 2 && workspaceType === 'new' && githubUrl) {
loadGithubTokens();
}
}, [step, workspaceType, githubUrl]);
// Load path suggestions
useEffect(() => {
if (workspacePath.length > 2) {
loadPathSuggestions(workspacePath);
} else {
setPathSuggestions([]);
setShowPathDropdown(false);
}
}, [workspacePath]);
const loadGithubTokens = async () => {
try {
setLoadingTokens(true);
const response = await api.get('/settings/credentials?type=github_token');
const data = await response.json();
const activeTokens = (data.credentials || []).filter(t => t.is_active);
setAvailableTokens(activeTokens);
// Auto-select first token if available
if (activeTokens.length > 0 && !selectedGithubToken) {
setSelectedGithubToken(activeTokens[0].id.toString());
}
} catch (error) {
console.error('Error loading GitHub tokens:', error);
} finally {
setLoadingTokens(false);
}
};
const loadPathSuggestions = async (inputPath) => {
try {
// Extract the directory to browse (parent of input)
const lastSlash = inputPath.lastIndexOf('/');
const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
const response = await api.browseFilesystem(dirPath);
const data = await response.json();
if (data.suggestions) {
// Filter suggestions based on the input, excluding exact match
const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
s.path.toLowerCase() !== inputPath.toLowerCase()
);
setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0);
}
} catch (error) {
console.error('Error loading path suggestions:', error);
}
};
const handleNext = () => {
setError(null);
if (step === 1) {
if (!workspaceType) {
setError(t('projectWizard.errors.selectType'));
return;
}
setStep(2);
} else if (step === 2) {
if (!workspacePath.trim()) {
setError(t('projectWizard.errors.providePath'));
return;
}
// No validation for GitHub token - it's optional (only needed for private repos)
setStep(3);
}
};
const handleBack = () => {
setError(null);
setStep(step - 1);
};
const handleCreate = async () => {
setIsCreating(true);
setError(null);
setCloneProgress('');
try {
if (workspaceType === 'new' && githubUrl) {
const params = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
params.append('githubTokenId', selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
params.append('newGithubToken', newGithubToken.trim());
}
const token = localStorage.getItem('auth-token');
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
await new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setCloneProgress(data.message);
} else if (data.type === 'complete') {
eventSource.close();
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
resolve();
} else if (data.type === 'error') {
eventSource.close();
reject(new Error(data.message));
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
reject(new Error('Connection lost during clone'));
};
});
return;
}
const payload = {
workspaceType,
path: workspacePath.trim(),
};
const response = await api.createWorkspace(payload);
const data = await response.json();
if (!response.ok) {
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
}
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
} catch (error) {
console.error('Error creating workspace:', error);
setError(error.message || t('projectWizard.errors.failedToCreate'));
} finally {
setIsCreating(false);
}
};
const selectPathSuggestion = (suggestion) => {
setWorkspacePath(suggestion.path);
setShowPathDropdown(false);
};
const openFolderBrowser = async () => {
setShowFolderBrowser(true);
await loadBrowserFolders('~');
};
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
const response = await api.browseFilesystem(path);
const data = await response.json();
setBrowserCurrentPath(data.path || path);
setBrowserFolders(data.suggestions || []);
} catch (error) {
console.error('Error loading folders:', error);
} finally {
setLoadingFolders(false);
}
};
const selectFolder = (folderPath, advanceToConfirm = false) => {
setWorkspacePath(folderPath);
setShowFolderBrowser(false);
if (advanceToConfirm) {
setStep(3);
}
};
const navigateToFolder = async (folderPath) => {
await loadBrowserFolders(folderPath);
};
const createNewFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
setError(null);
try {
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
const response = await api.createFolder(folderPath);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
}
setNewFolderName('');
setShowNewFolderInput(false);
await loadBrowserFolders(data.path || folderPath);
} catch (error) {
console.error('Error creating folder:', error);
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
} finally {
setCreatingFolder(false);
}
};
return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('projectWizard.title')}
</h3>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
disabled={isCreating}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Indicator */}
<div className="px-6 pt-4 pb-2">
<div className="flex items-center justify-between">
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div className="flex items-center gap-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
s < step
? 'bg-green-500 text-white'
: s === step
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
{s < step ? <Check className="w-4 h-4" /> : s}
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{s === 1 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
</span>
</div>
{s < 3 && (
<div
className={`flex-1 h-1 mx-2 rounded ${
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6 min-h-[300px]">
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{/* Step 1: Choose workspace type */}
{step === 1 && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('projectWizard.step1.question')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Workspace */}
<button
onClick={() => setWorkspaceType('existing')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
workspaceType === 'existing'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('projectWizard.step1.existing.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step1.existing.description')}
</p>
</div>
</div>
</button>
{/* New Workspace */}
<button
onClick={() => setWorkspaceType('new')}
className={`p-4 border-2 rounded-lg text-left transition-all ${
workspaceType === 'new'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('projectWizard.step1.new.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step1.new.description')}
</p>
</div>
</div>
</button>
</div>
</div>
</div>
)}
{/* Step 2: Configure workspace */}
{step === 2 && (
<div className="space-y-4">
{/* Workspace Path */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? t('projectWizard.step2.existingPath') : t('projectWizard.step2.newPath')}
</label>
<div className="relative flex gap-2">
<div className="flex-1 relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={openFolderBrowser}
className="px-3"
title="Browse folders"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
? t('projectWizard.step2.existingHelp')
: t('projectWizard.step2.newHelp')}
</p>
</div>
{/* GitHub URL (only for new workspace) */}
{workspaceType === 'new' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.githubUrl')}
</label>
<Input
type="text"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/username/repository"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.githubHelp')}
</p>
</div>
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-3 mb-4">
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
{t('projectWizard.step2.githubAuth')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step2.githubAuthHelp')}
</p>
</div>
</div>
{loadingTokens ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t('projectWizard.step2.loadingTokens')}
</div>
) : availableTokens.length > 0 ? (
<>
{/* Token Selection Tabs */}
<div className="grid grid-cols-3 gap-2 mb-4">
<button
onClick={() => setTokenMode('stored')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'stored'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{t('projectWizard.step2.storedToken')}
</button>
<button
onClick={() => setTokenMode('new')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'new'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{t('projectWizard.step2.newToken')}
</button>
<button
onClick={() => {
setTokenMode('none');
setSelectedGithubToken('');
setNewGithubToken('');
}}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
tokenMode === 'none'
? 'bg-green-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{t('projectWizard.step2.nonePublic')}
</button>
</div>
{tokenMode === 'stored' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.selectToken')}
</label>
<select
value={selectedGithubToken}
onChange={(e) => setSelectedGithubToken(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
>
<option value="">{t('projectWizard.step2.selectTokenPlaceholder')}</option>
{availableTokens.map((token) => (
<option key={token.id} value={token.id}>
{token.credential_name}
</option>
))}
</select>
</div>
) : tokenMode === 'new' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.newToken')}
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.tokenHelp')}
</p>
</div>
) : null}
</>
) : (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
{t('projectWizard.step2.publicRepoInfo')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('projectWizard.step2.optionalTokenPublic')}
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.noTokensHelp')}
</p>
</div>
</div>
)}
</div>
)}
</>
)}
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t('projectWizard.step3.reviewConfig')}
</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.workspaceType')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{workspacePath}
</span>
</div>
{workspaceType === 'new' && githubUrl && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.cloneFrom')}</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{githubUrl}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.authentication')}</span>
<span className="text-xs text-gray-900 dark:text-white">
{tokenMode === 'stored' && selectedGithubToken
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken
? t('projectWizard.step3.usingProvidedToken')
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
? t('projectWizard.step3.sshKey', 'SSH Key')
: t('projectWizard.step3.noAuthentication')}
</span>
</div>
</>
)}
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
{isCreating && cloneProgress ? (
<div className="space-y-2">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
{cloneProgress}
</code>
</div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? t('projectWizard.step3.existingInfo')
: githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
</p>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={step === 1 ? onClose : handleBack}
disabled={isCreating}
>
{step === 1 ? (
t('projectWizard.buttons.cancel')
) : (
<>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('projectWizard.buttons.back')}
</>
)}
</Button>
<Button
onClick={step === 3 ? handleCreate : handleNext}
disabled={isCreating || (step === 1 && !workspaceType)}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
</>
) : step === 3 ? (
<>
<Check className="w-4 h-4 mr-1" />
{t('projectWizard.buttons.createProject')}
</>
) : (
<>
{t('projectWizard.buttons.next')}
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
{/* Browser Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Folder
</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
className={`p-2 rounded-md transition-colors ${
showHiddenFolders
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
>
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button>
<button
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
className={`p-2 rounded-md transition-colors ${
showNewFolderInput
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Create new folder"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={() => setShowFolderBrowser(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* New Folder Input */}
{showNewFolderInput && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2">
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') createNewFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
autoFocus
/>
<Button
size="sm"
onClick={createNewFolder}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Folder List */}
<div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : (
<div className="space-y-1">
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
<button
onClick={() => {
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
let parentPath;
if (lastSlash <= 0) {
parentPath = '/';
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
parentPath = browserCurrentPath.substring(0, 3);
} else {
parentPath = browserCurrentPath.substring(0, lastSlash);
}
navigateToFolder(parentPath);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderOpen className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
</button>
)}
{/* Folders */}
{browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No subfolders found
</div>
) : (
browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
className="text-xs px-3"
>
Select
</Button>
</div>
))
)}
</div>
)}
</div>
{/* Browser Footer with Current Path */}
<div className="border-t border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
{browserCurrentPath}
</code>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<Button
variant="outline"
onClick={() => {
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
>
Use this folder
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ProjectCreationWizard;

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import Onboarding from './Onboarding';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../constants/config';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
);
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (IS_PLATFORM) {
if (isLoading) {
return <LoadingScreen />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return children;
}
if (isLoading) {
return <LoadingScreen />;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -1,448 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
Settings2,
Moon,
Sun,
ArrowDown,
Mic,
Brain,
Sparkles,
FileText,
Languages,
GripVertical
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from './DarkModeToggle';
import { useUiPreferences } from '../hooks/useUiPreferences';
import { useTheme } from '../contexts/ThemeContext';
import LanguageSelector from './LanguageSelector';
import { useDeviceSettings } from '../hooks/useDeviceSettings';
const QuickSettingsPanel = () => {
const { t } = useTranslation('settings');
const [isOpen, setIsOpen] = useState(false);
const [whisperMode, setWhisperMode] = useState(() => {
return localStorage.getItem('whisperMode') || 'default';
});
const { isDarkMode } = useTheme();
const { isMobile } = useDeviceSettings({ trackPWA: false });
const { preferences, setPreference } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed.y ?? 50;
} catch {
// Remove corrupted data
localStorage.removeItem('quickSettingsHandlePosition');
return 50;
}
}
return 50; // Default to 50% (middle of screen)
});
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
const handleRef = useRef(null);
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
}, [handlePosition]);
// Calculate position from percentage
const getPositionStyle = useCallback(() => {
if (isMobile) {
// On mobile, convert percentage to pixels from bottom
const bottomPixels = (window.innerHeight * handlePosition) / 100;
return { bottom: `${bottomPixels}px` };
} else {
// On desktop, use top with percentage
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
}
}, [handlePosition, isMobile]);
// Handle mouse/touch start
const handleDragStart = useCallback((e) => {
// Don't prevent default yet - we want to allow click if no drag happens
e.stopPropagation();
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
setDragStartY(clientY);
setDragStartPosition(handlePosition);
setHasMoved(false);
setIsDragging(false); // Don't set dragging until threshold is passed
}, [handlePosition]);
// Handle mouse/touch move
const handleDragMove = useCallback((e) => {
if (dragStartY === 0) return; // Not in a potential drag
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaY = Math.abs(clientY - dragStartY);
// Check if we've moved past threshold
if (!isDragging && deltaY > dragThreshold) {
setIsDragging(true);
setHasMoved(true);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
// Prevent body scroll on mobile during drag
if (e.type.includes('touch')) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
}
if (!isDragging) return;
// Prevent scrolling on touch move
if (e.type.includes('touch')) {
e.preventDefault();
}
const actualDeltaY = clientY - dragStartY;
// For top-based positioning (desktop), moving down increases top percentage
// For bottom-based positioning (mobile), we need to invert
let percentageDelta;
if (isMobile) {
// On mobile, moving down should decrease bottom position (increase percentage from top)
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
} else {
// On desktop, moving down should increase top position
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
}
let newPosition = dragStartPosition + percentageDelta;
// Apply constraints
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
setHandlePosition(newPosition);
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
// Handle mouse/touch end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragStartY(0);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restore body scroll on mobile
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}, []);
// Cleanup body styles on unmount in case component unmounts while dragging
useEffect(() => {
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, []);
// Set up global event listeners for drag
useEffect(() => {
if (dragStartY !== 0) {
// Mouse events
const handleMouseMove = (e) => handleDragMove(e);
const handleMouseUp = () => handleDragEnd();
// Touch events
const handleTouchMove = (e) => handleDragMove(e);
const handleTouchEnd = () => handleDragEnd();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [dragStartY, handleDragMove, handleDragEnd]);
const handleToggle = (e) => {
// Don't toggle if user was dragging
if (hasMoved) {
e.preventDefault();
setHasMoved(false);
return;
}
setIsOpen((previous) => !previous);
};
return (
<>
{/* Pull Tab - Combined drag handle and toggle button */}
<button
ref={handleRef}
onClick={handleToggle}
onMouseDown={(e) => {
// Start drag on mousedown
handleDragStart(e);
}}
onTouchStart={(e) => {
// Start drag on touchstart
handleDragStart(e);
}}
className={`fixed ${
isOpen ? 'right-64' : 'right-0'
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
) : isOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* Panel */}
<div
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
} ${isMobile ? 'h-screen' : ''}`}
>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
{t('quickSettings.title')}
</h3>
</div>
{/* Settings Content */}
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
{/* Appearance Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
{t('quickSettings.darkMode')}
</span>
<DarkModeToggle />
</div>
{/* Language Selector */}
<div>
<LanguageSelector compact={true} />
</div>
</div>
{/* Tool Display Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.autoExpandTools')}
</span>
<input
type="checkbox"
checked={autoExpandTools}
onChange={(e) => setPreference('autoExpandTools', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.showRawParameters')}
</span>
<input
type="checkbox"
checked={showRawParameters}
onChange={(e) => setPreference('showRawParameters', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.showThinking')}
</span>
<input
type="checkbox"
checked={showThinking}
onChange={(e) => setPreference('showThinking', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
{/* View Options */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.autoScrollToBottom')}
</span>
<input
type="checkbox"
checked={autoScrollToBottom}
onChange={(e) => setPreference('autoScrollToBottom', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.sendByCtrlEnter')}
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => setPreference('sendByCtrlEnter', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
{t('quickSettings.sendByCtrlEnterDescription')}
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
<div className="space-y-2">
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<input
type="radio"
name="whisperMode"
value="default"
checked={whisperMode === 'default'}
onChange={() => {
setWhisperMode('default');
localStorage.setItem('whisperMode', 'default');
window.dispatchEvent(new Event('whisperModeChanged'));
}}
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.whisper.modes.default')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('quickSettings.whisper.modes.defaultDescription')}
</p>
</div>
</label>
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<input
type="radio"
name="whisperMode"
value="prompt"
checked={whisperMode === 'prompt'}
onChange={() => {
setWhisperMode('prompt');
localStorage.setItem('whisperMode', 'prompt');
window.dispatchEvent(new Event('whisperModeChanged'));
}}
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.whisper.modes.prompt')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('quickSettings.whisper.modes.promptDescription')}
</p>
</div>
</label>
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<input
type="radio"
name="whisperMode"
value="vibe"
checked={whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect'}
onChange={() => {
setWhisperMode('vibe');
localStorage.setItem('whisperMode', 'vibe');
window.dispatchEvent(new Event('whisperModeChanged'));
}}
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('quickSettings.whisper.modes.vibe')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('quickSettings.whisper.modes.vibeDescription')}
</p>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
onClick={handleToggle}
/>
)}
</>
);
};
export default QuickSettingsPanel;

View File

@@ -1,133 +0,0 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
const SetupForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { register } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
setIsLoading(true);
const result = await register(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
<p className="text-muted-foreground mt-2">
Set up your account to get started
</p>
</div>
{/* Setup Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Setting up...' : 'Create Account'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
This is a single-user system. Only one account can be created.
</p>
</div>
</div>
</div>
</div>
);
};
export default SetupForm;

View File

@@ -1,210 +0,0 @@
import React from 'react';
import { Clock, CheckCircle, Circle, AlertCircle, Pause, X, ArrowRight, ChevronUp, Minus, Flag } from 'lucide-react';
import { cn } from '../lib/utils';
import Tooltip from './Tooltip';
const TaskCard = ({
task,
onClick,
showParent = false,
className = ''
}) => {
const getStatusConfig = (status) => {
switch (status) {
case 'done':
return {
icon: CheckCircle,
bgColor: 'bg-green-50 dark:bg-green-950',
borderColor: 'border-green-200 dark:border-green-800',
iconColor: 'text-green-600 dark:text-green-400',
textColor: 'text-green-900 dark:text-green-100',
statusText: 'Done'
};
case 'in-progress':
return {
icon: Clock,
bgColor: 'bg-blue-50 dark:bg-blue-950',
borderColor: 'border-blue-200 dark:border-blue-800',
iconColor: 'text-blue-600 dark:text-blue-400',
textColor: 'text-blue-900 dark:text-blue-100',
statusText: 'In Progress'
};
case 'review':
return {
icon: AlertCircle,
bgColor: 'bg-amber-50 dark:bg-amber-950',
borderColor: 'border-amber-200 dark:border-amber-800',
iconColor: 'text-amber-600 dark:text-amber-400',
textColor: 'text-amber-900 dark:text-amber-100',
statusText: 'Review'
};
case 'deferred':
return {
icon: Pause,
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-gray-200 dark:border-gray-700',
iconColor: 'text-gray-500 dark:text-gray-400',
textColor: 'text-gray-700 dark:text-gray-300',
statusText: 'Deferred'
};
case 'cancelled':
return {
icon: X,
bgColor: 'bg-red-50 dark:bg-red-950',
borderColor: 'border-red-200 dark:border-red-800',
iconColor: 'text-red-600 dark:text-red-400',
textColor: 'text-red-900 dark:text-red-100',
statusText: 'Cancelled'
};
case 'pending':
default:
return {
icon: Circle,
bgColor: 'bg-slate-50 dark:bg-slate-800',
borderColor: 'border-slate-200 dark:border-slate-700',
iconColor: 'text-slate-500 dark:text-slate-400',
textColor: 'text-slate-900 dark:text-slate-100',
statusText: 'Pending'
};
}
};
const config = getStatusConfig(task.status);
const Icon = config.icon;
const getPriorityIcon = (priority) => {
switch (priority) {
case 'high':
return (
<Tooltip content="High Priority">
<div className="w-4 h-4 bg-red-100 dark:bg-red-900/30 rounded flex items-center justify-center">
<ChevronUp className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</div>
</Tooltip>
);
case 'medium':
return (
<Tooltip content="Medium Priority">
<div className="w-4 h-4 bg-amber-100 dark:bg-amber-900/30 rounded flex items-center justify-center">
<Minus className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
</div>
</Tooltip>
);
case 'low':
return (
<Tooltip content="Low Priority">
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center">
<Circle className="w-1.5 h-1.5 text-blue-600 dark:text-blue-400 fill-current" />
</div>
</Tooltip>
);
default:
return (
<Tooltip content="No Priority Set">
<div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
<Circle className="w-1.5 h-1.5 text-gray-400 dark:text-gray-500" />
</div>
</Tooltip>
);
}
};
return (
<div
className={cn(
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200 cursor-pointer',
'p-3 space-y-3',
onClick && 'hover:-translate-y-0.5',
className
)}
onClick={onClick}
>
{/* Header with Task ID, Title, and Priority */}
<div className="flex items-start justify-between gap-2 mb-2">
{/* Task ID and Title */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Tooltip content={`Task ID: ${task.id}`}>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{task.id}
</span>
</Tooltip>
</div>
<h3 className="font-medium text-sm text-gray-900 dark:text-white line-clamp-2 leading-tight">
{task.title}
</h3>
{showParent && task.parentId && (
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
Task {task.parentId}
</span>
)}
</div>
{/* Priority Icon */}
<div className="flex-shrink-0">
{getPriorityIcon(task.priority)}
</div>
</div>
{/* Footer with Dependencies and Status */}
<div className="flex items-center justify-between">
{/* Dependencies */}
<div className="flex items-center">
{task.dependencies && Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
<Tooltip content={`Depends on: ${task.dependencies.map(dep => `Task ${dep}`).join(', ')}`}>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<ArrowRight className="w-3 h-3" />
<span>Depends on: {task.dependencies.join(', ')}</span>
</div>
</Tooltip>
)}
</div>
{/* Status Badge */}
<Tooltip content={`Status: ${config.statusText}`}>
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-full', config.iconColor.replace('text-', 'bg-'))} />
<span className={cn('text-xs font-medium', config.textColor)}>
{config.statusText}
</span>
</div>
</Tooltip>
</div>
{/* Subtask Progress (if applicable) */}
{task.subtasks && task.subtasks.length > 0 && (
<div className="ml-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} of ${task.subtasks.length} subtasks completed`}>
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
task.status === 'done' ? 'bg-green-500' : 'bg-blue-500'
)}
style={{
width: `${Math.round((task.subtasks.filter(st => st.status === 'done').length / task.subtasks.length) * 100)}%`
}}
/>
</div>
</Tooltip>
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} completed, ${task.subtasks.filter(st => st.status === 'pending').length} pending, ${task.subtasks.filter(st => st.status === 'in-progress').length} in progress`}>
<span className="text-xs text-gray-500 dark:text-gray-400">
{task.subtasks.filter(st => st.status === 'done').length}/{task.subtasks.length}
</span>
</Tooltip>
</div>
</div>
)}
</div>
);
};
export default TaskCard;

View File

@@ -1,407 +0,0 @@
import React, { useState } from 'react';
import { X, Flag, User, ArrowRight, CheckCircle, Circle, AlertCircle, Pause, Edit, Save, Copy, ChevronDown, ChevronRight, Clock } from 'lucide-react';
import { cn } from '../lib/utils';
import TaskIndicator from './TaskIndicator';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskDetail = ({
task,
onClose,
onEdit,
onStatusChange,
onTaskClick,
isOpen = true,
className = ''
}) => {
const [editMode, setEditMode] = useState(false);
const [editedTask, setEditedTask] = useState(task || {});
const [isSaving, setIsSaving] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showTestStrategy, setShowTestStrategy] = useState(false);
const { currentProject, refreshTasks } = useTaskMaster();
if (!isOpen || !task) return null;
const handleSave = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
// Only include changed fields
const updates = {};
if (editedTask.title !== task.title) updates.title = editedTask.title;
if (editedTask.description !== task.description) updates.description = editedTask.description;
if (editedTask.details !== task.details) updates.details = editedTask.details;
if (Object.keys(updates).length > 0) {
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
if (response.ok) {
// Refresh tasks to get updated data
refreshTasks?.();
onEdit?.(editedTask);
setEditMode(false);
} else {
const error = await response.json();
console.error('Failed to update task:', error);
alert(`Failed to update task: ${error.message}`);
}
} else {
setEditMode(false);
}
} catch (error) {
console.error('Error updating task:', error);
alert('Error updating task. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleStatusChange = async (newStatus) => {
if (!currentProject) return;
try {
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: newStatus });
if (response.ok) {
refreshTasks?.();
onStatusChange?.(task.id, newStatus);
} else {
const error = await response.json();
console.error('Failed to update task status:', error);
alert(`Failed to update task status: ${error.message}`);
}
} catch (error) {
console.error('Error updating task status:', error);
alert('Error updating task status. Please try again.');
}
};
const copyTaskId = () => {
copyTextToClipboard(task.id.toString());
};
const getStatusConfig = (status) => {
switch (status) {
case 'done':
return { icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-950' };
case 'in-progress':
return { icon: Clock, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950' };
case 'review':
return { icon: AlertCircle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950' };
case 'deferred':
return { icon: Pause, color: 'text-gray-500 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-800' };
case 'cancelled':
return { icon: X, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' };
default:
return { icon: Circle, color: 'text-slate-500 dark:text-slate-400', bg: 'bg-slate-50 dark:bg-slate-800' };
}
};
const statusConfig = getStatusConfig(task.status);
const StatusIcon = statusConfig.icon;
const getPriorityColor = (priority) => {
switch (priority) {
case 'high': return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
case 'medium': return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
case 'low': return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
default: return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
}
};
const statusOptions = [
{ value: 'pending', label: 'Pending' },
{ value: 'in-progress', label: 'In Progress' },
{ value: 'review', label: 'Review' },
{ value: 'done', label: 'Done' },
{ value: 'deferred', label: 'Deferred' },
{ value: 'cancelled', label: 'Cancelled' }
];
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
<div className={cn(
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
className
)}>
{/* Header */}
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusIcon className={cn('w-6 h-6', statusConfig.color)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<button
onClick={copyTaskId}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="Click to copy task ID"
>
<span>Task {task.id}</span>
<Copy className="w-3 h-3" />
</button>
{task.parentId && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Subtask of Task {task.parentId}
</span>
)}
</div>
{editMode ? (
<input
type="text"
value={editedTask.title || ''}
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
className="w-full text-lg font-semibold bg-transparent border-b-2 border-blue-500 focus:outline-none text-gray-900 dark:text-white"
placeholder="Task title"
/>
) : (
<h1 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-white line-clamp-2">
{task.title}
</h1>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{editMode ? (
<>
<button
onClick={handleSave}
disabled={isSaving}
className="p-2 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={isSaving ? "Saving..." : "Save changes"}
>
<Save className={cn("w-5 h-5", isSaving && "animate-spin")} />
</button>
<button
onClick={() => {
setEditMode(false);
setEditedTask(task);
}}
disabled={isSaving}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel editing"
>
<X className="w-5 h-5" />
</button>
</>
) : (
<button
onClick={() => setEditMode(true)}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
title="Edit task"
>
<Edit className="w-5 h-5" />
</button>
)}
<button
onClick={onClose}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
title="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 min-h-0">
{/* Status and Metadata Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Status */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<div className={cn(
'w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600',
statusConfig.bg,
statusConfig.color
)}>
<div className="flex items-center gap-2">
<StatusIcon className="w-4 h-4" />
<span className="font-medium capitalize">
{statusOptions.find(option => option.value === task.status)?.label || task.status}
</span>
</div>
</div>
</div>
{/* Priority */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<div className={cn(
'px-3 py-2 rounded-md text-sm font-medium capitalize',
getPriorityColor(task.priority)
)}>
<Flag className="w-4 h-4 inline mr-2" />
{task.priority || 'Not set'}
</div>
</div>
{/* Dependencies */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
{task.dependencies && task.dependencies.length > 0 ? (
<div className="flex flex-wrap gap-1">
{task.dependencies.map(depId => (
<button
key={depId}
onClick={() => onTaskClick && onTaskClick({ id: depId })}
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-sm hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
disabled={!onTaskClick}
title={onTaskClick ? `Click to view Task ${depId}` : `Task ${depId}`}
>
<ArrowRight className="w-3 h-3 inline mr-1" />
{depId}
</button>
))}
</div>
) : (
<span className="text-gray-500 dark:text-gray-400 text-sm">No dependencies</span>
)}
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
{editMode ? (
<textarea
value={editedTask.description || ''}
onChange={(e) => setEditedTask({ ...editedTask, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Task description"
/>
) : (
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{task.description || 'No description provided'}
</p>
)}
</div>
{/* Implementation Details */}
{task.details && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
<button
onClick={() => setShowDetails(!showDetails)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Implementation Details
</span>
{showDetails ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
{showDetails && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
{editMode ? (
<textarea
value={editedTask.details || ''}
onChange={(e) => setEditedTask({ ...editedTask, details: e.target.value })}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Implementation details"
/>
) : (
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{task.details}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Test Strategy */}
{task.testStrategy && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
<button
onClick={() => setShowTestStrategy(!showTestStrategy)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Test Strategy
</span>
{showTestStrategy ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
{showTestStrategy && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
<div className="bg-blue-50 dark:bg-blue-950 rounded-md p-4">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{task.testStrategy}
</p>
</div>
</div>
)}
</div>
)}
{/* Subtasks */}
{task.subtasks && task.subtasks.length > 0 && (
<div className="space-y-3">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Subtasks ({task.subtasks.length})
</label>
<div className="space-y-2">
{task.subtasks.map(subtask => {
const subtaskConfig = getStatusConfig(subtask.status);
const SubtaskIcon = subtaskConfig.icon;
return (
<div key={subtask.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<SubtaskIcon className={cn('w-4 h-4', subtaskConfig.color)} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white truncate">
{subtask.title}
</h4>
{subtask.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{subtask.description}
</p>
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{subtask.id}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="text-sm text-gray-500 dark:text-gray-400">
Task ID: {task.id}
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default TaskDetail;

View File

@@ -1,108 +0,0 @@
import React from 'react';
import { CheckCircle, Settings, X, AlertCircle } from 'lucide-react';
import { cn } from '../lib/utils';
/**
* TaskIndicator Component
*
* Displays TaskMaster status for projects in the sidebar with appropriate
* icons and colors based on the project's TaskMaster configuration state.
*/
const TaskIndicator = ({
status = 'not-configured',
size = 'sm',
className = '',
showLabel = false
}) => {
const getIndicatorConfig = () => {
switch (status) {
case 'fully-configured':
return {
icon: CheckCircle,
color: 'text-green-500 dark:text-green-400',
bgColor: 'bg-green-50 dark:bg-green-950',
label: 'TaskMaster Ready',
title: 'TaskMaster fully configured with MCP server'
};
case 'taskmaster-only':
return {
icon: Settings,
color: 'text-blue-500 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-950',
label: 'TaskMaster Init',
title: 'TaskMaster initialized, MCP server needs setup'
};
case 'mcp-only':
return {
icon: AlertCircle,
color: 'text-amber-500 dark:text-amber-400',
bgColor: 'bg-amber-50 dark:bg-amber-950',
label: 'MCP Ready',
title: 'MCP server configured, TaskMaster needs initialization'
};
case 'not-configured':
case 'error':
default:
return {
icon: X,
color: 'text-gray-400 dark:text-gray-500',
bgColor: 'bg-gray-50 dark:bg-gray-900',
label: 'No TaskMaster',
title: 'TaskMaster not configured'
};
}
};
const config = getIndicatorConfig();
const Icon = config.icon;
const sizeClasses = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
};
const paddingClasses = {
xs: 'p-0.5',
sm: 'p-1',
md: 'p-1.5',
lg: 'p-2'
};
if (showLabel) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors',
config.bgColor,
config.color,
className
)}
title={config.title}
>
<Icon className={sizeClasses[size]} />
<span className="font-medium">{config.label}</span>
</div>
);
}
return (
<div
className={cn(
'inline-flex items-center justify-center rounded-full transition-colors',
config.bgColor,
paddingClasses[size],
className
)}
title={config.title}
>
<Icon className={cn(sizeClasses[size], config.color)} />
</div>
);
};
export default TaskIndicator;

File diff suppressed because it is too large Load Diff

View File

@@ -1,604 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { api } from '../utils/api';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskMasterSetupWizard = ({
isOpen = true,
onClose,
onComplete,
currentProject,
className = ''
}) => {
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [setupData, setSetupData] = useState({
projectRoot: '',
initGit: true,
storeTasksInGit: true,
addAliases: true,
skipInstall: false,
rules: ['claude'],
mcpConfigured: false,
prdContent: ''
});
const totalSteps = 4;
useEffect(() => {
if (currentProject) {
setSetupData(prev => ({
...prev,
projectRoot: currentProject.path || ''
}));
}
}, [currentProject]);
const steps = [
{
id: 1,
title: 'Project Configuration',
description: 'Configure basic TaskMaster settings for your project'
},
{
id: 2,
title: 'MCP Server Setup',
description: 'Ensure TaskMaster MCP server is properly configured'
},
{
id: 3,
title: 'PRD Creation',
description: 'Create or import a Product Requirements Document'
},
{
id: 4,
title: 'Complete Setup',
description: 'Initialize TaskMaster and generate initial tasks'
}
];
const handleNext = async () => {
setError(null);
try {
if (currentStep === 1) {
// Validate project configuration
if (!setupData.projectRoot) {
setError('Project root path is required');
return;
}
setCurrentStep(2);
} else if (currentStep === 2) {
// Check MCP server status
setLoading(true);
try {
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
setSetupData(prev => ({
...prev,
mcpConfigured: mcpStatus.hasMCPServer && mcpStatus.isConfigured
}));
setCurrentStep(3);
} catch (err) {
setError('Failed to check MCP server status. You can continue but some features may not work.');
setCurrentStep(3);
}
} else if (currentStep === 3) {
// Validate PRD step
if (!setupData.prdContent.trim()) {
setError('Please create or import a PRD to continue');
return;
}
setCurrentStep(4);
} else if (currentStep === 4) {
// Complete setup
await completeSetup();
}
} catch (err) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
setError(null);
}
};
const completeSetup = async () => {
setLoading(true);
try {
// Initialize TaskMaster project
const initResponse = await api.post('/taskmaster/initialize', {
projectRoot: setupData.projectRoot,
initGit: setupData.initGit,
storeTasksInGit: setupData.storeTasksInGit,
addAliases: setupData.addAliases,
skipInstall: setupData.skipInstall,
rules: setupData.rules,
yes: true
});
if (!initResponse.ok) {
throw new Error('Failed to initialize TaskMaster project');
}
// Save PRD content if provided
if (setupData.prdContent.trim()) {
const prdResponse = await api.post('/taskmaster/save-prd', {
projectRoot: setupData.projectRoot,
content: setupData.prdContent
});
if (!prdResponse.ok) {
console.warn('Failed to save PRD content');
}
}
// Parse PRD to generate initial tasks
if (setupData.prdContent.trim()) {
const parseResponse = await api.post('/taskmaster/parse-prd', {
projectRoot: setupData.projectRoot,
input: '.taskmaster/docs/prd.txt',
numTasks: '10',
research: false,
force: false
});
if (!parseResponse.ok) {
console.warn('Failed to parse PRD and generate tasks');
}
}
onComplete?.();
onClose?.();
} catch (err) {
setError(err.message || 'Failed to complete TaskMaster setup');
} finally {
setLoading(false);
}
};
const copyMCPConfig = () => {
const mcpConfig = `{
"mcpServers": {
"": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
}
}
}
}`;
copyTextToClipboard(mcpConfig);
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<div className="space-y-6">
<div className="text-center">
<Settings className="w-12 h-12 text-blue-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Project Configuration
</h3>
<p className="text-gray-600 dark:text-gray-400">
Configure TaskMaster settings for your project
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Root Path
</label>
<input
type="text"
value={setupData.projectRoot}
onChange={(e) => setSetupData(prev => ({ ...prev, projectRoot: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="/path/to/your/project"
/>
</div>
<div className="space-y-3">
<h4 className="font-medium text-gray-900 dark:text-white">Options</h4>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={setupData.initGit}
onChange={(e) => setSetupData(prev => ({ ...prev, initGit: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Initialize Git repository</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={setupData.storeTasksInGit}
onChange={(e) => setSetupData(prev => ({ ...prev, storeTasksInGit: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Store tasks in Git</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={setupData.addAliases}
onChange={(e) => setSetupData(prev => ({ ...prev, addAliases: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Add shell aliases (tm, taskmaster)</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Rule Profiles
</label>
<div className="grid grid-cols-3 gap-2">
{['claude', 'cursor', 'vscode', 'roo', 'cline', 'windsurf'].map(rule => (
<label key={rule} className="flex items-center gap-2">
<input
type="checkbox"
checked={setupData.rules.includes(rule)}
onChange={(e) => {
if (e.target.checked) {
setSetupData(prev => ({ ...prev, rules: [...prev.rules, rule] }));
} else {
setSetupData(prev => ({ ...prev, rules: prev.rules.filter(r => r !== rule) }));
}
}}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">{rule}</span>
</label>
))}
</div>
</div>
</div>
</div>
);
case 2:
return (
<div className="space-y-6">
<div className="text-center">
<Server className="w-12 h-12 text-purple-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
MCP Server Setup
</h3>
<p className="text-gray-600 dark:text-gray-400">
TaskMaster works best with the MCP server configured
</p>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
MCP Server Configuration
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
To enable full TaskMaster integration, add the MCP server configuration to your Claude settings.
</p>
<div className="bg-white dark:bg-gray-800 rounded border p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-mono text-gray-600 dark:text-gray-400">.mcp.json</span>
<button
onClick={copyMCPConfig}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<Copy className="w-3 h-3" />
Copy
</button>
</div>
<pre className="text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
{`{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
}
}
}
}`}
</pre>
</div>
<div className="flex items-center gap-2 text-sm">
<a
href="https://docs.anthropic.com/en/docs/build-with-claude/tool-use/mcp-servers"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-1"
>
Learn about MCP setup
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Current Status</h4>
<div className="flex items-center gap-2">
{setupData.mcpConfigured ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm text-green-700 dark:text-green-300">MCP server is configured</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-amber-500" />
<span className="text-sm text-amber-700 dark:text-amber-300">MCP server not detected (optional)</span>
</>
)}
</div>
</div>
</div>
);
case 3:
return (
<div className="space-y-6">
<div className="text-center">
<FileText className="w-12 h-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Product Requirements Document
</h3>
<p className="text-gray-600 dark:text-gray-400">
Create or import a PRD to generate initial tasks
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
PRD Content
</label>
<textarea
value={setupData.prdContent}
onChange={(e) => setSetupData(prev => ({ ...prev, prdContent: e.target.value }))}
rows={12}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
placeholder="# Product Requirements Document
## 1. Overview
Describe your project or feature...
## 2. Objectives
- Primary goal
- Success metrics
## 3. User Stories
- As a user, I want...
## 4. Requirements
- Feature requirements
- Technical requirements
## 5. Implementation Plan
- Phase 1: Core features
- Phase 2: Enhancements"
/>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
AI Task Generation
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200">
TaskMaster will analyze your PRD and automatically generate a structured task list with dependencies, priorities, and implementation details.
</p>
</div>
</div>
</div>
</div>
</div>
);
case 4:
return (
<div className="space-y-6">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Complete Setup
</h3>
<p className="text-gray-600 dark:text-gray-400">
Ready to initialize TaskMaster for your project
</p>
</div>
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 className="font-medium text-green-900 dark:text-green-100 mb-3">
Setup Summary
</h4>
<ul className="space-y-2 text-sm text-green-800 dark:text-green-200">
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Project: {setupData.projectRoot}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Rules: {setupData.rules.join(', ')}
</li>
{setupData.mcpConfigured && (
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
MCP server configured
</li>
)}
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
PRD content ready ({setupData.prdContent.length} characters)
</li>
</ul>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
What happens next?
</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li>Initialize TaskMaster project structure</li>
<li>Save your PRD to <code>.taskmaster/docs/prd.txt</code></li>
<li>Generate initial tasks from your PRD</li>
<li>Set up project configuration and rules</li>
</ol>
</div>
</div>
);
default:
return null;
}
};
if (!isOpen) return null;
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
<div className={cn(
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
className
)}>
{/* Header */}
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3">
<Sparkles className="w-6 h-6 text-blue-600" />
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
TaskMaster Setup Wizard
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
Step {currentStep} of {totalSteps}: {steps[currentStep - 1]?.description}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
title="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Bar */}
<div className="px-4 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors',
currentStep > step.id
? 'bg-green-500 text-white'
: currentStep === step.id
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
)}>
{currentStep > step.id ? (
<CheckCircle className="w-4 h-4" />
) : (
step.id
)}
</div>
{index < steps.length - 1 && (
<div className={cn(
'w-16 h-1 mx-2 rounded',
currentStep > step.id
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
)} />
)}
</div>
))}
</div>
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
{steps.map(step => (
<span key={step.id} className="text-center">
{step.title}
</span>
))}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-6">
{renderStepContent()}
{error && (
<div className="mt-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" />
<div>
<h4 className="font-medium text-red-900 dark:text-red-100 mb-1">Error</h4>
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
onClick={handlePrevious}
disabled={currentStep === 1}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<div className="text-sm text-gray-500 dark:text-gray-400">
{currentStep} of {totalSteps}
</div>
<button
onClick={handleNext}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{currentStep === totalSteps ? 'Setting up...' : 'Processing...'}
</>
) : (
<>
{currentStep === totalSteps ? 'Complete Setup' : 'Next'}
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
};
export default TaskMasterSetupWizard;

View File

@@ -1,86 +0,0 @@
import React from 'react';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import TaskIndicator from './TaskIndicator';
const TaskMasterStatus = () => {
const {
currentProject,
projectTaskMaster,
mcpServerStatus,
isLoading,
isLoadingMCP,
error
} = useTaskMaster();
if (isLoading || isLoadingMCP) {
return (
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<div className="animate-spin w-3 h-3 border border-gray-300 border-t-blue-500 rounded-full mr-2"></div>
Loading TaskMaster status...
</div>
);
}
if (error) {
return (
<div className="flex items-center text-sm text-red-500 dark:text-red-400">
<span className="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
TaskMaster Error
</div>
);
}
// Show MCP server status
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
// Show project TaskMaster status
const projectConfigured = currentProject?.taskmaster?.hasTaskmaster;
const taskCount = currentProject?.taskmaster?.metadata?.taskCount || 0;
const completedCount = currentProject?.taskmaster?.metadata?.completed || 0;
if (!currentProject) {
return (
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<span className="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
No project selected
</div>
);
}
// Determine overall status for TaskIndicator
let overallStatus = 'not-configured';
if (projectConfigured && mcpConfigured) {
overallStatus = 'fully-configured';
} else if (projectConfigured) {
overallStatus = 'taskmaster-only';
} else if (mcpConfigured) {
overallStatus = 'mcp-only';
}
return (
<div className="flex items-center gap-3">
{/* TaskMaster Status Indicator */}
<TaskIndicator
status={overallStatus}
size="md"
showLabel={true}
/>
{/* Task Progress Info */}
{projectConfigured && (
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
{completedCount}/{taskCount} tasks
</span>
{taskCount > 0 && (
<span className="ml-2 opacity-75">
({Math.round((completedCount / taskCount) * 100)}%)
</span>
)}
</div>
)}
</div>
);
};
export default TaskMasterStatus;

View File

@@ -1,3 +0,0 @@
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
export default TasksSettingsTab;

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { Badge } from './ui/badge';
import { CheckCircle2, Clock, Circle } from 'lucide-react';
const TodoList = ({ todos, isResult = false }) => {
if (!todos || !Array.isArray(todos)) {
return null;
}
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />;
case 'in_progress':
return <Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />;
case 'pending':
default:
return <Circle className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800';
case 'in_progress':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800';
case 'pending':
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
}
};
const getPriorityColor = (priority) => {
switch (priority) {
case 'high':
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800';
case 'medium':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800';
case 'low':
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
}
};
return (
<div className="space-y-1.5">
{isResult && (
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div>
)}
{todos.map((todo, index) => (
<div
key={todo.id || `todo-${index}`}
className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-0.5">
<p className={`text-xs font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${getPriorityColor(todo.priority)}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${getStatusColor(todo.status)}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default TodoList;

View File

@@ -1,91 +0,0 @@
import React, { useState } from 'react';
import { cn } from '../lib/utils';
const Tooltip = ({
children,
content,
position = 'top',
className = '',
delay = 500
}) => {
const [isVisible, setIsVisible] = useState(false);
const [timeoutId, setTimeoutId] = useState(null);
const handleMouseEnter = () => {
const id = setTimeout(() => {
setIsVisible(true);
}, delay);
setTimeoutId(id);
};
const handleMouseLeave = () => {
if (timeoutId) {
clearTimeout(timeoutId);
setTimeoutId(null);
}
setIsVisible(false);
};
const getPositionClasses = () => {
switch (position) {
case 'top':
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
case 'bottom':
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
case 'left':
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
case 'right':
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
default:
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
}
};
const getArrowClasses = () => {
switch (position) {
case 'top':
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
case 'bottom':
return 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100';
case 'left':
return 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100';
case 'right':
return 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100';
default:
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
}
};
if (!content) {
return children;
}
return (
<div
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{isVisible && (
<div className={cn(
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
'animate-in fade-in-0 zoom-in-95 duration-200',
getPositionClasses(),
className
)}>
{content}
{/* Arrow */}
<div className={cn(
'absolute w-0 h-0 border-4 border-transparent',
getArrowClasses()
)} />
</div>
)}
</div>
);
};
export default Tooltip;

View File

@@ -1,15 +1,13 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Sidebar from '../sidebar/view/Sidebar'; import Sidebar from '../sidebar/view/Sidebar';
import MainContent from '../main-content/view/MainContent'; import MainContent from '../main-content/view/MainContent';
import MobileNav from '../MobileNav';
import { useWebSocket } from '../../contexts/WebSocketContext'; import { useWebSocket } from '../../contexts/WebSocketContext';
import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState'; import { useProjectsState } from '../../hooks/useProjectsState';
import MobileNav from './MobileNav';
export default function AppContent() { export default function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -98,7 +96,7 @@ export default function AppContent() {
</div> </div>
) : ( ) : (
<div <div
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible' className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'visible opacity-100' : 'invisible opacity-0'
}`} }`}
> >
<button <button
@@ -115,7 +113,7 @@ export default function AppContent() {
aria-label={t('versionUpdate.ariaLabels.closeSidebar')} aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/> />
<div <div
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border/40 transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full' className={`relative h-full w-[85vw] max-w-sm transform border-r border-border/40 bg-card transition-transform duration-150 ease-out sm:w-80 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`} }`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()}
@@ -125,7 +123,7 @@ export default function AppContent() {
</div> </div>
)} )}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}> <div className={`flex min-w-0 flex-1 flex-col ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent <MainContent
selectedProject={selectedProject} selectedProject={selectedProject}
selectedSession={selectedSession} selectedSession={selectedSession}

View File

@@ -1,9 +1,15 @@
import React from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react'; import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { Dispatch, SetStateAction } from 'react';
import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { AppTab } from '../../types/app';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) { type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
isInputFocused: boolean;
};
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
@@ -42,12 +48,11 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
return ( return (
<div <div
className={`fixed bottom-0 left-0 right-0 z-50 px-3 pb-[max(8px,env(safe-area-inset-bottom))] transform transition-transform duration-300 ease-in-out ${ className={`fixed bottom-0 left-0 right-0 z-50 transform px-3 pb-[max(8px,env(safe-area-inset-bottom))] transition-transform duration-300 ease-in-out ${isInputFocused ? 'translate-y-full' : 'translate-y-0'
isInputFocused ? 'translate-y-full' : 'translate-y-0' }`}
}`}
> >
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30"> <div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5"> <div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = activeTab === item.id; const isActive = activeTab === item.id;
@@ -60,19 +65,18 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
e.preventDefault(); e.preventDefault();
item.onClick(); item.onClick();
}} }}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${ 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
isActive ? 'text-primary'
? 'text-primary' : 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground hover:text-foreground' }`}
}`}
aria-label={item.label} aria-label={item.label}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
> >
{isActive && ( {isActive && (
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" /> <div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
)} )}
<Icon <Icon
className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`} className={`relative z-10 transition-all duration-200 ${isActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8} strokeWidth={isActive ? 2.4 : 1.8}
/> />
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}> <span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
@@ -86,5 +90,3 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
</div> </div>
); );
} }
export default MobileNav;

View File

@@ -0,0 +1,8 @@
export const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
export const AUTH_ERROR_MESSAGES = {
authStatusCheckFailed: 'Failed to check authentication status',
loginFailed: 'Login failed',
registrationFailed: 'Registration failed',
networkError: 'Network error. Please try again.',
} as const;

View File

@@ -0,0 +1,222 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { api } from '../../../utils/api';
import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants';
import type {
AuthContextValue,
AuthProviderProps,
AuthSessionPayload,
AuthStatusPayload,
AuthUser,
AuthUserPayload,
OnboardingStatusPayload,
} from '../types';
import { parseJsonSafely, resolveApiErrorMessage } from '../utils';
const AuthContext = createContext<AuthContextValue | null>(null);
const readStoredToken = (): string | null => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
const persistToken = (token: string) => {
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
};
const clearStoredToken = () => {
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
};
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(() => readStoredToken());
const [isLoading, setIsLoading] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true);
const [error, setError] = useState<string | null>(null);
const setSession = useCallback((nextUser: AuthUser, nextToken: string) => {
setUser(nextUser);
setToken(nextToken);
persistToken(nextToken);
}, []);
const clearSession = useCallback(() => {
setUser(null);
setToken(null);
clearStoredToken();
}, []);
const checkOnboardingStatus = useCallback(async () => {
try {
const response = await api.user.onboardingStatus();
if (!response.ok) {
return;
}
const payload = await parseJsonSafely<OnboardingStatusPayload>(response);
setHasCompletedOnboarding(Boolean(payload?.hasCompletedOnboarding));
} catch (caughtError) {
console.error('Error checking onboarding status:', caughtError);
// Fail open to avoid blocking access on transient onboarding status errors.
setHasCompletedOnboarding(true);
}
}, []);
const refreshOnboardingStatus = useCallback(async () => {
await checkOnboardingStatus();
}, [checkOnboardingStatus]);
const checkAuthStatus = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const statusResponse = await api.auth.status();
const statusPayload = await parseJsonSafely<AuthStatusPayload>(statusResponse);
if (statusPayload?.needsSetup) {
setNeedsSetup(true);
return;
}
setNeedsSetup(false);
if (!token) {
return;
}
const userResponse = await api.auth.user();
if (!userResponse.ok) {
clearSession();
return;
}
const userPayload = await parseJsonSafely<AuthUserPayload>(userResponse);
if (!userPayload?.user) {
clearSession();
return;
}
setUser(userPayload.user);
await checkOnboardingStatus();
} catch (caughtError) {
console.error('[Auth] Auth status check failed:', caughtError);
setError(AUTH_ERROR_MESSAGES.authStatusCheckFailed);
} finally {
setIsLoading(false);
}
}, [checkOnboardingStatus, clearSession, token]);
useEffect(() => {
if (IS_PLATFORM) {
setUser({ username: 'platform-user' });
setNeedsSetup(false);
void checkOnboardingStatus().finally(() => {
setIsLoading(false);
});
return;
}
void checkAuthStatus();
}, [checkAuthStatus, checkOnboardingStatus]);
const login = useCallback<AuthContextValue['login']>(
async (username, password) => {
try {
setError(null);
const response = await api.auth.login(username, password);
const payload = await parseJsonSafely<AuthSessionPayload>(response);
if (!response.ok || !payload?.token || !payload.user) {
const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.loginFailed);
setError(message);
return { success: false, error: message };
}
setSession(payload.user, payload.token);
setNeedsSetup(false);
await checkOnboardingStatus();
return { success: true };
} catch (caughtError) {
console.error('Login error:', caughtError);
setError(AUTH_ERROR_MESSAGES.networkError);
return { success: false, error: AUTH_ERROR_MESSAGES.networkError };
}
},
[checkOnboardingStatus, setSession],
);
const register = useCallback<AuthContextValue['register']>(
async (username, password) => {
try {
setError(null);
const response = await api.auth.register(username, password);
const payload = await parseJsonSafely<AuthSessionPayload>(response);
if (!response.ok || !payload?.token || !payload.user) {
const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.registrationFailed);
setError(message);
return { success: false, error: message };
}
setSession(payload.user, payload.token);
setNeedsSetup(false);
await checkOnboardingStatus();
return { success: true };
} catch (caughtError) {
console.error('Registration error:', caughtError);
setError(AUTH_ERROR_MESSAGES.networkError);
return { success: false, error: AUTH_ERROR_MESSAGES.networkError };
}
},
[checkOnboardingStatus, setSession],
);
const logout = useCallback(() => {
const tokenToInvalidate = token;
clearSession();
if (tokenToInvalidate) {
void api.auth.logout().catch((caughtError: unknown) => {
console.error('Logout endpoint error:', caughtError);
});
}
}, [clearSession, token]);
const contextValue = useMemo<AuthContextValue>(
() => ({
user,
token,
isLoading,
needsSetup,
hasCompletedOnboarding,
error,
login,
register,
logout,
refreshOnboardingStatus,
}),
[
error,
hasCompletedOnboarding,
isLoading,
login,
logout,
needsSetup,
refreshOnboardingStatus,
register,
token,
user,
],
);
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,2 @@
export { AuthProvider, useAuth } from './context/AuthContext';
export { default as ProtectedRoute } from './view/ProtectedRoute';

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
export type AuthUser = {
id?: number | string;
username: string;
[key: string]: unknown;
};
export type AuthActionResult = { success: true } | { success: false; error: string };
export type AuthSessionPayload = {
token?: string;
user?: AuthUser;
error?: string;
message?: string;
};
export type AuthStatusPayload = {
needsSetup?: boolean;
};
export type AuthUserPayload = {
user?: AuthUser;
};
export type OnboardingStatusPayload = {
hasCompletedOnboarding?: boolean;
};
export type ApiErrorPayload = {
error?: string;
message?: string;
};
export type AuthContextValue = {
user: AuthUser | null;
token: string | null;
isLoading: boolean;
needsSetup: boolean;
hasCompletedOnboarding: boolean;
error: string | null;
login: (username: string, password: string) => Promise<AuthActionResult>;
register: (username: string, password: string) => Promise<AuthActionResult>;
logout: () => void;
refreshOnboardingStatus: () => Promise<void>;
};
export type AuthProviderProps = {
children: ReactNode;
};

View File

@@ -0,0 +1,17 @@
import type { ApiErrorPayload } from './types';
export async function parseJsonSafely<T>(response: Response): Promise<T | null> {
try {
return (await response.json()) as T;
} catch {
return null;
}
}
export function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback: string): string {
if (!payload) {
return fallback;
}
return payload.error ?? payload.message ?? fallback;
}

View File

@@ -0,0 +1,15 @@
type AuthErrorAlertProps = {
errorMessage: string;
};
export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
if (!errorMessage) {
return null;
}
return (
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
);
}

View File

@@ -0,0 +1,37 @@
type AuthInputFieldProps = {
id: string;
label: string;
value: string;
onChange: (nextValue: string) => void;
placeholder: string;
isDisabled: boolean;
type?: 'text' | 'password' | 'email';
};
export default function AuthInputField({
id,
label,
value,
onChange,
placeholder,
isDisabled,
type = 'text',
}: AuthInputFieldProps) {
return (
<div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground">
{label}
</label>
<input
id={id}
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={placeholder}
required
disabled={isDisabled}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { MessageSquare } from 'lucide-react';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
export default function AuthLoadingScreen() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center">
<div className="mb-4 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
</div>
</div>
<h1 className="mb-2 text-2xl font-bold text-foreground">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
{loadingDotAnimationDelays.map((delay) => (
<div
key={delay}
className="h-2 w-2 animate-bounce rounded-full bg-blue-500"
style={{ animationDelay: delay }}
/>
))}
</div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
type AuthScreenLayoutProps = {
title: string;
description: string;
children: ReactNode;
footerText: string;
logo?: ReactNode;
};
export default function AuthScreenLayout({
title,
description,
children,
footerText,
logo,
}: AuthScreenLayoutProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="text-center">
<div className="mb-4 flex justify-center">
{logo ?? (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
</div>
)}
</div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="mt-2 text-muted-foreground">{description}</p>
</div>
{children}
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
import AuthScreenLayout from './AuthScreenLayout';
type LoginFormState = {
username: string;
password: string;
};
const initialState: LoginFormState = {
username: '',
password: '',
};
export default function LoginForm() {
const { t } = useTranslation('auth');
const { login } = useAuth();
const [formState, setFormState] = useState<LoginFormState>(initialState);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = useCallback((field: keyof LoginFormState, value: string) => {
setFormState((previous) => ({ ...previous, [field]: value }));
}, []);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage('');
// Keep form validation local so each auth screen owns its own UI feedback.
if (!formState.username.trim() || !formState.password) {
setErrorMessage(t('login.errors.requiredFields'));
return;
}
setIsSubmitting(true);
const result = await login(formState.username.trim(), formState.password);
if (!result.success) {
setErrorMessage(result.error);
}
setIsSubmitting(false);
},
[formState.password, formState.username, login, t],
);
return (
<AuthScreenLayout
title={t('login.title')}
description={t('login.description')}
footerText="Enter your credentials to access Claude Code UI"
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
id="username"
label={t('login.username')}
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
/>
<AuthInputField
id="password"
label={t('login.password')}
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder={t('login.placeholders.password')}
isDisabled={isSubmitting}
type="password"
/>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
>
{isSubmitting ? t('login.loading') : t('login.submit')}
</button>
</form>
</AuthScreenLayout>
);
}

View File

@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import { useAuth } from '../context/AuthContext';
import Onboarding from '../../onboarding/view/Onboarding';
import AuthLoadingScreen from './AuthLoadingScreen';
import LoginForm from './LoginForm';
import SetupForm from './SetupForm';
type ProtectedRouteProps = {
children: ReactNode;
};
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (isLoading) {
return <AuthLoadingScreen />;
}
if (IS_PLATFORM) {
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return <>{children}</>;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
if (!hasCompletedOnboarding) {
return <Onboarding onComplete={refreshOnboardingStatus} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,121 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
import AuthScreenLayout from './AuthScreenLayout';
type SetupFormState = {
username: string;
password: string;
confirmPassword: string;
};
const initialState: SetupFormState = {
username: '',
password: '',
confirmPassword: '',
};
function validateSetupForm(formState: SetupFormState): string | null {
if (!formState.username.trim() || !formState.password || !formState.confirmPassword) {
return 'Please fill in all fields.';
}
if (formState.username.trim().length < 3) {
return 'Username must be at least 3 characters long.';
}
if (formState.password.length < 6) {
return 'Password must be at least 6 characters long.';
}
if (formState.password !== formState.confirmPassword) {
return 'Passwords do not match.';
}
return null;
}
export default function SetupForm() {
const { register } = useAuth();
const [formState, setFormState] = useState<SetupFormState>(initialState);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = useCallback((field: keyof SetupFormState, value: string) => {
setFormState((previous) => ({ ...previous, [field]: value }));
}, []);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage('');
const validationError = validateSetupForm(formState);
if (validationError) {
setErrorMessage(validationError);
return;
}
setIsSubmitting(true);
const result = await register(formState.username.trim(), formState.password);
if (!result.success) {
setErrorMessage(result.error);
}
setIsSubmitting(false);
},
[formState, register],
);
return (
<AuthScreenLayout
title="Welcome to Claude Code UI"
description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
>
<form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField
id="username"
label="Username"
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder="Enter your username"
isDisabled={isSubmitting}
/>
<AuthInputField
id="password"
label="Password"
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder="Enter your password"
isDisabled={isSubmitting}
type="password"
/>
<AuthInputField
id="confirmPassword"
label="Confirm Password"
value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password"
isDisabled={isSubmitting}
type="password"
/>
<AuthErrorAlert errorMessage={errorMessage} />
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400"
>
{isSubmitting ? 'Setting up...' : 'Create Account'}
</button>
</form>
</AuthScreenLayout>
);
}

View File

@@ -11,9 +11,7 @@ import type {
} from 'react'; } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { thinkingModes } from '../constants/thinkingModes'; import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage'; import { safeLocalStorage } from '../utils/chatStorage';
import type { import type {
@@ -21,10 +19,10 @@ import type {
PendingPermissionRequest, PendingPermissionRequest,
PermissionMode, PermissionMode,
} from '../types/types'; } from '../types/types';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting'; import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null; sessionId: string | null;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants'; import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app'; import type { ProjectSession, SessionProvider } from '../../../types/app';
interface UseChatProviderStateArgs { interface UseChatProviderStateArgs {

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { api, authenticatedFetch } from '../../../utils/api'; import { api, authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types'; import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';

View File

@@ -161,7 +161,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
fileMentionSet.has(part) ? ( fileMentionSet.has(part) ? (
<span <span
key={`mention-${index}`} key={`mention-${index}`}
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent" className="-ml-0.5 rounded-md bg-blue-200/70 box-decoration-clone px-0.5 text-transparent dark:bg-blue-300/40"
> >
{part} {part}
</span> </span>

View File

@@ -17,7 +17,7 @@ tools/
│ ├── CollapsibleDisplay.tsx # Expandable tool display (uses children pattern) │ ├── CollapsibleDisplay.tsx # Expandable tool display (uses children pattern)
│ ├── CollapsibleSection.tsx # <details>/<summary> wrapper │ ├── CollapsibleSection.tsx # <details>/<summary> wrapper
│ ├── ContentRenderers/ │ ├── ContentRenderers/
│ │ ├── DiffViewer.tsx # File diff viewer (memoized) │ │ ├── ToolDiffViewer.tsx # File diff viewer (memoized)
│ │ ├── MarkdownContent.tsx # Markdown renderer │ │ ├── MarkdownContent.tsx # Markdown renderer
│ │ ├── FileListContent.tsx # Comma-separated clickable file list │ │ ├── FileListContent.tsx # Comma-separated clickable file list
│ │ ├── TodoListContent.tsx # Todo items with status badges │ │ ├── TodoListContent.tsx # Todo items with status badges
@@ -82,7 +82,7 @@ Wraps `CollapsibleSection` (`<details>`/`<summary>`) with a `border-l-2` accent
rawContent="..." // Raw JSON string rawContent="..." // Raw JSON string
toolCategory="edit" // Drives border color toolCategory="edit" // Drives border color
> >
<DiffViewer {...} /> // Content as children <ToolDiffViewer {...} /> // Content as children
</CollapsibleDisplay> </CollapsibleDisplay>
``` ```
@@ -217,7 +217,7 @@ interface ToolDisplayConfig {
- **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed - **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed
- **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes - **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes
- **DiffViewer** memoizes `createDiff()` — expensive diff computation cached - **ToolDiffViewer** memoizes `createDiff()` — expensive diff computation cached
- **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo` - **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo`
- Tool results route through `ToolRenderer` (no duplicate rendering paths) - Tool results route through `ToolRenderer` (no duplicate rendering paths)
- `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection) - `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection)

View File

@@ -1,8 +1,8 @@
import React, { memo, useMemo, useCallback } from 'react'; import React, { memo, useMemo, useCallback } from 'react';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types'; import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -142,7 +142,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
case 'diff': case 'diff':
if (createDiff) { if (createDiff) {
contentComponent = ( contentComponent = (
<DiffViewer <ToolDiffViewer
{...contentProps} {...contentProps}
createDiff={createDiff} createDiff={createDiff}
onFileClick={() => onFileOpen?.(contentProps.filePath)} onFileClick={() => onFileOpen?.(contentProps.filePath)}
@@ -202,7 +202,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
const msg = displayConfig.getMessage?.(parsedData) || 'Success'; const msg = displayConfig.getMessage?.(parsedData) || 'Success';
contentComponent = ( contentComponent = (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400"> <div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
{msg} {msg}

View File

@@ -43,7 +43,7 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default; const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return ( return (
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}> <div className={`border-l-2 ${borderColor} my-1 py-0.5 pl-3 ${className}`}>
<CollapsibleSection <CollapsibleSection
title={title} title={title}
toolName={toolName} toolName={toolName}
@@ -54,10 +54,10 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
{children} {children}
{showRawParameters && rawContent && ( {showRawParameters && rawContent && (
<details className="relative mt-2 group/raw"> <details className="group/raw relative mt-2">
<summary className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 py-0.5"> <summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg <svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/raw:rotate-90" className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -66,7 +66,7 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
</svg> </svg>
raw params raw params
</summary> </summary>
<pre className="mt-1 text-[11px] bg-gray-50 dark:bg-gray-900/50 border border-gray-200/40 dark:border-gray-700/40 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-600 dark:text-gray-400 font-mono"> <pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400">
{rawContent} {rawContent}
</pre> </pre>
</details> </details>

View File

@@ -23,10 +23,10 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
className = '' className = ''
}) => { }) => {
return ( return (
<details className={`relative group/details ${className}`} open={open}> <details className={`group/details relative ${className}`} open={open}>
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1"> <summary className="flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1">
<svg <svg
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0" className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -34,24 +34,24 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
{toolName && ( {toolName && (
<span className="font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">{toolName}</span> <span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span>
)} )}
{toolName && ( {toolName && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] flex-shrink-0">/</span> <span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span>
)} )}
{onTitleClick ? ( {onTitleClick ? (
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline truncate flex-1 text-left transition-colors" className="flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
> >
{title} {title}
</button> </button>
) : ( ) : (
<span className="text-gray-600 dark:text-gray-400 truncate flex-1"> <span className="flex-1 truncate text-gray-600 dark:text-gray-400">
{title} {title}
</span> </span>
)} )}
{action && <span className="flex-shrink-0 ml-1">{action}</span>} {action && <span className="ml-1 flex-shrink-0">{action}</span>}
</summary> </summary>
<div className="mt-1.5 pl-[18px]"> <div className="mt-1.5 pl-[18px]">
{children} {children}

View File

@@ -23,11 +23,11 @@ export const FileListContent: React.FC<FileListContentProps> = ({
return ( return (
<div> <div>
{title && ( {title && (
<div className="text-[11px] text-gray-500 dark:text-gray-400 mb-1"> <div className="mb-1 text-[11px] text-gray-500 dark:text-gray-400">
{title} {title}
</div> </div>
)} )}
<div className="flex flex-wrap gap-x-1 gap-y-0.5 max-h-48 overflow-y-auto"> <div className="flex max-h-48 flex-wrap gap-x-1 gap-y-0.5 overflow-y-auto">
{files.map((file, index) => { {files.map((file, index) => {
const filePath = typeof file === 'string' ? file : file.path; const filePath = typeof file === 'string' ? file : file.path;
const fileName = filePath.split('/').pop() || filePath; const fileName = filePath.split('/').pop() || filePath;
@@ -39,13 +39,13 @@ export const FileListContent: React.FC<FileListContentProps> = ({
<span key={index} className="inline-flex items-center"> <span key={index} className="inline-flex items-center">
<button <button
onClick={handleClick} onClick={handleClick}
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors" className="font-mono text-[11px] text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
title={filePath} title={filePath}
> >
{fileName} {fileName}
</button> </button>
{index < files.length - 1 && ( {index < files.length - 1 && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] ml-1">,</span> <span className="ml-1 text-[10px] text-gray-300 dark:text-gray-600">,</span>
)} )}
</span> </span>
); );

View File

@@ -33,31 +33,31 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
return ( return (
<div <div
key={idx} key={idx}
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden" className="border-gray-150 overflow-hidden rounded-lg border bg-gray-50/50 dark:border-gray-700/50 dark:bg-gray-800/30"
> >
<button <button
type="button" type="button"
onClick={() => setExpandedIdx(isExpanded ? null : idx)} onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors" className="flex w-full items-start gap-2.5 px-3 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
> >
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${ <div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full ${
answerLabels.length > 0 answerLabels.length > 0
? 'bg-blue-100 dark:bg-blue-900/40' ? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-gray-100 dark:bg-gray-800' : 'bg-gray-100 dark:bg-gray-800'
}`}> }`}>
{answerLabels.length > 0 ? ( {answerLabels.length > 0 ? (
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}> <svg className="h-2.5 w-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
) : ( ) : (
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" /> <div className="h-1.5 w-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-2">
{q.header && ( {q.header && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40"> <span className="inline-flex items-center rounded border border-blue-100/80 bg-blue-50 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:border-blue-800/40 dark:bg-blue-900/30 dark:text-blue-400">
{q.header} {q.header}
</span> </span>
)} )}
@@ -67,22 +67,22 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
</span> </span>
)} )}
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug"> <div className="mt-0.5 text-xs leading-snug text-gray-600 dark:text-gray-400">
{q.question} {q.question}
</div> </div>
{!isExpanded && answerLabels.length > 0 && ( {!isExpanded && answerLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5"> <div className="mt-1.5 flex flex-wrap gap-1">
{answerLabels.map((lbl) => { {answerLabels.map((lbl) => {
const isCustom = !q.options.some(o => o.label === lbl); const isCustom = !q.options.some(o => o.label === lbl);
return ( return (
<span <span
key={lbl} key={lbl}
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium" className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-1.5 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
> >
{lbl} {lbl}
{isCustom && ( {isCustom && (
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span> <span className="text-[9px] font-normal text-blue-400 dark:text-blue-500">(custom)</span>
)} )}
</span> </span>
); );
@@ -91,14 +91,14 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
)} )}
{!isExpanded && skipped && hasAnyAnswer && ( {!isExpanded && skipped && hasAnyAnswer && (
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic"> <span className="mt-1 inline-block text-[10px] italic text-gray-400 dark:text-gray-500">
Skipped Skipped
</span> </span>
)} )}
</div> </div>
<svg <svg
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${ className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-gray-400 transition-transform duration-200 dark:text-gray-500 ${
isExpanded ? 'rotate-180' : '' isExpanded ? 'rotate-180' : ''
}`} }`}
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
@@ -108,36 +108,36 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40"> <div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
<div className="space-y-1 ml-6.5"> <div className="ml-6.5 space-y-1">
{q.options.map((opt) => { {q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label); const wasSelected = answerLabels.includes(opt.label);
return ( return (
<div <div
key={opt.label} key={opt.label}
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${ className={`flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-[12px] ${
wasSelected wasSelected
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40' ? 'border border-blue-200/60 bg-blue-50/80 dark:border-blue-800/40 dark:bg-blue-900/20'
: 'text-gray-400 dark:text-gray-500' : 'text-gray-400 dark:text-gray-500'
}`} }`}
> >
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${ <div className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} flex items-center justify-center border-[1.5px] ${
wasSelected wasSelected
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500' ? 'border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-500'
: 'border-gray-300 dark:border-gray-600' : 'border-gray-300 dark:border-gray-600'
}`}> }`}>
{wasSelected && ( {wasSelected && (
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}> <svg className="h-2 w-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}> <span className={wasSelected ? 'font-medium text-gray-900 dark:text-gray-100' : ''}>
{opt.label} {opt.label}
</span> </span>
{opt.description && ( {opt.description && (
<span className={`block text-[11px] mt-0.5 ${ <span className={`mt-0.5 block text-[11px] ${
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600' wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
}`}> }`}>
{opt.description} {opt.description}
@@ -151,22 +151,22 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => ( {answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
<div <div
key={lbl} key={lbl}
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40" className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
> >
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}> <div className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} flex items-center justify-center border-[1.5px] border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-500`}>
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}> <svg className="h-2 w-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span> <span className="font-medium text-gray-900 dark:text-gray-100">{lbl}</span>
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span> <span className="ml-1 text-[10px] text-blue-500 dark:text-blue-400">(custom)</span>
</div> </div>
</div> </div>
))} ))}
{skipped && hasAnyAnswer && ( {skipped && hasAnyAnswer && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1"> <div className="px-2.5 py-1 text-[11px] italic text-gray-400 dark:text-gray-500">
No answer provided No answer provided
</div> </div>
)} )}
@@ -178,7 +178,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
})} })}
{!hasAnyAnswer && total === 1 && ( {!hasAnyAnswer && total === 1 && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic"> <div className="text-[11px] italic text-gray-400 dark:text-gray-500">
Skipped Skipped
</div> </div>
)} )}

View File

@@ -39,7 +39,7 @@ function parseTaskContent(content: string): TaskItem[] {
const statusConfig = { const statusConfig = {
completed: { completed: {
icon: ( icon: (
<svg className="w-3.5 h-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3.5 w-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
), ),
@@ -48,7 +48,7 @@ const statusConfig = {
}, },
in_progress: { in_progress: {
icon: ( icon: (
<svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
), ),
@@ -57,7 +57,7 @@ const statusConfig = {
}, },
pending: { pending: {
icon: ( icon: (
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={2} /> <circle cx="12" cy="12" r="9" strokeWidth={2} />
</svg> </svg>
), ),
@@ -76,7 +76,7 @@ export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) =>
// If we couldn't parse any tasks, fall back to text display // If we couldn't parse any tasks, fall back to text display
if (tasks.length === 0) { if (tasks.length === 0) {
return ( return (
<pre className="text-[11px] font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap"> <pre className="whitespace-pre-wrap font-mono text-[11px] text-gray-600 dark:text-gray-400">
{content} {content}
</pre> </pre>
); );
@@ -87,13 +87,13 @@ export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) =>
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-1.5"> <div className="mb-1.5 flex items-center gap-2">
<span className="text-[11px] text-gray-500 dark:text-gray-400"> <span className="text-[11px] text-gray-500 dark:text-gray-400">
{completed}/{total} completed {completed}/{total} completed
</span> </span>
<div className="flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> <div className="h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div <div
className="h-full bg-green-500 dark:bg-green-400 rounded-full transition-all" className="h-full rounded-full bg-green-500 transition-all dark:bg-green-400"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }} style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/> />
</div> </div>
@@ -104,16 +104,16 @@ export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) =>
return ( return (
<div <div
key={task.id} key={task.id}
className="flex items-center gap-1.5 py-0.5 group" className="group flex items-center gap-1.5 py-0.5"
> >
<span className="flex-shrink-0">{config.icon}</span> <span className="flex-shrink-0">{config.icon}</span>
<span className="text-[11px] font-mono text-gray-400 dark:text-gray-500 flex-shrink-0"> <span className="flex-shrink-0 font-mono text-[11px] text-gray-400 dark:text-gray-500">
#{task.id} #{task.id}
</span> </span>
<span className={`text-xs truncate flex-1 ${config.textClass}`}> <span className={`flex-1 truncate text-xs ${config.textClass}`}>
{task.subject} {task.subject}
</span> </span>
<span className={`text-[10px] px-1 py-px rounded border flex-shrink-0 ${config.badgeClass}`}> <span className={`flex-shrink-0 rounded border px-1 py-px text-[10px] ${config.badgeClass}`}>
{task.status.replace('_', ' ')} {task.status.replace('_', ' ')}
</span> </span>
</div> </div>

View File

@@ -22,10 +22,11 @@ export const TextContent: React.FC<TextContentProps> = ({
formattedJson = JSON.stringify(parsed, null, 2); formattedJson = JSON.stringify(parsed, null, 2);
} catch (e) { } catch (e) {
// If parsing fails, use original content // If parsing fails, use original content
console.warn('Failed to parse JSON content:', e);
} }
return ( return (
<pre className={`mt-1 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-2.5 rounded overflow-x-auto font-mono ${className}`}> <pre className={`mt-1 overflow-x-auto rounded bg-gray-900 p-2.5 font-mono text-xs text-gray-100 dark:bg-gray-950 ${className}`}>
{formattedJson} {formattedJson}
</pre> </pre>
); );
@@ -33,7 +34,7 @@ export const TextContent: React.FC<TextContentProps> = ({
if (format === 'code') { if (format === 'code') {
return ( return (
<pre className={`mt-1 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}> <pre className={`mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/50 bg-gray-50 p-2 font-mono text-xs text-gray-700 dark:border-gray-700/50 dark:bg-gray-800/50 dark:text-gray-300 ${className}`}>
{content} {content}
</pre> </pre>
); );
@@ -41,7 +42,7 @@ export const TextContent: React.FC<TextContentProps> = ({
// Plain text // Plain text
return ( return (
<div className={`mt-1 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}> <div className={`mt-1 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 ${className}`}>
{content} {content}
</div> </div>
); );

View File

@@ -0,0 +1,152 @@
import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react';
import { Badge } from '../../../../../shared/view/ui';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
export type TodoItem = {
id?: string;
content: string;
status: string;
priority?: string;
};
type NormalizedTodoItem = {
id?: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
return 'pending';
};
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800">
<div className="mt-0.5 flex-shrink-0">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start justify-between gap-2">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex flex-shrink-0 gap-1">
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo(
({
todos,
isResult = false,
}: {
todos: TodoItem[];
isResult?: boolean;
}) => {
// Memoize normalization to avoid recomputing list metadata on every render.
const normalizedTodos = useMemo<NormalizedTodoItem[]>(
() =>
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
);
if (normalizedTodos.length === 0) {
return null;
}
return (
<div className="space-y-1.5">
{isResult && (
<div className="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Todo List ({normalizedTodos.length}{' '}
{normalizedTodos.length === 1 ? 'item' : 'items'})
</div>
)}
{normalizedTodos.map((todo, index) => (
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} />
))}
</div>
);
}
);
export default TodoList;

View File

@@ -1,23 +1,40 @@
import React from 'react'; import { memo, useMemo } from 'react';
import TodoList from '../../../../TodoList'; import TodoList, { type TodoItem } from './TodoList';
interface TodoListContentProps { const isTodoItem = (value: unknown): value is TodoItem => {
todos: Array<{ if (typeof value !== 'object' || value === null) {
id?: string; return false;
content: string; }
status: string;
priority?: string; const todo = value as Record<string, unknown>;
}>; return typeof todo.content === 'string' && typeof todo.status === 'string';
isResult?: boolean; };
}
/** /**
* Renders a todo list * Renders a todo list
* Used by: TodoWrite, TodoRead * Used by: TodoWrite, TodoRead
*/ */
export const TodoListContent: React.FC<TodoListContentProps> = ({ export const TodoListContent = memo(
todos, ({
isResult = false todos,
}) => { isResult = false,
return <TodoList todos={todos} isResult={isResult} />; }: {
}; todos: unknown;
isResult?: boolean;
}) => {
const safeTodos = useMemo<TodoItem[]>(() => {
if (!Array.isArray(todos)) {
return [];
}
// Tool payloads are runtime data; render only validated todo objects.
return todos.filter(isTodoItem);
}, [todos]);
if (safeTodos.length === 0) {
return null;
}
return <TodoList todos={safeTodos} isResult={isResult} />;
}
);

View File

@@ -148,32 +148,32 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
tabIndex={-1} tabIndex={-1}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`w-full outline-none transition-all duration-500 ease-out ${ className={`w-full outline-none transition-all duration-500 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3' mounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'
}`} }`}
> >
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl"> <div className="relative overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-lg dark:border-gray-700/50 dark:bg-gray-800/90 dark:shadow-2xl">
{/* Accent line */} {/* Accent line */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" /> <div className="absolute left-0 right-0 top-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
{/* Header + Question — compact */} {/* Header + Question — compact */}
<div className="px-4 pt-3.5 pb-2"> <div className="px-4 pb-2 pt-3.5">
<div className="flex items-center gap-2.5 mb-1.5"> <div className="mb-1.5 flex items-center gap-2.5">
{/* Question icon */} {/* Question icon */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center"> <div className="flex h-6 w-6 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15">
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor"> <svg className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
</svg> </svg>
</div> </div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" /> <div className="absolute -right-0.5 -top-0.5 h-2 w-2 animate-pulse rounded-full bg-cyan-400 dark:bg-cyan-500" />
</div> </div>
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500"> <span className="text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500">
Claude needs your input Claude needs your input
</span> </span>
{q.header && ( {q.header && (
<span className="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-800/50"> <span className="inline-flex items-center rounded border border-blue-100 bg-blue-50 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:border-blue-800/50 dark:bg-blue-900/30 dark:text-blue-400">
{q.header} {q.header}
</span> </span>
)} )}
@@ -181,7 +181,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
{/* Step counter */} {/* Step counter */}
{!isSingle && ( {!isSingle && (
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0"> <span className="flex-shrink-0 text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
{currentStep + 1}/{total} {currentStep + 1}/{total}
</span> </span>
)} )}
@@ -189,7 +189,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
{/* Progress dots (multi-question) */} {/* Progress dots (multi-question) */}
{!isSingle && ( {!isSingle && (
<div className="flex items-center gap-1 mb-2"> <div className="mb-2 flex items-center gap-1">
{questions.map((_, i) => ( {questions.map((_, i) => (
<button <button
key={i} key={i}
@@ -208,7 +208,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
)} )}
{/* Question text */} {/* Question text */}
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100"> <p className="text-[14px] font-medium leading-snug text-gray-900 dark:text-gray-100">
{q.question} {q.question}
</p> </p>
{multi && ( {multi && (
@@ -217,7 +217,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
</div> </div>
{/* Options — tight spacing */} {/* Options — tight spacing */}
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}> <div className="scrollbar-thin max-h-48 overflow-y-auto px-4 pb-2" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
<div className="space-y-1"> <div className="space-y-1">
{q.options.map((opt, optIdx) => { {q.options.map((opt, optIdx) => {
const isSelected = selected.has(opt.label); const isSelected = selected.has(opt.label);
@@ -226,25 +226,25 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
key={opt.label} key={opt.label}
type="button" type="button"
onClick={() => toggleOption(currentStep, opt.label, multi)} onClick={() => toggleOption(currentStep, opt.label, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isSelected isSelected
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30' ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50' : 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
}`} }`}
> >
{/* Keyboard hint */} {/* Keyboard hint */}
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${ <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
isSelected isSelected
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold' ? 'bg-blue-500 font-semibold text-white dark:bg-blue-500'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600' : 'border border-gray-200 bg-gray-100 text-gray-400 group-hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500 dark:group-hover:border-gray-600'
}`}> }`}>
{optIdx + 1} {optIdx + 1}
</kbd> </kbd>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className={`text-[13px] leading-tight transition-colors duration-150 ${ <div className={`text-[13px] leading-tight transition-colors duration-150 ${
isSelected isSelected
? 'text-gray-900 dark:text-gray-100 font-medium' ? 'font-medium text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
}`}> }`}>
{opt.label} {opt.label}
@@ -262,7 +262,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
{/* Selection check */} {/* Selection check */}
{isSelected && ( {isSelected && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> <svg className="h-4 w-4 flex-shrink-0 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
)} )}
@@ -274,28 +274,28 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
<button <button
type="button" type="button"
onClick={() => toggleOther(currentStep, multi)} onClick={() => toggleOther(currentStep, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isOtherOn isOtherOn
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30' ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50' : 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
}`} }`}
> >
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${ <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${
isOtherOn isOtherOn
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold' ? 'bg-blue-500 font-semibold text-white dark:bg-blue-500'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600' : 'border border-gray-200 bg-gray-100 text-gray-400 group-hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500 dark:group-hover:border-gray-600'
}`}> }`}>
0 0
</kbd> </kbd>
<span className={`text-[13px] leading-tight transition-colors ${ <span className={`text-[13px] leading-tight transition-colors ${
isOtherOn isOtherOn
? 'text-gray-900 dark:text-gray-100 font-medium' ? 'font-medium text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400'
}`}> }`}>
Other... Other...
</span> </span>
{isOtherOn && ( {isOtherOn && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}> <svg className="ml-auto h-4 w-4 flex-shrink-0 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
)} )}
@@ -320,9 +320,9 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
e.stopPropagation(); e.stopPropagation();
}} }}
placeholder="Type your answer..." placeholder="Type your answer..."
className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200" className="w-full rounded-lg border-0 bg-gray-50 px-3 py-1.5 text-[13px] text-gray-900 outline-none ring-1 ring-gray-200 transition-shadow duration-200 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-400 dark:bg-gray-900/60 dark:text-gray-100 dark:ring-gray-700 dark:placeholder:text-gray-600 dark:focus:ring-blue-500"
/> />
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700"> <kbd className="absolute right-2 top-1/2 -translate-y-1/2 rounded border border-gray-200 bg-gray-100 px-1 py-0.5 font-mono text-[9px] text-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-600">
Enter Enter
</kbd> </kbd>
</div> </div>
@@ -332,11 +332,11 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
</div> </div>
{/* Footer — compact */} {/* Footer — compact */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2 border-t border-gray-100 bg-gray-50/50 px-4 py-2 dark:border-gray-700/50 dark:bg-gray-800/50">
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="text-[11px] text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
> >
{isSingle ? 'Skip' : 'Skip all'} {isSingle ? 'Skip' : 'Skip all'}
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span> <span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
@@ -347,9 +347,9 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
<button <button
type="button" type="button"
onClick={() => setCurrentStep(s => s - 1)} onClick={() => setCurrentStep(s => s - 1)}
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150" className="inline-flex items-center gap-0.5 rounded-lg px-2.5 py-1.5 text-[11px] font-medium text-gray-600 transition-all duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700/60"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
Back Back
@@ -361,19 +361,19 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length} disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200" className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-3.5 py-1.5 text-[11px] font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-30 disabled:shadow-none dark:from-blue-500 dark:to-blue-600"
> >
Submit Submit
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span> <span className="ml-0.5 font-mono text-[9px] opacity-70">Enter</span>
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => setCurrentStep(s => s + 1)} onClick={() => setCurrentStep(s => s + 1)}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200" className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-3.5 py-1.5 text-[11px] font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-md dark:from-blue-500 dark:to-blue-600"
> >
Next Next
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span> <span className="ml-0.5 font-mono text-[9px] opacity-70">Enter</span>
</button> </button>
)} )}
</div> </div>

View File

@@ -68,16 +68,16 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const renderCopyButton = () => ( const renderCopyButton = () => (
<button <button
onClick={handleAction} onClick={handleAction}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-all ml-1 flex-shrink-0" className="ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200"
title="Copy to clipboard" title="Copy to clipboard"
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
> >
{copied ? ( {copied ? (
<svg className="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
) : ( ) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg> </svg>
)} )}
@@ -89,15 +89,15 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
return ( return (
<div className="group my-1"> <div className="group my-1">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex items-center gap-1.5 flex-shrink-0 pt-0.5"> <div className="flex flex-shrink-0 items-center gap-1.5 pt-0.5">
<svg className="w-3 h-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0 flex items-start gap-2"> <div className="flex min-w-0 flex-1 items-start gap-2">
<div className="bg-gray-900 dark:bg-black rounded px-2.5 py-1 flex-1 min-w-0"> <div className="min-w-0 flex-1 rounded bg-gray-900 px-2.5 py-1 dark:bg-black">
<code className={`text-xs text-green-400 font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}> <code className={`font-mono text-xs text-green-400 ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}>
<span className="text-green-600 dark:text-green-500 select-none">$ </span>{value} <span className="select-none text-green-600 dark:text-green-500">$ </span>{value}
</code> </code>
</div> </div>
{action === 'copy' && renderCopyButton()} {action === 'copy' && renderCopyButton()}
@@ -105,7 +105,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
</div> </div>
{secondary && ( {secondary && (
<div className="ml-7 mt-1"> <div className="ml-7 mt-1">
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic"> <span className="text-[11px] italic text-gray-400 dark:text-gray-500">
{secondary} {secondary}
</span> </span>
</div> </div>
@@ -118,12 +118,12 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
if (action === 'open-file') { if (action === 'open-file') {
const displayName = value.split('/').pop() || value; const displayName = value.split('/').pop() || value;
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span> <span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<button <button
onClick={handleAction} onClick={handleAction}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline transition-colors truncate" className="truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
title={value} title={value}
> >
{displayName} {displayName}
@@ -135,23 +135,23 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
// Search / jump-to-results style // Search / jump-to-results style
if (action === 'jump-to-results') { if (action === 'jump-to-results') {
return ( return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}> <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span> <span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className={`text-xs font-mono truncate flex-1 min-w-0 ${colorScheme.primary}`}> <span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>
{value} {value}
</span> </span>
{secondary && ( {secondary && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic flex-shrink-0"> <span className="flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500">
{secondary} {secondary}
</span> </span>
)} )}
{toolResult && ( {toolResult && (
<a <a
href={`#tool-result-${toolId}`} href={`#tool-result-${toolId}`}
className="flex-shrink-0 text-[11px] text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors flex items-center gap-0.5" className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</a> </a>
@@ -162,21 +162,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
// Default one-line style // Default one-line style
return ( return (
<div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}> <div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
{icon && icon !== 'terminal' && ( {icon && icon !== 'terminal' && (
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span> <span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)} )}
{!icon && (label || toolName) && ( {!icon && (label || toolName) && (
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span> <span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
)} )}
{(icon || label || toolName) && ( {(icon || label || toolName) && (
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span> <span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
)} )}
<span className={`text-xs font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} flex-1 min-w-0 ${colorScheme.primary}`}> <span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>
{value} {value}
</span> </span>
{secondary && ( {secondary && (
<span className={`text-[11px] ${colorScheme.secondary} italic flex-shrink-0`}> <span className={`text-[11px] ${colorScheme.secondary} flex-shrink-0 italic`}>
{secondary} {secondary}
</span> </span>
)} )}

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import type { SubagentChildTool } from '../../types/types'; import type { SubagentChildTool } from '../../types/types';
import { CollapsibleSection } from './CollapsibleSection';
interface SubagentContainerProps { interface SubagentContainerProps {
toolInput: unknown; toolInput: unknown;
@@ -57,7 +57,7 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
const title = `Subagent / ${subagentType}: ${description}`; const title = `Subagent / ${subagentType}: ${description}`;
return ( return (
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1"> <div className="my-1 border-l-2 border-l-purple-500 py-0.5 pl-3 dark:border-l-purple-400">
<CollapsibleSection <CollapsibleSection
title={title} title={title}
toolName="Task" toolName="Task"
@@ -65,21 +65,21 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
> >
{/* Prompt/request to the subagent */} {/* Prompt/request to the subagent */}
{prompt && ( {prompt && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4"> <div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400">
{prompt} {prompt}
</div> </div>
)} )}
{/* Current tool indicator (while running) */} {/* Current tool indicator (while running) */}
{currentTool && !isComplete && ( {currentTool && !isComplete && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" /> <span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span> <span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span> <span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && ( {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<> <>
<span className="text-gray-300 dark:text-gray-600">/</span> <span className="text-gray-300 dark:text-gray-600">/</span>
<span className="font-mono truncate text-gray-500 dark:text-gray-400"> <span className="truncate font-mono text-gray-500 dark:text-gray-400">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)} {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span> </span>
</> </>
@@ -89,8 +89,8 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Completion status */} {/* Completion status */}
{isComplete && ( {isComplete && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1"> <div className="mt-1 flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span> <span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
@@ -99,10 +99,10 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Tool history (collapsed) */} {/* Tool history (collapsed) */}
{childTools.length > 0 && ( {childTools.length > 0 && (
<details className="mt-2 group/history"> <details className="group/history mt-2">
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1"> <summary className="flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<svg <svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0" className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -111,18 +111,18 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
</svg> </svg>
<span>View tool history ({childTools.length})</span> <span>View tool history ({childTools.length})</span>
</summary> </summary>
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5"> <div className="mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700">
{childTools.map((child, index) => ( {childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400"> <div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span> <span className="w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span> <span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && ( {getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="font-mono truncate text-gray-400 dark:text-gray-500"> <span className="truncate font-mono text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)} {getCompactToolDisplay(child.toolName, child.toolInput)}
</span> </span>
)} )}
{child.toolResult?.isError && ( {child.toolResult?.isError && (
<span className="text-red-500 flex-shrink-0">(error)</span> <span className="flex-shrink-0 text-red-500">(error)</span>
)} )}
</div> </div>
))} ))}
@@ -163,11 +163,11 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
} }
return typeof content === 'string' ? ( return typeof content === 'string' ? (
<div className="whitespace-pre-wrap break-words line-clamp-6"> <div className="line-clamp-6 whitespace-pre-wrap break-words">
{content} {content}
</div> </div>
) : content ? ( ) : content ? (
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]"> <pre className="line-clamp-6 whitespace-pre-wrap break-words font-mono text-[11px]">
{JSON.stringify(content, null, 2)} {JSON.stringify(content, null, 2)}
</pre> </pre>
) : null; ) : null;

View File

@@ -6,7 +6,7 @@ type DiffLine = {
lineNum: number; lineNum: number;
}; };
interface DiffViewerProps { interface ToolDiffViewerProps {
oldContent: string; oldContent: string;
newContent: string; newContent: string;
filePath: string; filePath: string;
@@ -19,7 +19,7 @@ interface DiffViewerProps {
/** /**
* Compact diff viewer VS Code-style * Compact diff viewer VS Code-style
*/ */
export const DiffViewer: React.FC<DiffViewerProps> = ({ export const ToolDiffViewer: React.FC<ToolDiffViewerProps> = ({
oldContent, oldContent,
newContent, newContent,
filePath, filePath,
@@ -38,44 +38,44 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
); );
return ( return (
<div className="border border-gray-200/60 dark:border-gray-700/50 rounded overflow-hidden"> <div className="overflow-hidden rounded border border-gray-200/60 dark:border-gray-700/50">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-2.5 py-1 bg-gray-50/80 dark:bg-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/50"> <div className="flex items-center justify-between border-b border-gray-200/60 bg-gray-50/80 px-2.5 py-1 dark:border-gray-700/50 dark:bg-gray-800/40">
{onFileClick ? ( {onFileClick ? (
<button <button
onClick={onFileClick} onClick={onFileClick}
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer transition-colors" className="cursor-pointer truncate font-mono text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
> >
{filePath} {filePath}
</button> </button>
) : ( ) : (
<span className="text-[11px] font-mono text-gray-600 dark:text-gray-400 truncate"> <span className="truncate font-mono text-[11px] text-gray-600 dark:text-gray-400">
{filePath} {filePath}
</span> </span>
)} )}
<span className={`text-[10px] font-medium px-1.5 py-px rounded ${badgeClasses} flex-shrink-0 ml-2`}> <span className={`rounded px-1.5 py-px text-[10px] font-medium ${badgeClasses} ml-2 flex-shrink-0`}>
{badge} {badge}
</span> </span>
</div> </div>
{/* Diff lines */} {/* Diff lines */}
<div className="text-[11px] font-mono leading-[18px]"> <div className="font-mono text-[11px] leading-[18px]">
{diffLines.map((diffLine, i) => ( {diffLines.map((diffLine, i) => (
<div key={i} className="flex"> <div key={i} className="flex">
<span <span
className={`w-6 text-center select-none flex-shrink-0 ${ className={`w-6 flex-shrink-0 select-none text-center ${
diffLine.type === 'removed' diffLine.type === 'removed'
? 'bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500' ? 'bg-red-50 text-red-400 dark:bg-red-950/30 dark:text-red-500'
: 'bg-green-50 dark:bg-green-950/30 text-green-400 dark:text-green-500' : 'bg-green-50 text-green-400 dark:bg-green-950/30 dark:text-green-500'
}`} }`}
> >
{diffLine.type === 'removed' ? '-' : '+'} {diffLine.type === 'removed' ? '-' : '+'}
</span> </span>
<span <span
className={`px-2 flex-1 whitespace-pre-wrap ${ className={`flex-1 whitespace-pre-wrap px-2 ${
diffLine.type === 'removed' diffLine.type === 'removed'
? 'bg-red-50/50 dark:bg-red-950/20 text-red-800 dark:text-red-200' ? 'bg-red-50/50 text-red-800 dark:bg-red-950/20 dark:text-red-200'
: 'bg-green-50/50 dark:bg-green-950/20 text-green-800 dark:text-green-200' : 'bg-green-50/50 text-green-800 dark:bg-green-950/20 dark:text-green-200'
}`} }`}
> >
{diffLine.content} {diffLine.content}

View File

@@ -1,5 +1,5 @@
export { CollapsibleSection } from './CollapsibleSection'; export { CollapsibleSection } from './CollapsibleSection';
export { DiffViewer } from './DiffViewer'; export { ToolDiffViewer } from './ToolDiffViewer';
export { OneLineDisplay } from './OneLineDisplay'; export { OneLineDisplay } from './OneLineDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay'; export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer'; export { SubagentContainer } from './SubagentContainer';

View File

@@ -274,6 +274,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
} }
return { todos, isResult: true }; return { todos, isResult: true };
} catch (e) { } catch (e) {
console.warn('Failed to parse todo list content:', e);
return { todos: [], isResult: true }; return { todos: [], isResult: true };
} }
} }
@@ -514,6 +515,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
}; };
} catch (e) { } catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' }; return { content: '' };
} }
} }
@@ -544,6 +546,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
}; };
} catch (e) { } catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' }; return { content: '' };
} }
} }

View File

@@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import QuickSettingsPanel from '../../QuickSettingsPanel';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import ChatComposer from './subcomponents/ChatComposer'; import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps } from '../types/types'; import type { ChatInterfaceProps, Provider } from '../types/types';
import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState'; import { useChatComposerState } from '../hooks/useChatComposerState';
import type { Provider } from '../types/types'; import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null; sessionId: string | null;
@@ -87,7 +87,6 @@ function ChatInterface({
isLoadingMoreMessages, isLoadingMoreMessages,
hasMoreMessages, hasMoreMessages,
totalMessages, totalMessages,
isSystemSessionChange,
setIsSystemSessionChange, setIsSystemSessionChange,
canAbortSession, canAbortSession,
setCanAbortSession, setCanAbortSession,
@@ -259,7 +258,7 @@ function ChatInterface({
: t('messageTypes.claude'); : t('messageTypes.claude');
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<p className="text-sm"> <p className="text-sm">
{t('projectSelection.startChatWithProvider', { {t('projectSelection.startChatWithProvider', {
@@ -274,7 +273,7 @@ function ChatInterface({
return ( return (
<> <>
<div className="h-full flex flex-col"> <div className="flex h-full flex-col">
<ChatMessagesPane <ChatMessagesPane
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
onWheel={handleScroll} onWheel={handleScroll}

View File

@@ -1,6 +1,5 @@
import { SessionProvider } from '../../../../types/app'; import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = { type AssistantThinkingIndicatorProps = {
selectedProvider: SessionProvider; selectedProvider: SessionProvider;
@@ -11,15 +10,15 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
return ( return (
<div className="chat-message assistant"> <div className="chat-message assistant">
<div className="w-full"> <div className="w-full">
<div className="flex items-center space-x-3 mb-2"> <div className="mb-2 flex items-center space-x-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-transparent p-1 text-sm text-white">
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" /> <SessionProviderLogo provider={selectedProvider} className="h-full w-full" />
</div> </div>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'} {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
</div> </div>
</div> </div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0"> <div className="w-full pl-3 text-sm text-gray-500 dark:text-gray-400 sm:pl-0">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className="animate-pulse">.</div> <div className="animate-pulse">.</div>
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}> <div className="animate-pulse" style={{ animationDelay: '0.2s' }}>

View File

@@ -1,9 +1,3 @@
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import MicButton from '../../../mic-button/view/MicButton';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { import type {
ChangeEvent, ChangeEvent,
@@ -17,7 +11,13 @@ import type {
SetStateAction, SetStateAction,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
interface MentionableFile { interface MentionableFile {
name: string; name: string;
@@ -169,7 +169,7 @@ export default function ChatComposer({
: ''; : '';
return ( return (
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}> <div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && ( {!hasQuestionPanel && (
<div className="flex-1"> <div className="flex-1">
<ClaudeStatus <ClaudeStatus
@@ -181,7 +181,7 @@ export default function ChatComposer({
</div> </div>
)} )}
<div className="max-w-4xl mx-auto mb-3"> <div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner <PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests} pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision} handlePermissionDecision={handlePermissionDecision}
@@ -205,11 +205,11 @@ export default function ChatComposer({
/>} />}
</div> </div>
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto"> {!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative mx-auto max-w-4xl">
{isDragActive && ( {isDragActive && (
<div className="absolute inset-0 bg-primary/15 border-2 border-dashed border-primary/50 rounded-2xl flex items-center justify-center z-50"> <div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="bg-card rounded-xl p-4 shadow-lg border border-border/30"> <div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="w-8 h-8 text-primary mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -223,7 +223,7 @@ export default function ChatComposer({
)} )}
{attachedImages.length > 0 && ( {attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-muted/40 rounded-xl"> <div className="mb-2 rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => ( {attachedImages.map((file, index) => (
<ImageAttachment <ImageAttachment
@@ -239,14 +239,14 @@ export default function ChatComposer({
)} )}
{showFileDropdown && filteredFiles.length > 0 && ( {showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card/95 backdrop-blur-md border border-border/50 rounded-xl shadow-lg max-h-48 overflow-y-auto z-50"> <div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => ( {filteredFiles.map((file, index) => (
<div <div
key={file.path} key={file.path}
className={`px-4 py-3 cursor-pointer border-b border-border/30 last:border-b-0 touch-manipulation ${ className={`cursor-pointer touch-manipulation border-b border-border/30 px-4 py-3 last:border-b-0 ${
index === selectedFileIndex index === selectedFileIndex
? 'bg-primary/8 text-primary' ? 'bg-primary/8 text-primary'
: 'hover:bg-accent/50 text-foreground' : 'text-foreground hover:bg-accent/50'
}`} }`}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
@@ -258,8 +258,8 @@ export default function ChatComposer({
onSelectFile(file); onSelectFile(file);
}} }}
> >
<div className="font-medium text-sm">{file.name}</div> <div className="text-sm font-medium">{file.name}</div>
<div className="text-xs text-muted-foreground font-mono">{file.path}</div> <div className="font-mono text-xs text-muted-foreground">{file.path}</div>
</div> </div>
))} ))}
</div> </div>
@@ -277,13 +277,13 @@ export default function ChatComposer({
<div <div
{...getRootProps()} {...getRootProps()}
className={`relative bg-card/80 backdrop-blur-sm rounded-2xl shadow-sm border border-border/50 focus-within:shadow-md focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/15 transition-all duration-200 overflow-hidden ${ className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${
isTextareaExpanded ? 'chat-input-expanded' : '' isTextareaExpanded ? 'chat-input-expanded' : ''
}`} }`}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
<div ref={inputHighlightRef} aria-hidden="true" className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"> <div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words"> <div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40">
{renderInputWithMentions(input)} {renderInputWithMentions(input)}
</div> </div>
</div> </div>
@@ -302,17 +302,17 @@ export default function ChatComposer({
onInput={onTextareaInput} onInput={onTextareaInput}
placeholder={placeholder} placeholder={placeholder}
disabled={isLoading} disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-foreground placeholder-muted-foreground/50 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200" className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }} style={{ height: '50px' }}
/> />
<button <button
type="button" type="button"
onClick={openImagePicker} onClick={openImagePicker}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-accent/60 rounded-xl transition-colors" className="absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60"
title={t('input.attachImages')} title={t('input.attachImages')}
> >
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -322,8 +322,8 @@ export default function ChatComposer({
</svg> </svg>
</button> </button>
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}> <div className="absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16" style={{ display: 'none' }}>
<MicButton onTranscript={onTranscript} className="w-10 h-10 sm:w-10 sm:h-10" /> <MicButton onTranscript={onTranscript} className="h-10 w-10 sm:h-10 sm:w-10" />
</div> </div>
<button <button
@@ -337,15 +337,15 @@ export default function ChatComposer({
event.preventDefault(); event.preventDefault();
onSubmit(event); onSubmit(event);
}} }}
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-11 sm:h-11 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed rounded-xl flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background" className="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11"
> >
<svg className="w-4 h-4 sm:w-[18px] sm:h-[18px] text-primary-foreground transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg> </svg>
</button> </button>
<div <div
className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-muted-foreground/50 pointer-events-none hidden sm:block transition-opacity duration-200 ${ className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${
input.trim() ? 'opacity-0' : 'opacity-100' input.trim() ? 'opacity-0' : 'opacity-100'
}`} }`}
> >

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector'; import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie'; import TokenUsagePie from './TokenUsagePie';
import type { PermissionMode, Provider } from '../../types/types';
interface ChatInputControlsProps { interface ChatInputControlsProps {
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
@@ -38,24 +38,24 @@ export default function ChatInputControls({
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
return ( return (
<div className="flex items-center justify-center gap-2 sm:gap-3 flex-wrap"> <div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
<button <button
type="button" type="button"
onClick={onModeSwitch} onClick={onModeSwitch}
className={`px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${ className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${
permissionMode === 'default' permissionMode === 'default'
? 'bg-muted/50 text-muted-foreground border-border/60 hover:bg-muted' ? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/15 text-green-700 dark:text-green-300 border-green-300/60 dark:border-green-600/40 hover:bg-green-100 dark:hover:bg-green-900/25' ? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions' : permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/15 text-orange-700 dark:text-orange-300 border-orange-300/60 dark:border-orange-600/40 hover:bg-orange-100 dark:hover:bg-orange-900/25' ? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'bg-primary/5 text-primary border-primary/20 hover:bg-primary/10' : 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`} }`}
title={t('input.clickToChangeMode')} title={t('input.clickToChangeMode')}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className={`w-1.5 h-1.5 rounded-full ${ className={`h-1.5 w-1.5 rounded-full ${
permissionMode === 'default' permissionMode === 'default'
? 'bg-muted-foreground' ? 'bg-muted-foreground'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
@@ -83,10 +83,10 @@ export default function ChatInputControls({
<button <button
type="button" type="button"
onClick={onToggleCommandMenu} onClick={onToggleCommandMenu}
className="relative w-7 h-7 sm:w-8 sm:h-8 text-muted-foreground hover:text-foreground rounded-lg flex items-center justify-center transition-colors hover:bg-accent/60" className="relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8"
title={t('input.showAllCommands')} title={t('input.showAllCommands')}
> >
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -96,7 +96,7 @@ export default function ChatInputControls({
</svg> </svg>
{slashCommandsCount > 0 && ( {slashCommandsCount > 0 && (
<span <span
className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[10px] font-bold rounded-full w-4 h-4 sm:w-5 sm:h-5 flex items-center justify-center" className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5"
> >
{slashCommandsCount} {slashCommandsCount}
</span> </span>
@@ -107,11 +107,11 @@ export default function ChatInputControls({
<button <button
type="button" type="button"
onClick={onClearInput} onClick={onClearInput}
className="w-7 h-7 sm:w-8 sm:h-8 bg-card hover:bg-accent/60 border border-border/50 rounded-lg flex items-center justify-center transition-all duration-200 group shadow-sm" className="group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8"
title={t('input.clearInput', { defaultValue: 'Clear input' })} title={t('input.clearInput', { defaultValue: 'Clear input' })}
> >
<svg <svg
className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground group-hover:text-foreground transition-colors" className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -124,10 +124,10 @@ export default function ChatInputControls({
{isUserScrolledUp && hasMessages && ( {isUserScrolledUp && hasMessages && (
<button <button
onClick={onScrollToBottom} onClick={onScrollToBottom}
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105" className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })} title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
> >
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg> </svg>
</button> </button>

View File

@@ -1,13 +1,12 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import type { ChatMessage } from '../../types/types'; import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app'; import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
interface ChatMessagesPaneProps { interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>; scrollContainerRef: RefObject<HTMLDivElement>;
@@ -134,12 +133,12 @@ export default function ChatMessagesPane({
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={onWheel} onWheel={onWheel}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative" className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
> >
{isLoadingSessionMessages && chatMessages.length === 0 ? ( {isLoadingSessionMessages && chatMessages.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 mt-8"> <div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" /> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
<p>{t('session.loading.sessionMessages')}</p> <p>{t('session.loading.sessionMessages')}</p>
</div> </div>
</div> </div>
@@ -167,9 +166,9 @@ export default function ChatMessagesPane({
<> <>
{/* Loading indicator for older messages (hide when load-all is active) */} {/* Loading indicator for older messages (hide when load-all is active) */}
{isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && ( {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
<div className="text-center text-gray-500 dark:text-gray-400 py-3"> <div className="py-3 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" /> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
<p className="text-sm">{t('session.loading.olderMessages')}</p> <p className="text-sm">{t('session.loading.olderMessages')}</p>
</div> </div>
</div> </div>
@@ -177,7 +176,7 @@ export default function ChatMessagesPane({
{/* Indicator showing there are more messages to load (hide when all loaded) */} {/* Indicator showing there are more messages to load (hide when all loaded) */}
{hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && ( {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{totalMessages > 0 && ( {totalMessages > 0 && (
<span> <span>
{t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '} {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '}
@@ -189,22 +188,22 @@ export default function ChatMessagesPane({
{/* Floating "Load all messages" overlay */} {/* Floating "Load all messages" overlay */}
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && ( {(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
<div className="sticky top-2 z-20 flex justify-center pointer-events-none"> <div className="pointer-events-none sticky top-2 z-20 flex justify-center">
{loadAllJustFinished ? ( {loadAllJustFinished ? (
<div className="px-4 py-1.5 text-xs font-medium text-white bg-green-600 dark:bg-green-500 rounded-full shadow-lg flex items-center space-x-2"> <div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
<span>{t('session.messages.allLoaded')}</span> <span>{t('session.messages.allLoaded')}</span>
</div> </div>
) : ( ) : (
<button <button
className="pointer-events-auto px-4 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded-full shadow-lg transition-all duration-200 hover:scale-105 disabled:opacity-75 disabled:cursor-wait flex items-center space-x-2" className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={loadAllMessages} onClick={loadAllMessages}
disabled={isLoadingAllMessages} disabled={isLoadingAllMessages}
> >
{isLoadingAllMessages && ( {isLoadingAllMessages && (
<div className="animate-spin rounded-full h-3 w-3 border-2 border-white/30 border-t-white" /> <div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)} )}
<span> <span>
{isLoadingAllMessages {isLoadingAllMessages
@@ -219,21 +218,21 @@ export default function ChatMessagesPane({
{/* Performance warning when all messages are loaded */} {/* Performance warning when all messages are loaded */}
{allMessagesLoaded && ( {allMessagesLoaded && (
<div className="text-center text-amber-600 dark:text-amber-400 text-xs py-1.5 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"> <div className="border-b border-amber-200 bg-amber-50 py-1.5 text-center text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400">
{t('session.messages.perfWarning')} {t('session.messages.perfWarning')}
</div> </div>
)} )}
{/* Legacy message count indicator (for non-paginated view) */} {/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && ( {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} | {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |
<button className="ml-1 text-blue-600 hover:text-blue-700 underline" onClick={loadEarlierMessages}> <button className="ml-1 text-blue-600 underline hover:text-blue-700" onClick={loadEarlierMessages}>
{t('session.messages.loadEarlier')} {t('session.messages.loadEarlier')}
</button> </button>
{' | '} {' | '}
<button <button
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline" className="text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onClick={loadAllMessages} onClick={loadAllMessages}
> >
{t('session.messages.loadAll')} {t('session.messages.loadAll')}
@@ -247,7 +246,6 @@ export default function ChatMessagesPane({
<MessageComponent <MessageComponent
key={getMessageKey(message)} key={getMessageKey(message)}
message={message} message={message}
index={index}
prevMessage={prevMessage} prevMessage={prevMessage}
createDiff={createDiff} createDiff={createDiff}
onFileOpen={onFileOpen} onFileOpen={onFileOpen}

View File

@@ -109,8 +109,8 @@ export default function ClaudeStatus({
: t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' }); : t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' });
return ( return (
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300"> <div className="animate-in slide-in-from-bottom mb-3 w-full duration-300 sm:mb-6">
<div className="relative max-w-4xl mx-auto overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md"> <div className="relative mx-auto max-w-4xl overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20" />
<div className="relative px-3 py-3 sm:px-4 sm:py-3.5"> <div className="relative px-3 py-3 sm:px-4 sm:py-3.5">
@@ -160,7 +160,7 @@ export default function ClaudeStatus({
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs"> <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs">
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-flex items-center -ml-2 rounded-full border border-border/70 bg-background/60 px-2 py-0.5" className="-ml-2 inline-flex items-center rounded-full border border-border/70 bg-background/60 px-2 py-0.5"
> >
{elapsedLabel} {elapsedLabel}
</span> </span>
@@ -173,7 +173,7 @@ export default function ClaudeStatus({
<button <button
type="button" type="button"
onClick={onAbort} onClick={onAbort}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 active:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 sm:w-auto" className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 active:opacity-90 sm:w-auto"
> >
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

View File

@@ -17,16 +17,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
}, [file]); }, [file]);
return ( return (
<div className="relative group"> <div className="group relative">
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" /> <img src={preview} alt={file.name} className="h-20 w-20 rounded object-cover" />
{uploadProgress !== undefined && uploadProgress < 100 && ( {uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-white text-xs">{uploadProgress}%</div> <div className="text-xs text-white">{uploadProgress}%</div>
</div> </div>
)} )}
{error && ( {error && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center bg-red-500/50">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </div>
@@ -34,10 +34,10 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
aria-label="Remove image" aria-label="Remove image"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>

View File

@@ -32,7 +32,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
if (shouldInline) { if (shouldInline) {
return ( return (
<code <code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || '' className={`whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] text-gray-900 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-100 ${className || ''
}`} }`}
{...props} {...props}
> >
@@ -45,9 +45,9 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const language = match ? match[1] : 'text'; const language = match ? match[1] : 'text';
return ( return (
<div className="relative group my-2"> <div className="group relative my-2">
{language && language !== 'text' && ( {language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div> <div className="absolute left-3 top-2 z-10 text-xs font-medium uppercase text-gray-400">{language}</div>
)} )}
<button <button
@@ -60,13 +60,13 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
} }
}) })
} }
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600" className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
> >
{copied ? ( {copied ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
@@ -78,7 +78,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
) : ( ) : (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg <svg
className="w-3.5 h-3.5" className="h-3.5 w-3.5"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -119,27 +119,27 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = { const markdownComponents = {
code: CodeBlock, code: CodeBlock,
blockquote: ({ children }: { children?: React.ReactNode }) => ( blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2"> <blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children} {children}
</blockquote> </blockquote>
), ),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer"> <a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
{children} {children}
</a> </a>
), ),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>, p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => ( table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-2"> <div className="my-2 overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table> <table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div> </div>
), ),
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>, thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }: { children?: React.ReactNode }) => ( th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th> <th className="border border-gray-200 px-3 py-2 text-left text-sm font-semibold dark:border-gray-700">{children}</th>
), ),
td: ({ children }: { children?: React.ReactNode }) => ( td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td> <td className="border border-gray-200 px-3 py-2 align-top text-sm dark:border-gray-700">{children}</td>
), ),
}; };

View File

@@ -7,12 +7,12 @@ import type {
PermissionGrantResult, PermissionGrantResult,
Provider, Provider,
} from '../../types/types'; } from '../../types/types';
import { Markdown } from './Markdown';
import { formatUsageLimitText } from '../../utils/chatFormatting'; import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions'; import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Markdown } from './Markdown';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -22,7 +22,6 @@ type DiffLine = {
interface MessageComponentProps { interface MessageComponentProps {
message: ChatMessage; message: ChatMessage;
index: number;
prevMessage: ChatMessage | null; prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[]; createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
@@ -43,7 +42,7 @@ type InteractiveOption = {
type PermissionGrantState = 'idle' | 'granted' | 'error'; type PermissionGrantState = 'idle' | 'granted' | 'error';
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
@@ -101,9 +100,9 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
> >
{message.type === 'user' ? ( {message.type === 'user' ? (
/* User message bubble on the right */ /* User message bubble on the right */
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial group"> <div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div className="text-sm whitespace-pre-wrap break-words"> <div className="whitespace-pre-wrap break-words text-sm">
{message.content} {message.content}
</div> </div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
@@ -113,13 +112,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
key={img.name || idx} key={img.name || idx}
src={img.data} src={img.data}
alt={img.name} alt={img.name}
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity" className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
onClick={() => window.open(img.data, '_blank')} onClick={() => window.open(img.data, '_blank')}
/> />
))} ))}
</div> </div>
)} )}
<div className="flex items-center justify-end gap-1 mt-1 text-xs text-blue-100"> <div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -135,7 +134,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')} aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
> >
{messageCopied ? ( {messageCopied ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
@@ -144,7 +143,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</svg> </svg>
) : ( ) : (
<svg <svg
className="w-3.5 h-3.5" className="h-3.5 w-3.5"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -161,7 +160,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div> </div>
</div> </div>
{!isGrouped && ( {!isGrouped && (
<div className="hidden sm:flex w-8 h-8 bg-blue-600 rounded-full items-center justify-center text-white text-sm flex-shrink-0"> <div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">
U U
</div> </div>
)} )}
@@ -170,7 +169,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
/* Compact task notification on the left */ /* Compact task notification on the left */
<div className="w-full"> <div className="w-full">
<div className="flex items-center gap-2 py-0.5"> <div className="flex items-center gap-2 py-0.5">
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} /> <span className={`inline-block h-1.5 w-1.5 flex-shrink-0 rounded-full ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span> <span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
</div> </div>
</div> </div>
@@ -178,18 +177,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
/* Claude/Error/Tool messages on the left */ /* Claude/Error/Tool messages on the left */
<div className="w-full"> <div className="w-full">
{!isGrouped && ( {!isGrouped && (
<div className="flex items-center space-x-3 mb-2"> <div className="mb-2 flex items-center space-x-3">
{message.type === 'error' ? ( {message.type === 'error' ? (
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-600 text-sm text-white">
! !
</div> </div>
) : message.type === 'tool' ? ( ) : message.type === 'tool' ? (
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-sm text-white dark:bg-gray-700">
🔧 🔧
</div> </div>
) : ( ) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
<SessionProviderLogo provider={provider} className="w-full h-full" /> <SessionProviderLogo provider={provider} className="h-full w-full" />
</div> </div>
)} )}
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">
@@ -234,20 +233,20 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Error results - red error box with content // Error results - red error box with content
<div <div
id={`tool-result-${message.toolId}`} id={`tool-result-${message.toolId}`}
className="relative mt-2 p-3 rounded border scroll-mt-4 bg-red-50/50 dark:bg-red-950/10 border-red-200/60 dark:border-red-800/40" className="relative mt-2 scroll-mt-4 rounded border border-red-200/60 bg-red-50/50 p-3 dark:border-red-800/40 dark:bg-red-950/10"
> >
<div className="relative flex items-center gap-1.5 mb-2"> <div className="relative mb-2 flex items-center gap-1.5">
<svg className="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span> <span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div> </div>
<div className="relative text-sm text-red-900 dark:text-red-100"> <div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert"> <Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')} {String(message.toolResult.content || '')}
</Markdown> </Markdown>
{permissionSuggestion && ( {permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3"> <div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
@@ -261,9 +260,9 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
} }
}} }}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'} disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted' className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default' ? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70' : 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`} }`}
> >
{permissionSuggestion.isAllowed || permissionGrantState === 'granted' {permissionSuggestion.isAllowed || permissionGrantState === 'granted'
@@ -274,7 +273,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }} onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100" className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
> >
{t('permissions.openSettings')} {t('permissions.openSettings')}
</button> </button>
@@ -317,15 +316,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</> </>
) : message.isInteractivePrompt ? ( ) : message.isInteractivePrompt ? (
// Special handling for interactive prompts // Special handling for interactive prompts
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4"> <div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-500">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3"> <h4 className="mb-3 text-base font-semibold text-amber-900 dark:text-amber-100">
{t('interactive.title')} {t('interactive.title')}
</h4> </h4>
{(() => { {(() => {
@@ -349,29 +348,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return ( return (
<> <>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4"> <p className="mb-4 text-sm text-amber-800 dark:text-amber-200">
{questionLine} {questionLine}
</p> </p>
{/* Option buttons */} {/* Option buttons */}
<div className="space-y-2 mb-4"> <div className="mb-4 space-y-2">
{options.map((option) => ( {options.map((option) => (
<button <button
key={option.number} key={option.number}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected className={`w-full rounded-lg border-2 px-4 py-3 text-left transition-all ${option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md' ? 'border-amber-600 bg-amber-600 text-white shadow-md dark:border-amber-700 dark:bg-amber-700'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700' : 'border-amber-300 bg-white text-amber-900 dark:border-amber-700 dark:bg-gray-800 dark:text-amber-100'
} cursor-not-allowed opacity-75`} } cursor-not-allowed opacity-75`}
disabled disabled
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected <span className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-bold ${option.isSelected
? 'bg-white/20' ? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50' : 'bg-amber-100 dark:bg-amber-800/50'
}`}> }`}>
{option.number} {option.number}
</span> </span>
<span className="text-sm sm:text-base font-medium flex-1"> <span className="flex-1 text-sm font-medium sm:text-base">
{option.text} {option.text}
</span> </span>
{option.isSelected && ( {option.isSelected && (
@@ -382,11 +381,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
))} ))}
</div> </div>
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3"> <div className="rounded-lg bg-amber-100 p-3 dark:bg-amber-800/30">
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1"> <p className="mb-1 text-sm font-medium text-amber-900 dark:text-amber-100">
{t('interactive.waiting')} {t('interactive.waiting')}
</p> </p>
<p className="text-amber-800 dark:text-amber-200 text-xs"> <p className="text-xs text-amber-800 dark:text-amber-200">
{t('interactive.instruction')} {t('interactive.instruction')}
</p> </p>
</div> </div>
@@ -400,14 +399,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
/* Thinking messages - collapsible by default */ /* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group"> <details className="group">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2"> <summary className="flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-3 w-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<span>{t('thinking.emoji')}</span> <span>{t('thinking.emoji')}</span>
</summary> </summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm"> <div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray"> <Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content} {message.content}
</Markdown> </Markdown>
</div> </div>
@@ -418,10 +417,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{/* Thinking accordion for reasoning */} {/* Thinking accordion for reasoning */}
{showThinking && message.reasoning && ( {showThinking && message.reasoning && (
<details className="mb-3"> <details className="mb-3">
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium"> <summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
{t('thinking.emoji')} {t('thinking.emoji')}
</summary> </summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm"> <div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
<div className="whitespace-pre-wrap"> <div className="whitespace-pre-wrap">
{message.reasoning} {message.reasoning}
</div> </div>
@@ -442,15 +441,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return ( return (
<div className="my-2"> <div className="my-2">
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400"> <div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg> </svg>
<span className="font-medium">{t('json.response')}</span> <span className="font-medium">{t('json.response')}</span>
</div> </div>
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900">
<pre className="p-4 overflow-x-auto"> <pre className="overflow-x-auto p-4">
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre"> <code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200">
{formatted} {formatted}
</code> </code>
</pre> </pre>
@@ -464,7 +463,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Normal rendering for non-JSON content // Normal rendering for non-JSON content
return message.type === 'assistant' ? ( return message.type === 'assistant' ? (
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray"> <Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{content} {content}
</Markdown> </Markdown>
) : ( ) : (
@@ -477,7 +476,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)} )}
{!isGrouped && ( {!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1"> <div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
{formattedTime} {formattedTime}
</div> </div>
)} )}

View File

@@ -56,7 +56,7 @@ export default function PermissionRequestsBanner({
return ( return (
<div <div
key={request.requestId} key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm" className="rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20"
> >
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -74,10 +74,10 @@ export default function PermissionRequestsBanner({
{rawInput && ( {rawInput && (
<details className="mt-2"> <details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100"> <summary className="cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100">
View tool input View tool input
</summary> </summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap"> <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100">
{rawInput} {rawInput}
</pre> </pre>
</details> </details>
@@ -87,7 +87,7 @@ export default function PermissionRequestsBanner({
<button <button
type="button" type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })} onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors" className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700"
> >
Allow once Allow once
</button> </button>
@@ -99,10 +99,10 @@ export default function PermissionRequestsBanner({
} }
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry }); handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}} }}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${ className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
permissionEntry permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30' ? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed' : 'cursor-not-allowed border-gray-300 text-gray-400'
}`} }`}
disabled={!permissionEntry} disabled={!permissionEntry}
> >
@@ -111,7 +111,7 @@ export default function PermissionRequestsBanner({
<button <button
type="button" type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })} onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors" className="inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30"
> >
Deny Deny
</button> </button>

View File

@@ -2,9 +2,9 @@ import React from 'react';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app'; import type { ProjectSession, SessionProvider } from '../../../../types/app';
import { NextTaskBanner } from '../../../task-master';
interface ProviderSelectionEmptyStateProps { interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -125,20 +125,20 @@ export default function ProviderSelectionEmptyState({
/* ── New session — provider picker ── */ /* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) { if (!selectedSession && !currentSessionId) {
return ( return (
<div className="flex items-center justify-center h-full px-4"> <div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Heading */} {/* Heading */}
<div className="text-center mb-8"> <div className="mb-8 text-center">
<h2 className="text-lg sm:text-xl font-semibold text-foreground tracking-tight"> <h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t('providerSelection.title')} {t('providerSelection.title')}
</h2> </h2>
<p className="text-[13px] text-muted-foreground mt-1"> <p className="mt-1 text-[13px] text-muted-foreground">
{t('providerSelection.description')} {t('providerSelection.description')}
</p> </p>
</div> </div>
{/* Provider cards — horizontal row, equal width */} {/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6"> <div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5">
{PROVIDERS.map((p) => { {PROVIDERS.map((p) => {
const active = provider === p.id; const active = provider === p.id;
return ( return (
@@ -146,27 +146,27 @@ export default function ProviderSelectionEmptyState({
key={p.id} key={p.id}
onClick={() => selectProvider(p.id)} onClick={() => selectProvider(p.id)}
className={` className={`
relative flex flex-col items-center gap-2.5 pt-5 pb-4 px-2 relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
rounded-xl border-[1.5px] transition-all duration-150 pb-4 pt-5 transition-all duration-150
active:scale-[0.97] active:scale-[0.97]
${active ${active
? `${p.accent} ${p.ring} ring-2 bg-card shadow-sm` ? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: 'border-border bg-card/60 hover:bg-card hover:border-border/80' : 'border-border bg-card/60 hover:border-border/80 hover:bg-card'
} }
`} `}
> >
<SessionProviderLogo <SessionProviderLogo
provider={p.id} provider={p.id}
className={`w-9 h-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`} className={`h-9 w-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
/> />
<div className="text-center"> <div className="text-center">
<p className="text-[13px] font-semibold text-foreground leading-none">{p.name}</p> <p className="text-[13px] font-semibold leading-none text-foreground">{p.name}</p>
<p className="text-[10px] text-muted-foreground mt-1 leading-tight">{t(p.infoKey)}</p> <p className="mt-1 text-[10px] leading-tight text-muted-foreground">{t(p.infoKey)}</p>
</div> </div>
{/* Check badge */} {/* Check badge */}
{active && ( {active && (
<div className={`absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}> <div className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<Check className="w-2.5 h-2.5" strokeWidth={3} /> <Check className="h-2.5 w-2.5" strokeWidth={3} />
</div> </div>
)} )}
</button> </button>
@@ -175,21 +175,21 @@ export default function ProviderSelectionEmptyState({
</div> </div>
{/* Model picker — appears after provider is chosen */} {/* Model picker — appears after provider is chosen */}
<div className={`transition-all duration-200 ${provider ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}> <div className={`transition-all duration-200 ${provider ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'}`}>
<div className="flex items-center justify-center gap-2 mb-5"> <div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span> <span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
<div className="relative"> <div className="relative">
<select <select
value={currentModel} value={currentModel}
onChange={(e) => handleModelChange(e.target.value)} onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1} tabIndex={-1}
className="appearance-none pl-3 pr-7 py-1.5 text-sm font-medium bg-muted/50 border border-border/60 rounded-lg text-foreground cursor-pointer hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20" className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
> >
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => ( {modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option> <option key={value} value={value}>{label}</option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div> </div>
</div> </div>
@@ -219,10 +219,10 @@ export default function ProviderSelectionEmptyState({
/* ── Existing session — continue prompt ── */ /* ── Existing session — continue prompt ── */
if (selectedSession) { if (selectedSession) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="text-center px-6 max-w-md"> <div className="max-w-md px-6 text-center">
<p className="text-lg font-semibold text-foreground mb-1.5">{t('session.continue.title')}</p> <p className="mb-1.5 text-lg font-semibold text-foreground">{t('session.continue.title')}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{t('session.continue.description')}</p> <p className="text-sm leading-relaxed text-muted-foreground">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && ( {tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5"> <div className="mt-5">

View File

@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Brain, X } from 'lucide-react'; import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes'; import { thinkingModes } from '../../constants/thinkingModes';
type ThinkingModeSelectorProps = { type ThinkingModeSelectorProps = {
@@ -53,18 +52,18 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={`w-10 h-10 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-200 ${selectedMode === 'none' className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600' ? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800' : 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`} }`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })} title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
> >
<IconComponent className={`w-5 h-5 ${currentMode.color}`} /> <IconComponent className={`h-5 w-5 ${currentMode.color}`} />
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute bottom-full right-0 mb-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden"> <div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="p-3 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')} {t('thinkingMode.selector.title')}
@@ -74,12 +73,12 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
setIsOpen(false); setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
}} }}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<X className="w-4 h-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
</button> </button>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('thinkingMode.selector.description')} {t('thinkingMode.selector.description')}
</p> </p>
</div> </div>
@@ -97,30 +96,30 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
setIsOpen(false); setIsOpen(false);
if (onClose) onClose(); if (onClose) onClose();
}} }}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : '' className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}> <div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
{ModeIcon ? <ModeIcon className="w-5 h-5" /> : <div className="w-5 h-5" />} {ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-medium text-sm ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300' <span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
}`}> }`}>
{mode.name} {mode.name}
</span> </span>
{isSelected && ( {isSelected && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded"> <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t('thinkingMode.selector.active')} {t('thinkingMode.selector.active')}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{mode.description} {mode.description}
</p> </p>
{mode.prefix && ( {mode.prefix && (
<code className="text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded mt-1 inline-block"> <code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
{mode.prefix} {mode.prefix}
</code> </code>
)} )}
@@ -131,7 +130,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
})} })}
</div> </div>
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"> <div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Tip:</strong> {t('thinkingMode.selector.tip')} <strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p> </p>

View File

@@ -22,7 +22,7 @@ export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
return ( return (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" className="transform -rotate-90"> <svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
{/* Background circle */} {/* Background circle */}
<circle <circle
cx="12" cx="12"

View File

@@ -220,7 +220,7 @@ export default function CodeEditor({
/> />
{saveError && ( {saveError && (
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40"> <div className="border-b border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
{saveError} {saveError}
</div> </div>
)} )}

View File

@@ -102,20 +102,20 @@ export default function EditorSidebar({
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth); const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return ( return (
<div ref={containerRef} className={`flex h-full flex-shrink-0 min-w-0 ${editorExpanded ? 'flex-1' : ''}`}> <div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
{!editorExpanded && ( {!editorExpanded && (
<div <div
ref={resizeHandleRef} ref={resizeHandleRef}
onMouseDown={onResizeStart} onMouseDown={onResizeStart}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group" className="group relative w-1 flex-shrink-0 cursor-col-resize bg-gray-200 transition-colors hover:bg-blue-500 dark:bg-gray-700 dark:hover:bg-blue-600"
title="Drag to resize" title="Drag to resize"
> >
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100 dark:bg-blue-600" />
</div> </div>
)} )}
<div <div
className={`border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1 min-w-0' : `flex-shrink-0 min-w-[${MIN_EDITOR_WIDTH}px]`}`} className={`h-full overflow-hidden border-l border-gray-200 dark:border-gray-700 ${useFlexLayout ? 'min-w-0 flex-1' : `min-w-[ flex-shrink-0${MIN_EDITOR_WIDTH}px]`}`}
style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }} style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }}
> >
<CodeEditor <CodeEditor

View File

@@ -20,20 +20,20 @@ export default function CodeEditorBinaryFile({
message, message,
}: CodeEditorBinaryFileProps) { }: CodeEditorBinaryFileProps) {
const binaryContent = ( const binaryContent = (
<div className="w-full h-full flex flex-col items-center justify-center bg-background text-muted-foreground p-8"> <div className="flex h-full w-full flex-col items-center justify-center bg-background p-8 text-muted-foreground">
<div className="flex flex-col items-center gap-4 max-w-md text-center"> <div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg className="w-8 h-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-8 w-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="text-lg font-medium text-foreground mb-2">{title}</h3> <h3 className="mb-2 text-lg font-medium text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">{message}</p> <p className="text-sm text-muted-foreground">{message}</p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors" className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
> >
Close Close
</button> </button>
@@ -43,18 +43,18 @@ export default function CodeEditorBinaryFile({
if (isSidebar) { if (isSidebar) {
return ( return (
<div className="w-full h-full flex flex-col bg-background"> <div className="flex h-full w-full flex-col bg-background">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0"> <div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3> <h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title="Close" title="Close"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -75,23 +75,23 @@ export default function CodeEditorBinaryFile({
return ( return (
<div className={containerClassName}> <div className={containerClassName}>
<div className={innerClassName}> <div className={innerClassName}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0"> <div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3> <h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex shrink-0 items-center gap-0.5">
<button <button
type="button" type="button"
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
> >
{isFullscreen ? ( {isFullscreen ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg> </svg>
)} )}
@@ -99,10 +99,10 @@ export default function CodeEditorBinaryFile({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title="Close" title="Close"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>

View File

@@ -12,7 +12,7 @@ export default function CodeEditorFooter({
shortcutsLabel, shortcutsLabel,
}: CodeEditorFooterProps) { }: CodeEditorFooterProps) {
return ( return (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0"> <div className="flex flex-shrink-0 items-center justify-between border-t border-border bg-muted px-3 py-1.5">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span> <span>
{linesLabel} {content.split('\n').length} {linesLabel} {content.split('\n').length}

View File

@@ -49,74 +49,74 @@ export default function CodeEditorHeader({
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save; const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
return ( return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0 gap-2"> <div className="flex min-w-0 flex-shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-1.5">
{/* File info - can shrink */} {/* File info - can shrink */}
<div className="flex items-center gap-2 min-w-0 flex-1 shrink"> <div className="flex min-w-0 flex-1 shrink items-center gap-2">
<div className="min-w-0 shrink"> <div className="min-w-0 shrink">
<div className="flex items-center gap-2 min-w-0"> <div className="flex min-w-0 items-center gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3> <h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
{file.diffInfo && ( {file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap shrink-0"> <span className="shrink-0 whitespace-nowrap rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-600 dark:bg-blue-900 dark:text-blue-300">
{labels.showingChanges} {labels.showingChanges}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p> <p className="truncate text-xs text-gray-500 dark:text-gray-400">{file.path}</p>
</div> </div>
</div> </div>
{/* Buttons - don't shrink, always visible */} {/* Buttons - don't shrink, always visible */}
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex shrink-0 items-center gap-0.5">
{isMarkdownFile && ( {isMarkdownFile && (
<button <button
type="button" type="button"
onClick={onToggleMarkdownPreview} onClick={onToggleMarkdownPreview}
className={`p-1.5 rounded-md flex items-center justify-center transition-colors ${ className={`flex items-center justify-center rounded-md p-1.5 transition-colors ${
markdownPreview markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
}`} }`}
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown} title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
> >
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {markdownPreview ? <Code2 className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={onOpenSettings} onClick={onOpenSettings}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.settings} title={labels.settings}
> >
<SettingsIcon className="w-4 h-4" /> <SettingsIcon className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
onClick={onDownload} onClick={onDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.download} title={labels.download}
> >
<Download className="w-4 h-4" /> <Download className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
onClick={onSave} onClick={onSave}
disabled={saving} disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors ${ className={`flex items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50 ${
saveSuccess saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30' ? 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
}`} }`}
title={saveTitle} title={saveTitle}
> >
{saveSuccess ? ( {saveSuccess ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
) : ( ) : (
<Save className="w-4 h-4" /> <Save className="h-4 w-4" />
)} )}
</button> </button>
@@ -124,20 +124,20 @@ export default function CodeEditorHeader({
<button <button
type="button" type="button"
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen} title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
> >
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />} {isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center" className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.close} title={labels.close}
> >
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -15,17 +15,17 @@ export default function CodeEditorLoadingState({
<> <>
<style>{getEditorLoadingStyles(isDarkMode)}</style> <style>{getEditorLoadingStyles(isDarkMode)}</style>
{isSidebar ? ( {isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex h-full w-full items-center justify-center bg-background">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" /> <div className="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span> <span className="text-gray-900 dark:text-white">{loadingText}</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center"> <div className="fixed inset-0 z-[9999] md:flex md:items-center md:justify-center md:bg-black/50">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center"> <div className="code-editor-loading flex h-full w-full items-center justify-center p-8 md:h-auto md:w-auto md:rounded-lg">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" /> <div className="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span> <span className="text-gray-900 dark:text-white">{loadingText}</span>
</div> </div>
</div> </div>

View File

@@ -27,7 +27,7 @@ export default function CodeEditorSurface({
if (markdownPreview && isMarkdownFile) { if (markdownPreview && isMarkdownFile) {
return ( return (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900"> <div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none"> <div className="prose prose-sm mx-auto max-w-4xl max-w-none px-8 py-6 dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg dark:prose-a:text-blue-400">
<MarkdownPreview content={content} /> <MarkdownPreview content={content} />
</div> </div>
</div> </div>

View File

@@ -24,7 +24,7 @@ export default function MarkdownCodeBlock({
if (shouldRenderInline) { if (shouldRenderInline) {
return ( return (
<code <code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`} className={`whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] text-gray-900 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-100 ${className || ''}`}
{...props} {...props}
> >
{children} {children}
@@ -36,9 +36,9 @@ export default function MarkdownCodeBlock({
const language = languageMatch ? languageMatch[1] : 'text'; const language = languageMatch ? languageMatch[1] : 'text';
return ( return (
<div className="relative group my-2"> <div className="group relative my-2">
{language !== 'text' && ( {language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div> <div className="absolute left-3 top-2 z-10 text-xs font-medium uppercase text-gray-400">{language}</div>
)} )}
<button <button
@@ -50,7 +50,7 @@ export default function MarkdownCodeBlock({
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
})} })}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600" className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100"
> >
{copied ? 'Copied!' : 'Copy'} {copied ? 'Copied!' : 'Copy'}
</button> </button>

View File

@@ -13,26 +13,26 @@ type MarkdownPreviewProps = {
const markdownPreviewComponents: Components = { const markdownPreviewComponents: Components = {
code: MarkdownCodeBlock, code: MarkdownCodeBlock,
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2"> <blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children} {children}
</blockquote> </blockquote>
), ),
a: ({ href, children }) => ( a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer"> <a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
{children} {children}
</a> </a>
), ),
table: ({ children }) => ( table: ({ children }) => (
<div className="overflow-x-auto my-2"> <div className="my-2 overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table> <table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div> </div>
), ),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>, thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }) => ( th: ({ children }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th> <th className="border border-gray-200 px-3 py-2 text-left text-sm font-semibold dark:border-gray-700">{children}</th>
), ),
td: ({ children }) => ( td: ({ children }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td> <td className="border border-gray-200 px-3 py-2 align-top text-sm dark:border-gray-700">{children}</td>
), ),
}; };

View File

@@ -0,0 +1,312 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Download, FileText, FolderPlus, Pencil, RefreshCw, Trash2, type LucideIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
type FileContextItem = {
name: string;
type: 'file' | 'directory';
path: string;
size?: number;
modified?: string;
permissionsRwx?: string;
children?: FileContextItem[];
[key: string]: unknown;
};
type ContextMenuAction = {
key: string;
label: string;
icon?: LucideIcon;
onSelect?: () => void;
isDanger?: boolean;
isDisabled?: boolean;
shortcut?: string;
showDividerBefore?: boolean;
};
const CONTEXT_MENU_WIDTH = 200;
const CONTEXT_MENU_HEIGHT = 300;
const VIEWPORT_PADDING = 10;
function calculateViewportSafePosition(clientX: number, clientY: number) {
// Keep the context menu inside the visible viewport.
const safeX =
clientX + CONTEXT_MENU_WIDTH > window.innerWidth
? window.innerWidth - CONTEXT_MENU_WIDTH - VIEWPORT_PADDING
: clientX;
const safeY =
clientY + CONTEXT_MENU_HEIGHT > window.innerHeight
? window.innerHeight - CONTEXT_MENU_HEIGHT - VIEWPORT_PADDING
: clientY;
return { x: Math.max(VIEWPORT_PADDING, safeX), y: Math.max(VIEWPORT_PADDING, safeY) };
}
export default function FileContextMenu({
children,
item,
onRename,
onDelete,
onNewFile,
onNewFolder,
onRefresh,
onCopyPath,
onDownload,
isLoading = false,
className = '',
}: {
children: ReactNode;
item?: FileContextItem | null;
onRename?: (item: FileContextItem) => void;
onDelete?: (item: FileContextItem) => void;
onNewFile?: (path: string) => void;
onNewFolder?: (path: string) => void;
onRefresh?: () => void;
onCopyPath?: (item: FileContextItem) => void;
onDownload?: (item: FileContextItem) => void;
isLoading?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const closeContextMenu = useCallback(() => {
setIsMenuOpen(false);
}, []);
const openContextMenuAtCursor = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuPosition(calculateViewportSafePosition(event.clientX, event.clientY));
setIsMenuOpen(true);
}, []);
const runMenuActionAndClose = useCallback((action?: () => void) => {
closeContextMenu();
action?.();
}, [closeContextMenu]);
const menuActions = useMemo<ContextMenuAction[]>(() => {
if (item?.type === 'file') {
return [
{
key: 'rename',
icon: Pencil,
label: t('fileTree.context.rename', 'Rename'),
onSelect: () => onRename?.(item),
},
{
key: 'delete',
icon: Trash2,
label: t('fileTree.context.delete', 'Delete'),
onSelect: () => onDelete?.(item),
isDanger: true,
},
{
key: 'copyPath',
icon: Copy,
label: t('fileTree.context.copyPath', 'Copy Path'),
onSelect: () => onCopyPath?.(item),
showDividerBefore: true,
},
{
key: 'download',
icon: Download,
label: t('fileTree.context.download', 'Download'),
onSelect: () => onDownload?.(item),
},
];
}
if (item?.type === 'directory') {
return [
{
key: 'newFile',
icon: FileText,
label: t('fileTree.context.newFile', 'New File'),
onSelect: () => onNewFile?.(item.path),
},
{
key: 'newFolder',
icon: FolderPlus,
label: t('fileTree.context.newFolder', 'New Folder'),
onSelect: () => onNewFolder?.(item.path),
},
{
key: 'rename',
icon: Pencil,
label: t('fileTree.context.rename', 'Rename'),
onSelect: () => onRename?.(item),
showDividerBefore: true,
},
{
key: 'delete',
icon: Trash2,
label: t('fileTree.context.delete', 'Delete'),
onSelect: () => onDelete?.(item),
isDanger: true,
},
{
key: 'copyPath',
icon: Copy,
label: t('fileTree.context.copyPath', 'Copy Path'),
onSelect: () => onCopyPath?.(item),
showDividerBefore: true,
},
{
key: 'download',
icon: Download,
label: t('fileTree.context.download', 'Download'),
onSelect: () => onDownload?.(item),
},
];
}
return [
{
key: 'newFile',
icon: FileText,
label: t('fileTree.context.newFile', 'New File'),
onSelect: () => onNewFile?.(''),
},
{
key: 'newFolder',
icon: FolderPlus,
label: t('fileTree.context.newFolder', 'New Folder'),
onSelect: () => onNewFolder?.(''),
},
{
key: 'refresh',
icon: RefreshCw,
label: t('fileTree.context.refresh', 'Refresh'),
onSelect: onRefresh,
showDividerBefore: true,
},
];
}, [item, onCopyPath, onDelete, onDownload, onNewFile, onNewFolder, onRefresh, onRename, t]);
useEffect(() => {
if (!isMenuOpen) {
return;
}
const handleOutsideMouseDown = (event: MouseEvent) => {
const menuElement = menuRef.current;
if (menuElement && !menuElement.contains(event.target as Node)) {
closeContextMenu();
}
};
const handleEscapeKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeContextMenu();
}
};
document.addEventListener('mousedown', handleOutsideMouseDown);
document.addEventListener('keydown', handleEscapeKeyDown);
return () => {
document.removeEventListener('mousedown', handleOutsideMouseDown);
document.removeEventListener('keydown', handleEscapeKeyDown);
};
}, [closeContextMenu, isMenuOpen]);
useEffect(() => {
if (!isMenuOpen) {
return;
}
// Arrow key support keeps the menu accessible without a mouse.
const handleKeyboardMenuNavigation = (event: KeyboardEvent) => {
const menuItems = menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
if (!menuItems || menuItems.length === 0) {
return;
}
const activeElement = document.activeElement as HTMLElement | null;
const currentIndex = Array.from(menuItems).findIndex((menuItem) => menuItem === activeElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
menuItems[nextIndex]?.focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const previousIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
menuItems[previousIndex]?.focus();
} else if (event.key === 'Enter' || event.key === ' ') {
if (activeElement?.hasAttribute('role')) {
event.preventDefault();
activeElement.click();
}
}
};
document.addEventListener('keydown', handleKeyboardMenuNavigation);
return () => {
document.removeEventListener('keydown', handleKeyboardMenuNavigation);
};
}, [isMenuOpen]);
return (
<>
<div onContextMenu={openContextMenuAtCursor} className={cn('contents', className)}>
{children}
</div>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{ position: 'fixed', left: menuPosition.x, top: menuPosition.y, zIndex: 9999 }}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">{t('fileTree.context.loading', 'Loading...')}</span>
</div>
) : (
menuActions.map((action) => (
<Fragment key={action.key}>
{action.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
<button
role="menuitem"
tabIndex={action.isDisabled ? -1 : 0}
disabled={isLoading || action.isDisabled}
onClick={() => runMenuActionAndClose(action.onSelect)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
action.isDisabled
? 'opacity-50 cursor-not-allowed'
: action.isDanger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none',
)}
>
{action.icon && <action.icon className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1">{action.label}</span>
{action.shortcut && <span className="font-mono text-xs text-muted-foreground">{action.shortcut}</span>}
</button>
</Fragment>
))
)}
</div>
)}
</>
);
}

View File

@@ -2,7 +2,6 @@ import { useCallback, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react'; import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import ImageViewer from './ImageViewer';
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons'; import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
import { useExpandedDirectories } from '../hooks/useExpandedDirectories'; import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
import { useFileTreeData } from '../hooks/useFileTreeData'; import { useFileTreeData } from '../hooks/useFileTreeData';
@@ -12,13 +11,14 @@ import { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';
import { useFileTreeUpload } from '../hooks/useFileTreeUpload'; import { useFileTreeUpload } from '../hooks/useFileTreeUpload';
import type { FileTreeImageSelection, FileTreeNode } from '../types/types'; import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils'; import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
import { Project } from '../../../types/app';
import { ScrollArea, Input } from '../../../shared/view/ui';
import FileTreeBody from './FileTreeBody'; import FileTreeBody from './FileTreeBody';
import FileTreeDetailedColumns from './FileTreeDetailedColumns'; import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader'; import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState'; import FileTreeLoadingState from './FileTreeLoadingState';
import { Project } from '../../../types/app'; import ImageViewer from './ImageViewer';
import { Input } from '../../ui/input';
import { ScrollArea } from '../../ui/scroll-area';
type FileTreeProps = { type FileTreeProps = {
selectedProject: Project | null; selectedProject: Project | null;
@@ -123,7 +123,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
return ( return (
<div <div
ref={upload.treeRef} ref={upload.treeRef}
className="h-full flex flex-col bg-background relative" className="relative flex h-full flex-col bg-background"
onDragEnter={upload.handleDragEnter} onDragEnter={upload.handleDragEnter}
onDragOver={upload.handleDragOver} onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave} onDragLeave={upload.handleDragLeave}
@@ -131,9 +131,9 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
> >
{/* Drag overlay */} {/* Drag overlay */}
{upload.isDragOver && ( {upload.isDragOver && (
<div className="absolute inset-0 z-50 bg-blue-500/10 border-2 border-dashed border-blue-500 flex items-center justify-center"> <div className="absolute inset-0 z-50 flex items-center justify-center border-2 border-dashed border-blue-500 bg-blue-500/10">
<div className="bg-background/95 px-6 py-4 rounded-lg shadow-lg flex items-center gap-3"> <div className="flex items-center gap-3 rounded-lg bg-background/95 px-6 py-4 shadow-lg">
<Upload className="w-6 h-6 text-blue-500" /> <Upload className="h-6 w-6 text-blue-500" />
<span className="text-sm font-medium">{t('fileTree.dropToUpload', 'Drop files to upload')}</span> <span className="text-sm font-medium">{t('fileTree.dropToUpload', 'Drop files to upload')}</span>
</div> </div>
</div> </div>
@@ -158,7 +158,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
{/* New item input */} {/* New item input */}
{operations.isCreating && ( {operations.isCreating && (
<div <div
className="flex items-center gap-1.5 py-[3px] pr-2 mb-1" className="mb-1 flex items-center gap-1.5 py-[3px] pr-2"
style={{ paddingLeft: `${(operations.newItemParent.split('/').length - 1) * 16 + 4}px` }} style={{ paddingLeft: `${(operations.newItemParent.split('/').length - 1) * 16 + 4}px` }}
> >
{operations.newItemType === 'directory' ? ( {operations.newItemType === 'directory' ? (
@@ -181,7 +181,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
if (operations.isCreating) operations.handleConfirmCreate(); if (operations.isCreating) operations.handleConfirmCreate();
}, 100); }, 100);
}} }}
className="h-6 text-sm flex-1" className="h-6 flex-1 text-sm"
disabled={operations.operationLoading} disabled={operations.operationLoading}
/> />
</div> </div>
@@ -225,10 +225,10 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
{operations.deleteConfirmation.isOpen && operations.deleteConfirmation.item && ( {operations.deleteConfirmation.isOpen && operations.deleteConfirmation.item && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg shadow-lg p-4 max-w-sm mx-4"> <div className="mx-4 max-w-sm rounded-lg border border-border bg-background p-4 shadow-lg">
<div className="flex items-center gap-3 mb-4"> <div className="mb-4 flex items-center gap-3">
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900/30"> <div className="rounded-full bg-red-100 p-2 dark:bg-red-900/30">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" /> <AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div> </div>
<div> <div>
<h3 className="font-medium text-foreground"> <h3 className="font-medium text-foreground">
@@ -241,7 +241,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
</p> </p>
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground mb-4"> <p className="mb-4 text-sm text-muted-foreground">
{operations.deleteConfirmation.item.type === 'directory' {operations.deleteConfirmation.item.type === 'directory'
? t('fileTree.delete.folderWarning', 'This folder and all its contents will be permanently deleted.') ? t('fileTree.delete.folderWarning', 'This folder and all its contents will be permanently deleted.')
: t('fileTree.delete.fileWarning', 'This file will be permanently deleted.')} : t('fileTree.delete.fileWarning', 'This file will be permanently deleted.')}
@@ -250,16 +250,16 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
<button <button
onClick={operations.handleCancelDelete} onClick={operations.handleCancelDelete}
disabled={operations.operationLoading} disabled={operations.operationLoading}
className="px-3 py-1.5 text-sm rounded-md hover:bg-accent transition-colors" className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
> >
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button <button
onClick={operations.handleConfirmDelete} onClick={operations.handleConfirmDelete}
disabled={operations.operationLoading} disabled={operations.operationLoading}
className="px-3 py-1.5 text-sm rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2" className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
> >
{operations.operationLoading && <Loader2 className="w-4 h-4 animate-spin" />} {operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{t('fileTree.delete.confirm', 'Delete')} {t('fileTree.delete.confirm', 'Delete')}
</button> </button>
</div> </div>
@@ -278,9 +278,9 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
)} )}
> >
{toast.type === 'success' ? ( {toast.type === 'success' ? (
<Check className="w-4 h-4" /> <Check className="h-4 w-4" />
) : ( ) : (
<X className="w-4 h-4" /> <X className="h-4 w-4" />
)} )}
<span className="text-sm">{toast.message}</span> <span className="text-sm">{toast.message}</span>
</div> </div>

View File

@@ -4,7 +4,7 @@ export default function FileTreeDetailedColumns() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="px-3 pt-1.5 pb-1 border-b border-border"> <div className="border-b border-border px-3 pb-1 pt-1.5">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70"> <div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div> <div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div> <div className="col-span-2">{t('fileTree.size')}</div>

View File

@@ -8,11 +8,11 @@ type FileTreeEmptyStateProps = {
export default function FileTreeEmptyState({ icon: Icon, title, description }: FileTreeEmptyStateProps) { export default function FileTreeEmptyState({ icon: Icon, title, description }: FileTreeEmptyStateProps) {
return ( return (
<div className="text-center py-8"> <div className="py-8 text-center">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3"> <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-muted">
<Icon className="w-6 h-6 text-muted-foreground" /> <Icon className="h-6 w-6 text-muted-foreground" />
</div> </div>
<h4 className="font-medium text-foreground mb-1">{title}</h4> <h4 className="mb-1 font-medium text-foreground">{title}</h4>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
</div> </div>
); );

View File

@@ -1,7 +1,6 @@
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react'; import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/button'; import { Button, Input } from '../../../shared/view/ui';
import { Input } from '../../ui/input';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import type { FileTreeViewMode } from '../types/types'; import type { FileTreeViewMode } from '../types/types';
@@ -35,7 +34,7 @@ export default function FileTreeHeader({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2"> <div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
{/* Title and Toolbar */} {/* Title and Toolbar */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3> <h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
@@ -51,7 +50,7 @@ export default function FileTreeHeader({
aria-label={t('fileTree.newFile', 'New File (Cmd+N)')} aria-label={t('fileTree.newFile', 'New File (Cmd+N)')}
disabled={operationLoading} disabled={operationLoading}
> >
<FileText className="w-3.5 h-3.5" /> <FileText className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
{onNewFolder && ( {onNewFolder && (
@@ -64,7 +63,7 @@ export default function FileTreeHeader({
aria-label={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')} aria-label={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
disabled={operationLoading} disabled={operationLoading}
> >
<FolderPlus className="w-3.5 h-3.5" /> <FolderPlus className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
{onRefresh && ( {onRefresh && (
@@ -89,11 +88,11 @@ export default function FileTreeHeader({
title={t('fileTree.collapseAll', 'Collapse All')} title={t('fileTree.collapseAll', 'Collapse All')}
aria-label={t('fileTree.collapseAll', 'Collapse All')} aria-label={t('fileTree.collapseAll', 'Collapse All')}
> >
<ChevronDown className="w-3.5 h-3.5" /> <ChevronDown className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
{/* Divider */} {/* Divider */}
<div className="w-px h-4 bg-border mx-0.5" /> <div className="mx-0.5 h-4 w-px bg-border" />
{/* View mode buttons */} {/* View mode buttons */}
<Button <Button
variant={viewMode === 'simple' ? 'default' : 'ghost'} variant={viewMode === 'simple' ? 'default' : 'ghost'}
@@ -103,7 +102,7 @@ export default function FileTreeHeader({
title={t('fileTree.simpleView')} title={t('fileTree.simpleView')}
aria-label={t('fileTree.simpleView')} aria-label={t('fileTree.simpleView')}
> >
<List className="w-3.5 h-3.5" /> <List className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant={viewMode === 'compact' ? 'default' : 'ghost'} variant={viewMode === 'compact' ? 'default' : 'ghost'}
@@ -113,7 +112,7 @@ export default function FileTreeHeader({
title={t('fileTree.compactView')} title={t('fileTree.compactView')}
aria-label={t('fileTree.compactView')} aria-label={t('fileTree.compactView')}
> >
<Eye className="w-3.5 h-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'} variant={viewMode === 'detailed' ? 'default' : 'ghost'}
@@ -123,31 +122,31 @@ export default function FileTreeHeader({
title={t('fileTree.detailedView')} title={t('fileTree.detailedView')}
aria-label={t('fileTree.detailedView')} aria-label={t('fileTree.detailedView')}
> >
<TableProperties className="w-3.5 h-3.5" /> <TableProperties className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder={t('fileTree.searchPlaceholder')} placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)} onChange={(event) => onSearchQueryChange(event.target.value)}
className="pl-8 pr-8 h-8 text-sm" className="h-8 pl-8 pr-8 text-sm"
/> />
{searchQuery && ( {searchQuery && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent" className="absolute right-0.5 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-accent"
onClick={() => onSearchQueryChange('')} onClick={() => onSearchQueryChange('')}
title={t('fileTree.clearSearch')} title={t('fileTree.clearSearch')}
aria-label={t('fileTree.clearSearch')} aria-label={t('fileTree.clearSearch')}
> >
<X className="w-3 h-3" /> <X className="h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>

View File

@@ -4,8 +4,8 @@ export default function FileTreeLoadingState() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-sm">{t('fileTree.loading')}</div> <div className="text-sm text-muted-foreground">{t('fileTree.loading')}</div>
</div> </div>
); );
} }

View File

@@ -1,9 +1,9 @@
import type { ReactNode, RefObject } from 'react'; import type { ReactNode, RefObject } from 'react';
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'; import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import FileContextMenu from '../../FileContextMenu';
import { Input } from '../../ui/input';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types'; import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
import { Input } from '../../../shared/view/ui';
import FileContextMenu from './FileContextMenu';
type FileTreeNodeProps = { type FileTreeNodeProps = {
item: FileTreeNodeType; item: FileTreeNodeType;
@@ -40,7 +40,7 @@ type TreeItemIconProps = {
function TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) { function TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) {
if (item.type === 'directory') { if (item.type === 'directory') {
return ( return (
<span className="flex items-center gap-0.5 flex-shrink-0"> <span className="flex flex-shrink-0 items-center gap-0.5">
<ChevronRight <ChevronRight
className={cn( className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150', 'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
@@ -48,15 +48,15 @@ function TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) {
)} )}
/> />
{isOpen ? ( {isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" /> <FolderOpen className="h-4 w-4 flex-shrink-0 text-blue-500" />
) : ( ) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
)} )}
</span> </span>
); );
} }
return <span className="flex items-center flex-shrink-0 ml-[18px]">{renderFileIcon(item.name)}</span>; return <span className="ml-[18px] flex flex-shrink-0 items-center">{renderFileIcon(item.name)}</span>;
} }
export default function FileTreeNode({ export default function FileTreeNode({
@@ -128,7 +128,7 @@ export default function FileTreeNode({
handleConfirmRename(); handleConfirmRename();
}, 100); }, 100);
}} }}
className="h-6 text-sm flex-1" className="h-6 flex-1 text-sm"
disabled={operationLoading} disabled={operationLoading}
/> />
</div> </div>
@@ -143,23 +143,23 @@ export default function FileTreeNode({
> >
{viewMode === 'detailed' ? ( {viewMode === 'detailed' ? (
<> <>
<div className="col-span-5 flex items-center gap-1.5 min-w-0"> <div className="col-span-5 flex min-w-0 items-center gap-1.5">
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} /> <TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span> <span className={nameClassName}>{item.name}</span>
</div> </div>
<div className="col-span-2 text-sm text-muted-foreground tabular-nums"> <div className="col-span-2 text-sm tabular-nums text-muted-foreground">
{item.type === 'file' ? formatFileSize(item.size) : ''} {item.type === 'file' ? formatFileSize(item.size) : ''}
</div> </div>
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div> <div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div> <div className="col-span-2 font-mono text-sm text-muted-foreground">{item.permissionsRwx || ''}</div>
</> </>
) : viewMode === 'compact' ? ( ) : viewMode === 'compact' ? (
<> <>
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex min-w-0 items-center gap-1.5">
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} /> <TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span> <span className={nameClassName}>{item.name}</span>
</div> </div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2"> <div className="ml-2 flex flex-shrink-0 items-center gap-3 text-sm text-muted-foreground">
{item.type === 'file' && ( {item.type === 'file' && (
<> <>
<span className="tabular-nums">{formatFileSize(item.size)}</span> <span className="tabular-nums">{formatFileSize(item.size)}</span>
@@ -202,7 +202,7 @@ export default function FileTreeNode({
{isDirectory && isOpen && hasChildren && ( {isDirectory && isOpen && hasChildren && (
<div className="relative"> <div className="relative">
<span <span
className="absolute top-0 bottom-0 border-l border-border/40" className="absolute bottom-0 top-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }} style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true" aria-hidden="true"
/> />

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Button } from '../../ui/button'; import { Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { FileTreeImageSelection } from '../types/types'; import type { FileTreeImageSelection } from '../types/types';
@@ -58,16 +58,16 @@ export default function ImageViewer({ file, onClose }: ImageViewerProps) {
}, [imagePath]); }, [imagePath]);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden"> <div className="mx-4 max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between p-4 border-b"> <div className="flex items-center justify-between border-b p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{file.name}</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">{file.name}</h3>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]"> <div className="flex min-h-[400px] items-center justify-center bg-gray-50 p-4 dark:bg-gray-900">
{loading && ( {loading && (
<div className="text-center text-gray-500 dark:text-gray-400"> <div className="text-center text-gray-500 dark:text-gray-400">
<p>Loading image...</p> <p>Loading image...</p>
@@ -77,18 +77,18 @@ export default function ImageViewer({ file, onClose }: ImageViewerProps) {
<img <img
src={imageUrl} src={imageUrl}
alt={file.name} alt={file.name}
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md" className="max-h-[70vh] max-w-full rounded-lg object-contain shadow-md"
/> />
)} )}
{!loading && !imageUrl && ( {!loading && !imageUrl && (
<div className="text-center text-gray-500 dark:text-gray-400"> <div className="text-center text-gray-500 dark:text-gray-400">
<p>{error || 'Unable to load image'}</p> <p>{error || 'Unable to load image'}</p>
<p className="text-sm mt-2 break-all">{file.path}</p> <p className="mt-2 break-all text-sm">{file.path}</p>
</div> </div>
)} )}
</div> </div>
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800"> <div className="border-t bg-gray-50 p-4 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">{file.path}</p> <p className="text-sm text-gray-600 dark:text-gray-400">{file.path}</p>
</div> </div>
</div> </div>

View File

@@ -154,13 +154,6 @@ export function useGitPanelController({
setGitStatus({ error: 'Git operation failed', details: String(error) }); setGitStatus({ error: 'Git operation failed', details: String(error) });
setCurrentBranch(''); setCurrentBranch('');
} finally { } finally {
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName
) {
return;
}
setIsLoading(false); setIsLoading(false);
} }
}, [fetchFileDiff, selectedProject]); }, [fetchFileDiff, selectedProject]);

View File

@@ -66,14 +66,14 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
if (!selectedProject) { if (!selectedProject) {
return ( return (
<div className="h-full flex items-center justify-center text-muted-foreground"> <div className="flex h-full items-center justify-center text-muted-foreground">
<p>Select a project to view source control</p> <p>Select a project to view source control</p>
</div> </div>
); );
} }
return ( return (
<div className="h-full flex flex-col bg-background"> <div className="flex h-full flex-col bg-background">
<GitPanelHeader <GitPanelHeader
isMobile={isMobile} isMobile={isMobile}
currentBranch={currentBranch} currentBranch={currentBranch}

View File

@@ -113,9 +113,9 @@ export default function GitPanelHeader({
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setShowBranchDropdown((previous) => !previous)} onClick={() => setShowBranchDropdown((previous) => !previous)}
className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`} className={`flex items-center rounded-lg transition-colors hover:bg-accent ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
> >
<GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} /> <GitBranch className={`text-muted-foreground ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`} />
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span> <span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
{remoteStatus?.hasRemote && ( {remoteStatus?.hasRemote && (
@@ -146,22 +146,22 @@ export default function GitPanelHeader({
</span> </span>
)} )}
</span> </span>
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
</button> </button>
{showBranchDropdown && ( {showBranchDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden"> <div className="absolute left-0 top-full z-50 mt-1 w-64 overflow-hidden rounded-xl border border-border bg-card shadow-lg">
<div className="py-1 max-h-64 overflow-y-auto"> <div className="max-h-64 overflow-y-auto py-1">
{branches.map((branch) => ( {branches.map((branch) => (
<button <button
key={branch} key={branch}
onClick={() => void handleSwitchBranch(branch)} onClick={() => void handleSwitchBranch(branch)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${ className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-accent ${
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground' branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
}`} }`}
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">
{branch === currentBranch && <Check className="w-3 h-3 text-primary" />} {branch === currentBranch && <Check className="h-3 w-3 text-primary" />}
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span> <span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
</span> </span>
</button> </button>
@@ -173,9 +173,9 @@ export default function GitPanelHeader({
setShowNewBranchModal(true); setShowNewBranchModal(true);
setShowBranchDropdown(false); setShowBranchDropdown(false);
}} }}
className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2" className="flex w-full items-center space-x-2 px-4 py-2 text-left text-sm transition-colors hover:bg-accent"
> >
<Plus className="w-3 h-3" /> <Plus className="h-3 w-3" />
<span>Create new branch</span> <span>Create new branch</span>
</button> </button>
</div> </div>
@@ -190,10 +190,10 @@ export default function GitPanelHeader({
<button <button
onClick={requestPublishConfirmation} onClick={requestPublishConfirmation}
disabled={isPublishing} disabled={isPublishing}
className="px-2.5 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors" className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50"
title={`Publish branch "${currentBranch}" to ${remoteName}`} title={`Publish branch "${currentBranch}" to ${remoteName}`}
> >
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} /> <Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span> <span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
</button> </button>
)} )}
@@ -204,10 +204,10 @@ export default function GitPanelHeader({
<button <button
onClick={requestPullConfirmation} onClick={requestPullConfirmation}
disabled={isPulling} disabled={isPulling}
className="px-2.5 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors" className="flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-green-700 disabled:opacity-50"
title={`Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}`} title={`Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}`}
> >
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} /> <Download className={`h-3 w-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${behindCount}`}</span> <span>{isPulling ? 'Pulling...' : `Pull ${behindCount}`}</span>
</button> </button>
)} )}
@@ -216,10 +216,10 @@ export default function GitPanelHeader({
<button <button
onClick={requestPushConfirmation} onClick={requestPushConfirmation}
disabled={isPushing} disabled={isPushing}
className="px-2.5 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors" className="flex items-center gap-1 rounded-lg bg-orange-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-orange-700 disabled:opacity-50"
title={`Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}`} title={`Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}`}
> >
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} /> <Upload className={`h-3 w-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${aheadCount}`}</span> <span>{isPushing ? 'Pushing...' : `Push ${aheadCount}`}</span>
</button> </button>
)} )}
@@ -228,10 +228,10 @@ export default function GitPanelHeader({
<button <button
onClick={() => void handleFetch()} onClick={() => void handleFetch()}
disabled={isFetching} disabled={isFetching}
className="px-2.5 py-1 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors" className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
title={`Fetch from ${remoteName}`} title={`Fetch from ${remoteName}`}
> >
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span> <span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button> </button>
)} )}
@@ -243,10 +243,10 @@ export default function GitPanelHeader({
<button <button
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`} className={`rounded-lg transition-colors hover:bg-accent ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Refresh git status" title="Refresh git status"
> >
<RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} /> <RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`} />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -7,18 +7,18 @@ type GitRepositoryErrorStateProps = {
export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) { export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12"> <div className="flex flex-1 flex-col items-center justify-center px-6 py-12 text-muted-foreground">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6"> <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/50">
<GitBranch className="w-8 h-8 opacity-40" /> <GitBranch className="h-8 w-8 opacity-40" />
</div> </div>
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{error}</h3> <h3 className="mb-3 text-center text-lg font-medium text-foreground">{error}</h3>
{details && ( {details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{details}</p> <p className="mb-6 max-w-md text-center text-sm leading-relaxed">{details}</p>
)} )}
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md"> <div className="max-w-md rounded-xl border border-primary/10 bg-primary/5 p-4">
<p className="text-sm text-primary text-center"> <p className="text-center text-sm text-primary">
<strong>Tip:</strong> Run{' '} <strong>Tip:</strong> Run{' '}
<code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code>{' '} <code className="rounded-md bg-primary/10 px-2 py-1 font-mono text-xs">git init</code>{' '}
in your project directory to initialize git source control. in your project directory to initialize git source control.
</p> </p>
</div> </div>

View File

@@ -11,19 +11,19 @@ export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewT
return ( return (
<div <div
className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${ className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0' isHidden ? 'max-h-0 -translate-y-2 overflow-hidden opacity-0' : 'max-h-16 translate-y-0 opacity-100'
}`} }`}
> >
<button <button
onClick={() => onChange('changes')} onClick={() => onChange('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes' activeView === 'changes'
? 'text-primary border-b-2 border-primary' ? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" /> <FileText className="h-4 w-4" />
<span>Changes</span> <span>Changes</span>
</span> </span>
</button> </button>
@@ -31,12 +31,12 @@ export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewT
onClick={() => onChange('history')} onClick={() => onChange('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history' activeView === 'history'
? 'text-primary border-b-2 border-primary' ? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<History className="w-4 h-4" /> <History className="h-4 w-4" />
<span>History</span> <span>History</span>
</span> </span>
</button> </button>

View File

@@ -153,39 +153,39 @@ export default function ChangesView({
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex h-32 items-center justify-center">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> <RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div> </div>
) : gitStatus?.hasCommits === false ? ( ) : gitStatus?.hasCommits === false ? (
<div className="flex flex-col items-center justify-center p-8 text-center"> <div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4"> <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
<GitBranch className="w-7 h-7 text-muted-foreground/50" /> <GitBranch className="h-7 w-7 text-muted-foreground/50" />
</div> </div>
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3> <h3 className="mb-2 text-lg font-medium text-foreground">No commits yet</h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md"> <p className="mb-6 max-w-md text-sm text-muted-foreground">
This repository doesn&apos;t have any commits yet. Create your first commit to start tracking changes. This repository doesn&apos;t have any commits yet. Create your first commit to start tracking changes.
</p> </p>
<button <button
onClick={() => void onCreateInitialCommit()} onClick={() => void onCreateInitialCommit()}
disabled={isCreatingInitialCommit} disabled={isCreatingInitialCommit}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors" className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{isCreatingInitialCommit ? ( {isCreatingInitialCommit ? (
<> <>
<RefreshCw className="w-4 h-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
<span>Creating Initial Commit...</span> <span>Creating Initial Commit...</span>
</> </>
) : ( ) : (
<> <>
<GitCommit className="w-4 h-4" /> <GitCommit className="h-4 w-4" />
<span>Create Initial Commit</span> <span>Create Initial Commit</span>
</> </>
)} )}
</button> </button>
</div> </div>
) : !gitStatus || !hasChangedFiles(gitStatus) ? ( ) : !gitStatus || !hasChangedFiles(gitStatus) ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> <div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
<GitCommit className="w-10 h-10 mb-2 opacity-40" /> <GitCommit className="mb-2 h-10 w-10 opacity-40" />
<p className="text-sm">No changes detected</p> <p className="text-sm">No changes detected</p>
</div> </div>
) : ( ) : (

Some files were not shown because too many files have changed in this diff Show More