├── .github
└── workflows
│ ├── ci.yml
│ └── deploy.yml
├── .gitignore
├── .pre-commit-config.yaml
├── README.md
├── bun.lockb
├── components.json
├── e2e
└── conversation.spec.ts
├── eslint.config.js
├── gptme.toml
├── index.html
├── jest.config.ts
├── jest.setup.ts
├── package-lock.json
├── package.json
├── playwright.config.ts
├── postcss.config.js
├── prettier.config.cjs
├── src
├── App.css
├── App.tsx
├── components
│ ├── BrowserPreview.tsx
│ ├── ChatInput.tsx
│ ├── ChatMessage.tsx
│ ├── CodeDisplay.tsx
│ ├── ConnectionButton.tsx
│ ├── ConversationContent.tsx
│ ├── ConversationList.tsx
│ ├── ConversationSettings.tsx
│ ├── Conversations.tsx
│ ├── DeleteConversationConfirmationDialog.tsx
│ ├── LeftSidebar.tsx
│ ├── MenuBar.tsx
│ ├── MessageAvatar.tsx
│ ├── RightSidebar.tsx
│ ├── TabbedCodeBlock.tsx
│ ├── ThemeToggle.tsx
│ ├── ToolConfirmationDialog.tsx
│ ├── __tests__
│ │ └── ChatMessage.test.tsx
│ ├── settings
│ │ ├── EnvironmentVariables.tsx
│ │ ├── McpConfiguration.tsx
│ │ └── ToolsConfiguration.tsx
│ ├── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── aspect-ratio.tsx
│ │ ├── avatar.tsx
│ │ ├── badge.tsx
│ │ ├── breadcrumb.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── chart.tsx
│ │ ├── checkbox.tsx
│ │ ├── collapsible.tsx
│ │ ├── command.tsx
│ │ ├── context-menu.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── hover-card.tsx
│ │ ├── input-otp.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── menubar.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── pagination.tsx
│ │ ├── popover.tsx
│ │ ├── progress.tsx
│ │ ├── radio-group.tsx
│ │ ├── resizable.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── skeleton.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── switch.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ ├── toggle-group.tsx
│ │ ├── toggle.tsx
│ │ ├── tooltip.tsx
│ │ └── use-toast.ts
│ └── workspace
│ │ ├── FileList.tsx
│ │ ├── FilePreview.tsx
│ │ ├── PathSegments.tsx
│ │ └── WorkspaceExplorer.tsx
├── contexts
│ └── ApiContext.tsx
├── democonversations.ts
├── hooks
│ ├── use-toast.ts
│ ├── useConversation.ts
│ └── useConversationSettings.ts
├── index.css
├── lib
│ └── utils.ts
├── main.tsx
├── pages
│ └── Index.tsx
├── schemas
│ └── conversationSettings.ts
├── stores
│ ├── conversations.ts
│ └── sidebar.ts
├── types
│ ├── api.ts
│ ├── conversation.ts
│ └── workspace.ts
├── utils
│ ├── __tests__
│ │ ├── markdownRenderer.test.ts
│ │ └── markdownUtils.test.ts
│ ├── api.ts
│ ├── consoleProxy.ts
│ ├── conversation.ts
│ ├── highlightUtils.ts
│ ├── markdownRenderer.ts
│ ├── markdownUtils.ts
│ ├── messageUtils.ts
│ ├── smd.d.ts
│ ├── smd.js
│ ├── time.ts
│ ├── title.ts
│ └── workspaceApi.ts
└── vite-env.d.ts
├── tailwind.config.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.test.json
└── vite.config.ts
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master, 'dev/*' ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [20.x]
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 | cache: 'npm'
24 |
25 | - name: Install dependencies
26 | run: npm ci
27 |
28 | - name: Type check
29 | run: npm run typecheck
30 |
31 | - name: Lint
32 | run: npm run lint
33 |
34 | - name: Test
35 | run: npm test
36 |
37 | - name: Build
38 | run: npm run build
39 |
40 | - name: Start gptme-server in background
41 | env:
42 | ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
43 | run: |
44 | # pipx install gptme[server]
45 | pipx install "gptme[server] @ git+https://github.com/gptme/gptme.git"
46 | gptme-server --cors-origin="http://localhost:5701" & # run in background
47 | sleep 3 # sleep so we get initial logs
48 |
49 | - name: Install Playwright browsers
50 | run: npx playwright install --with-deps chromium
51 |
52 | - name: Check that server is up
53 | run: curl --retry 2 --retry-delay 5 --retry-connrefused -sSfL http://localhost:5700/api
54 |
55 | - name: Run e2e tests
56 | run: npm run test:e2e
57 |
58 | - name: Upload test results
59 | if: always()
60 | uses: actions/upload-artifact@v4
61 | with:
62 | name: playwright-report
63 | path: playwright-report/
64 | retention-days: 30
65 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | # Allow manual deployment
7 | workflow_dispatch:
8 |
9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
10 | permissions:
11 | contents: read
12 | pages: write
13 | id-token: write
14 |
15 | # Allow only one concurrent deployment
16 | concurrency:
17 | group: "pages"
18 | cancel-in-progress: true
19 |
20 | jobs:
21 | deploy:
22 | environment:
23 | name: github-pages
24 | url: ${{ steps.deployment.outputs.page_url }}
25 | runs-on: ubuntu-latest
26 | steps:
27 | - name: Checkout
28 | uses: actions/checkout@v4
29 |
30 | - name: Setup Node.js
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: 20
34 | cache: 'npm'
35 |
36 | - name: Install dependencies
37 | run: npm ci
38 |
39 | - name: Build
40 | run: npm run build
41 |
42 | - name: Setup Pages
43 | uses: actions/configure-pages@v4
44 |
45 | - name: Upload artifact
46 | uses: actions/upload-pages-artifact@v3
47 | with:
48 | path: dist
49 |
50 | - name: Deploy to GitHub Pages
51 | id: deployment
52 | uses: actions/deploy-pages@v4
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v5.0.0
4 | hooks:
5 | - id: check-yaml
6 | - id: end-of-file-fixer
7 | - id: trailing-whitespace
8 |
9 | - repo: local
10 | hooks:
11 | - id: lint
12 | name: lint
13 | stages: [pre-commit]
14 | types: [javascript, jsx, ts, tsx]
15 | entry: npm run lint:fix
16 | language: system
17 | pass_filenames: false
18 | always_run: true
19 |
20 | # handled by lint:fix
21 | #- id: format
22 | # name: format
23 | # stages: [commit]
24 | # types: [javascript, jsx, ts, tsx]
25 | # entry: npm run format
26 | # language: system
27 | # pass_filenames: false
28 | # always_run: true
29 |
30 | - id: typecheck
31 | name: typecheck
32 | stages: [pre-commit]
33 | types: [javascript, jsx, ts, tsx]
34 | entry: npm run typecheck
35 | language: system
36 | pass_filenames: false
37 | always_run: true
38 |
39 | # Uncomment the following lines to enable testing hooks
40 | # Don't commit them uncommented since they take too long to run every time
41 |
42 | #- id: test
43 | # name: test
44 | # types: [javascript, jsx, ts, tsx]
45 | # entry: npm test
46 | # language: system
47 | # pass_filenames: false
48 | # always_run: true
49 |
50 | #- id: test-e2e
51 | # name: test-e2e
52 | # types: [javascript, jsx, ts, tsx]
53 | # entry: npm run test:e2e
54 | # language: system
55 | # pass_filenames: false
56 | # always_run: true
57 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gptme-webui
2 |
3 | [](https://github.com/gptme/gptme-webui/actions/workflows/ci.yml)
4 |
5 | A fancy web UI for [gptme][gptme], built with [lovable.dev](https://lovable.dev).
6 |
7 | An alternative to the minimal UI currently provided by `gptme`.
8 |
9 |
10 | ## Features
11 |
12 | - Chat with LLMs using gptme, just like in the CLI, but with a fancy UI
13 | - Generate responses and run tools by connecting to your local gptme-server instance
14 | - Read bundled conversations without running gptme locally (useful for sharing)
15 |
16 | ## Usage
17 |
18 | You can use the web UI hosted at [chat.gptme.org](https://chat.gptme.org/), or run it locally:
19 |
20 | ```sh
21 | git clone https://github.com/gptme/gptme-webui
22 | cd gptme-webui
23 | npm i
24 | npm run dev
25 | ```
26 |
27 | To connect to a local `gptme-server` instance, you need to start one with `gptme-server --cors-origin='https://chat.gptme.org'` (or whatever the origin of your web UI is).
28 |
29 | ## Tech stack
30 |
31 | This project is built with:
32 |
33 | - Vite
34 | - TypeScript
35 | - React
36 | - shadcn-ui
37 | - Tailwind CSS
38 |
39 | ## Development
40 |
41 | Available commands:
42 |
43 | - `npm run dev` - Start development server
44 | - `npm run typecheck` - Run type checking
45 | - `npm run typecheck:watch` - Run type checking in watch mode
46 | - `npm run build` - Build for production (includes type checking)
47 | - `npm run lint` - Run linting and type checking
48 |
49 | ## Project info
50 |
51 | **URL**: https://run.gptengineer.app/projects/b6f40770-f632-4741-8247-3d47b9beac4e/improve
52 |
53 | [gptme]: https://github.com/gptme/gptme
54 | [gptengineer.app]: https://gptengineer.app
55 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gptme/gptme-webui/6cd2dff1a25cd9bcd702a03e195fcb274a73c24f/bun.lockb
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/index.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/e2e/conversation.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | test.describe('Connecting', () => {
4 | test('should connect and list conversations', async ({ page }) => {
5 | // Go to the app
6 | await page.goto('/');
7 |
8 | // Should show demo conversations initially
9 | await expect(page.getByText('Introduction to gptme')).toBeVisible();
10 |
11 | // Should show connection status
12 | const connectionButton = page.getByRole('button', { name: /Connect/i });
13 | await expect(connectionButton).toBeVisible();
14 |
15 | // Click the demo conversation
16 | await page.getByText('Introduction to gptme').click();
17 |
18 | // Should show the conversation content
19 | await expect(page.getByText(/Hello! I'm gptme, your AI programming assistant/)).toBeVisible();
20 | await page.goto('/');
21 |
22 | // Should show demo conversations immediately
23 | await expect(page.getByText('Introduction to gptme')).toBeVisible();
24 |
25 | // Wait for successful connection
26 | await expect(page.getByRole('button', { name: /Connect/i })).toHaveClass(/text-green-600/, {
27 | timeout: 10000,
28 | });
29 |
30 | // Wait for success toast to confirm API connection
31 | await expect(page.getByText('Connected to gptme server')).toBeVisible();
32 |
33 | // Wait for conversations to load
34 | // Should show both demo conversations and connected conversations
35 | await expect(page.getByText('Introduction to gptme')).toBeVisible();
36 |
37 | // Wait for loading state to finish
38 | await expect(page.getByText('Loading conversations...')).toBeHidden();
39 |
40 | // Get the conversation list
41 | const conversationList = page.getByTestId('conversation-list');
42 |
43 | // Get all conversation titles
44 | const conversationTitles = await conversationList
45 | .locator('[data-testid="conversation-title"]')
46 | .allTextContents();
47 |
48 | // Should have both demo and API conversations
49 | const demoConversations = conversationTitles.filter((title) => title.includes('Introduction'));
50 | const apiConversations = conversationTitles.filter((title) => /^\d+$/.test(title));
51 |
52 | expect(demoConversations.length).toBeGreaterThan(0);
53 |
54 | if (apiConversations.length > 0) {
55 | // Check for historical timestamps if we have API conversations
56 | const timestamps = await conversationList
57 | .getByRole('button')
58 | .locator('time')
59 | .allTextContents();
60 | expect(timestamps.length).toBeGreaterThan(1);
61 |
62 | // There should be some timestamps that aren't "just now"
63 | const nonJustNowTimestamps = timestamps.filter((t) => t !== 'just now');
64 | expect(nonJustNowTimestamps.length).toBeGreaterThan(0);
65 | } else {
66 | // This happens when e2e tests are run in CI with a fresh gptme-server
67 | console.log('No API conversations found, skipping timestamp check');
68 | }
69 | });
70 |
71 | test('should handle connection errors gracefully', async ({ page }) => {
72 | // Start with server unavailable
73 | await page.goto('/');
74 |
75 | // Should still show demo conversations
76 | await expect(page.getByText('Introduction to gptme')).toBeVisible();
77 |
78 | // Click connect button and try to connect to non-existent server
79 | const connectionButton = page.getByRole('button', { name: /Connect/i });
80 | await connectionButton.click();
81 |
82 | // Fill in invalid server URL and try to connect
83 | await page.getByLabel('Server URL').fill('http://localhost:1');
84 | await page.getByRole('button', { name: /^(Connect|Reconnect)$/ }).click();
85 |
86 | // Wait for error toast to appear
87 | await expect(page.getByText('Could not connect to gptme instance')).toBeVisible({
88 | timeout: 10000,
89 | });
90 |
91 | // Close the connection dialog by clicking outside
92 | await page.keyboard.press('Escape');
93 |
94 | // Verify connection button is in disconnected state
95 | await expect(connectionButton).toBeVisible();
96 | await expect(connectionButton).not.toHaveClass(/text-green-600/);
97 |
98 | // Should show demo conversations
99 | await expect(page.getByText('Introduction to gptme')).toBeVisible();
100 |
101 | // Should not show any API conversations
102 | const conversationList = page.getByTestId('conversation-list');
103 | const conversationTitles = await conversationList
104 | .locator('[data-testid="conversation-title"]')
105 | .allTextContents();
106 |
107 | const apiConversations = conversationTitles.filter((title) => /^\d+$/.test(title));
108 | expect(apiConversations.length).toBe(0);
109 | });
110 | });
111 |
112 | test.describe('Conversation Flow', () => {
113 | test('should be able to create a new conversation and send a message', async ({ page }) => {
114 | await page.goto('/');
115 |
116 | // Click the "New Conversation" button to start a new conversation
117 | await page.locator('[data-testid="new-conversation-button"]').click();
118 |
119 | // Wait for the new conversation page to load
120 | await expect(page).toHaveURL(/\?conversation=\d+$/);
121 |
122 | const message = 'Hello. We are testing, just say exactly "Hello world" without anything else.';
123 |
124 | // Type a message
125 | await page.getByTestId('chat-input').fill(message);
126 | await page.keyboard.press('Enter');
127 |
128 | // Should show the message in the conversation
129 | // Look specifically for the user's message in a user message container
130 | await expect(
131 | page.locator('.role-user', {
132 | hasText: message,
133 | })
134 | ).toBeVisible();
135 |
136 | // Should show the AI's response
137 | await expect(
138 | page.locator('.role-assistant', {
139 | hasText: 'Hello world',
140 | })
141 | ).toBeVisible();
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import reactHooks from 'eslint-plugin-react-hooks';
4 | import reactRefresh from 'eslint-plugin-react-refresh';
5 | import tseslint from 'typescript-eslint';
6 | import prettier from 'eslint-plugin-prettier';
7 |
8 | export default tseslint.config(
9 | { ignores: ['dist', 'playwright.config.ts'] },
10 | {
11 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
12 | files: ['**/*.{ts,tsx}'],
13 | languageOptions: {
14 | ecmaVersion: 2020,
15 | globals: {
16 | ...globals.browser,
17 | ...globals.jest,
18 | React: 'readonly',
19 | },
20 | parserOptions: {
21 | ecmaFeatures: {
22 | jsx: true,
23 | },
24 | sourceType: 'module',
25 | },
26 | },
27 | plugins: {
28 | 'react-hooks': reactHooks,
29 | 'react-refresh': reactRefresh,
30 | prettier: prettier,
31 | },
32 | rules: {
33 | ...reactHooks.configs.recommended.rules,
34 | 'prettier/prettier': 'warn',
35 | '@typescript-eslint/no-unused-vars': [
36 | 'warn',
37 | {
38 | argsIgnorePattern: '^_',
39 | varsIgnorePattern: '^_',
40 | ignoreRestSiblings: true,
41 | },
42 | ],
43 | 'react-hooks/rules-of-hooks': 'error',
44 | 'react-hooks/exhaustive-deps': 'warn',
45 | 'no-undef': 'error',
46 | 'no-unused-vars': 'off',
47 | '@typescript-eslint/no-explicit-any': 'warn',
48 | '@typescript-eslint/consistent-type-imports': [
49 | 'warn',
50 | {
51 | prefer: 'type-imports',
52 | fixStyle: 'inline-type-imports',
53 | },
54 | ],
55 | //"@typescript-eslint/no-restricted-imports": [
56 | // "error",
57 | // {
58 | // patterns: [
59 | // {
60 | // group: ["react"],
61 | // message: "Import specific entities from react instead of the entire module",
62 | // allowTypeImports: true
63 | // }
64 | // ]
65 | // }
66 | //]
67 | },
68 | }
69 | );
70 |
--------------------------------------------------------------------------------
/gptme.toml:
--------------------------------------------------------------------------------
1 | files = ["README.md", "package.json", "src/main.tsx", "src/App.tsx", "src/types/*.ts", "../gptme/gptme/server/api.py"]
2 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | gptme
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | testEnvironment: 'jsdom',
3 | setupFilesAfterEnv: ['/jest.setup.ts'],
4 | moduleNameMapper: {
5 | '^@/(.*)$': '/src/$1',
6 | '\\.(css|less|sass|scss)$': 'identity-obj-proxy',
7 | },
8 | transform: {
9 | '^.+\\.(ts|tsx|js|jsx)$': [
10 | 'ts-jest',
11 | {
12 | useESM: true,
13 | tsconfig: 'tsconfig.test.json',
14 | },
15 | ],
16 | },
17 | testPathIgnorePatterns: ['/node_modules/', '/e2e/'],
18 | extensionsToTreatAsEsm: ['.ts', '.tsx'],
19 | };
20 |
--------------------------------------------------------------------------------
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gptme-webui",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "typecheck": "tsc -p tsconfig.app.json --noEmit",
9 | "typecheck:watch": "tsc -p tsconfig.app.json --noEmit --watch",
10 | "build": "tsc -p tsconfig.app.json --noEmit && vite build",
11 | "build:dev": "tsc -p tsconfig.app.json --noEmit && vite build --mode development",
12 | "lint": "eslint . && tsc -p tsconfig.app.json --noEmit",
13 | "lint:fix": "eslint . --fix && tsc -p tsconfig.app.json --noEmit",
14 | "format": "prettier --list-different --write \"**/*.{ts,tsx,js,jsx,json,css,html}\"",
15 | "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,html}\"",
16 | "preview": "vite preview",
17 | "test": "NODE_OPTIONS=--no-warnings jest",
18 | "test:watch": "jest --watch",
19 | "test:coverage": "jest --coverage",
20 | "test:e2e": "playwright test --reporter=list",
21 | "test:e2e:ui": "playwright test --ui"
22 | },
23 | "dependencies": {
24 | "@hookform/resolvers": "^3.9.0",
25 | "@legendapp/state": "^3.0.0-beta.30",
26 | "@radix-ui/react-accordion": "^1.2.0",
27 | "@radix-ui/react-alert-dialog": "^1.1.1",
28 | "@radix-ui/react-aspect-ratio": "^1.1.0",
29 | "@radix-ui/react-avatar": "^1.1.0",
30 | "@radix-ui/react-checkbox": "^1.1.1",
31 | "@radix-ui/react-collapsible": "^1.1.0",
32 | "@radix-ui/react-context-menu": "^2.2.1",
33 | "@radix-ui/react-dialog": "^1.1.1",
34 | "@radix-ui/react-dropdown-menu": "^2.1.1",
35 | "@radix-ui/react-hover-card": "^1.1.1",
36 | "@radix-ui/react-label": "^2.1.0",
37 | "@radix-ui/react-menubar": "^1.1.1",
38 | "@radix-ui/react-navigation-menu": "^1.2.0",
39 | "@radix-ui/react-popover": "^1.1.1",
40 | "@radix-ui/react-progress": "^1.1.0",
41 | "@radix-ui/react-radio-group": "^1.2.0",
42 | "@radix-ui/react-scroll-area": "^1.1.0",
43 | "@radix-ui/react-select": "^2.1.1",
44 | "@radix-ui/react-separator": "^1.1.0",
45 | "@radix-ui/react-slider": "^1.2.0",
46 | "@radix-ui/react-slot": "^1.1.0",
47 | "@radix-ui/react-switch": "^1.1.0",
48 | "@radix-ui/react-tabs": "^1.1.0",
49 | "@radix-ui/react-toast": "^1.2.1",
50 | "@radix-ui/react-toggle": "^1.1.0",
51 | "@radix-ui/react-toggle-group": "^1.1.0",
52 | "@radix-ui/react-tooltip": "^1.1.2",
53 | "@tanstack/react-query": "^5.56.2",
54 | "class-variance-authority": "^0.7.0",
55 | "clsx": "^2.1.1",
56 | "cmdk": "^1.0.0",
57 | "date-fns": "^3.6.0",
58 | "embla-carousel-react": "^8.3.0",
59 | "highlight.js": "^11.10.0",
60 | "input-otp": "^1.2.4",
61 | "lovable-tagger": "^1.0.19",
62 | "lucide-react": "^0.451.0",
63 | "marked": "^14.1.3",
64 | "marked-highlight": "^2.1.1",
65 | "next-themes": "^0.3.0",
66 | "react": "^18.3.1",
67 | "react-day-picker": "^8.10.1",
68 | "react-dom": "^18.3.1",
69 | "react-hook-form": "^7.53.0",
70 | "react-resizable-panels": "^2.1.3",
71 | "react-router-dom": "^6.26.2",
72 | "recharts": "^2.12.7",
73 | "sonner": "^1.5.0",
74 | "tailwind-merge": "^2.5.2",
75 | "tailwindcss-animate": "^1.0.7",
76 | "vaul": "^0.9.3",
77 | "zod": "^3.23.8"
78 | },
79 | "devDependencies": {
80 | "@eslint/js": "^9.9.0",
81 | "@playwright/test": "^1.51.1",
82 | "@tailwindcss/typography": "^0.5.15",
83 | "@testing-library/jest-dom": "^6.6.3",
84 | "@testing-library/react": "^16.3.0",
85 | "@types/jest": "^29.5.14",
86 | "@types/node": "^22.5.5",
87 | "@types/react": "^18.3.3",
88 | "@types/react-dom": "^18.3.0",
89 | "@vitejs/plugin-react-swc": "^3.5.0",
90 | "@vitest/coverage-v8": "^3.1.1",
91 | "autoprefixer": "^10.4.20",
92 | "eslint": "^9.9.0",
93 | "eslint-plugin-prettier": "^5.2.3",
94 | "eslint-plugin-react-hooks": "^5.1.0-rc.0",
95 | "eslint-plugin-react-refresh": "^0.4.9",
96 | "globals": "^15.9.0",
97 | "identity-obj-proxy": "^3.0.0",
98 | "jest": "^29.7.0",
99 | "jest-environment-jsdom": "^29.7.0",
100 | "jsdom": "^26.0.0",
101 | "postcss": "^8.4.47",
102 | "prettier": "^3.2.5",
103 | "prettier-plugin-tailwindcss": "^0.5.12",
104 | "tailwindcss": "^3.4.11",
105 | "ts-jest": "^29.2.5",
106 | "ts-node": "^10.9.2",
107 | "typescript": "^5.5.3",
108 | "typescript-eslint": "^8.0.1",
109 | "vite": "^5.4.1",
110 | "vitest": "^3.1.1"
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | import { defineConfig, devices } from '@playwright/test';
4 |
5 | const port = process.env.PORT || 5701;
6 |
7 | export default defineConfig({
8 | testDir: './e2e',
9 | fullyParallel: true,
10 | forbidOnly: !!process.env.CI,
11 | retries: process.env.CI ? 2 : 0,
12 | workers: process.env.CI ? 1 : undefined,
13 | reporter: 'html',
14 | use: {
15 | baseURL: 'http://localhost:' + port,
16 | trace: 'on-first-retry',
17 | },
18 | projects: [
19 | {
20 | name: 'chromium',
21 | use: { ...devices['Desktop Chrome'] },
22 | },
23 | ],
24 | webServer: {
25 | command: 'npm run dev',
26 | url: 'http://localhost:' + port,
27 | reuseExistingServer: !process.env.CI,
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | singleQuote: true,
4 | tabWidth: 2,
5 | trailingComma: "es5",
6 | printWidth: 100,
7 | bracketSameLine: false,
8 | useTabs: false,
9 | arrowParens: "always",
10 | endOfLine: "lf",
11 | plugins: ["prettier-plugin-tailwindcss"],
12 | };
13 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from '@/components/ui/toaster';
2 | import { Toaster as Sonner } from '@/components/ui/sonner';
3 | import { TooltipProvider } from '@/components/ui/tooltip';
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { BrowserRouter } from 'react-router-dom';
6 | import { ApiProvider } from './contexts/ApiContext';
7 | import Index from './pages/Index';
8 | import type { FC } from 'react';
9 |
10 | const queryClient = new QueryClient({
11 | defaultOptions: {
12 | queries: {
13 | // Disable automatic background refetching
14 | refetchOnWindowFocus: false,
15 | refetchOnMount: false,
16 | refetchOnReconnect: false,
17 | // Reduce stale time to ensure updates are visible immediately
18 | staleTime: 0,
19 | // Keep cached data longer
20 | gcTime: 1000 * 60 * 5,
21 | // Ensure we get updates
22 | notifyOnChangeProps: 'all',
23 | },
24 | mutations: {
25 | // Ensure mutations trigger immediate updates
26 | onSuccess: () => {
27 | queryClient.invalidateQueries();
28 | },
29 | },
30 | },
31 | });
32 |
33 | const App: FC = () => {
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | export default App;
50 |
--------------------------------------------------------------------------------
/src/components/BrowserPreview.tsx:
--------------------------------------------------------------------------------
1 | import { RefreshCw, Smartphone, Monitor, Terminal, Globe } from 'lucide-react';
2 | import { Button } from '@/components/ui/button';
3 | import { Input } from '@/components/ui/input';
4 | import { useState, useEffect, useRef } from 'react';
5 | import { consoleProxyScript } from '@/utils/consoleProxy';
6 | import { ScrollArea } from '@/components/ui/scroll-area';
7 | import type { FC } from 'react';
8 |
9 | interface ConsoleMessage {
10 | level: 'log' | 'info' | 'warn' | 'error' | 'debug';
11 | args: unknown[];
12 | timestamp: number;
13 | }
14 |
15 | interface Props {
16 | defaultUrl?: string;
17 | }
18 |
19 | export const BrowserPreview: FC = ({ defaultUrl = 'http://localhost:8080' }) => {
20 | const [inputValue, setInputValue] = useState(defaultUrl);
21 | const [currentUrl, setCurrentUrl] = useState(defaultUrl);
22 | const [isMobile, setIsMobile] = useState(false);
23 | const [showConsole, setShowConsole] = useState(true);
24 | const [logs, setLogs] = useState([]);
25 | const iframeRef = useRef(null);
26 |
27 | // Clear logs when URL changes
28 | useEffect(() => {
29 | setLogs([]);
30 | }, [currentUrl]);
31 |
32 | const handleRefresh = () => {
33 | // Force refresh by appending a dummy parameter if URL is unchanged
34 | setCurrentUrl(
35 | inputValue === currentUrl
36 | ? `${inputValue}${inputValue.includes('?') ? '&' : '?'}_refresh=${Date.now()}`
37 | : inputValue
38 | );
39 | setLogs([]); // Clear logs on refresh
40 | };
41 |
42 | const toggleMode = () => {
43 | setIsMobile((prev) => !prev);
44 | };
45 |
46 | const toggleConsole = () => {
47 | setShowConsole((prev) => !prev);
48 | };
49 |
50 | useEffect(() => {
51 | const handleMessage = (event: MessageEvent) => {
52 | if (event.data?.type === 'console') {
53 | setLogs((prev) => [
54 | ...prev,
55 | {
56 | level: event.data.level,
57 | args: event.data.args,
58 | timestamp: Date.now(),
59 | },
60 | ]);
61 | }
62 | };
63 |
64 | window.addEventListener('message', handleMessage);
65 | return () => window.removeEventListener('message', handleMessage);
66 | }, []);
67 |
68 | // Inject console proxy script when iframe loads
69 | // NOTE: only works with same-origin URLs (we need a workaround to capture logs from cross-origin iframes)
70 | const handleIframeLoad = () => {
71 | const iframe = iframeRef.current;
72 | if (iframe?.contentWindow) {
73 | // Use Function constructor instead of eval for better type safety
74 | const script = new Function(consoleProxyScript);
75 | iframe.contentWindow.document.head.appendChild(
76 | Object.assign(iframe.contentWindow.document.createElement('script'), {
77 | textContent: `(${script.toString()})();`,
78 | })
79 | );
80 | }
81 | };
82 |
83 | const clearLogs = () => {
84 | setLogs([]);
85 | };
86 |
87 | return (
88 |
89 |
90 |
91 |
92 | setInputValue(e.target.value)}
96 | onKeyDown={(e) => {
97 | if (e.key === 'Enter') {
98 | handleRefresh();
99 | }
100 | }}
101 | className="flex-1 pl-8"
102 | />
103 |
104 |
107 |
115 |
123 |
124 |
138 | {showConsole && (
139 |
140 |
141 |
Console
142 |
143 |
146 |
147 |
148 |
149 |
150 | {logs.map((log, i) => (
151 |
161 | {log.args.map((arg, j) => (
162 |
163 | {typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}{' '}
164 |
165 | ))}
166 |
167 | ))}
168 |
169 |
170 |
171 | )}
172 |
173 | );
174 | };
175 |
--------------------------------------------------------------------------------
/src/components/CodeDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Button } from '@/components/ui/button';
3 | import { Clipboard, Check } from 'lucide-react';
4 | import { highlightCode } from '@/utils/highlightUtils';
5 | // We still need the CSS though
6 | import 'highlight.js/styles/github-dark.css';
7 |
8 | interface CodeDisplayProps {
9 | code: string;
10 | maxHeight?: string;
11 | showLineNumbers?: boolean;
12 | language?: string;
13 | }
14 |
15 | export function CodeDisplay({
16 | code,
17 | maxHeight = '300px',
18 | showLineNumbers = true,
19 | language,
20 | }: CodeDisplayProps) {
21 | const [copied, setCopied] = useState(false);
22 | const [highlightedCode, setHighlightedCode] = useState('');
23 |
24 | useEffect(() => {
25 | if (!code) return;
26 |
27 | // Use our shared utility
28 | setHighlightedCode(highlightCode(code, language, true, 1000).code);
29 | }, [code, language]);
30 |
31 | if (!code) return null;
32 |
33 | const handleCopy = () => {
34 | navigator.clipboard.writeText(code);
35 | setCopied(true);
36 | setTimeout(() => setCopied(false), 2000);
37 | };
38 |
39 | const lines = code.split('\n');
40 |
41 | return (
42 |
43 |
44 |
52 |
53 |
54 | {/* Single scrollable container for both line numbers and code */}
55 |
56 |
57 | {showLineNumbers && lines.length > 1 && (
58 |
59 | {lines.map((_, i) => (
60 |
61 | {i + 1}
62 |
63 | ))}
64 |
65 | )}
66 |
67 |
68 | {highlightedCode ? (
69 |
70 |
71 |
72 | ) : (
73 |
{code}
74 | )}
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/DeleteConversationConfirmationDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | Dialog,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | } from '@/components/ui/dialog';
10 | import { Button } from '@/components/ui/button';
11 | import { Loader2 } from 'lucide-react';
12 | import { useApi } from '@/contexts/ApiContext';
13 | import { conversations$, selectedConversation$ } from '@/stores/conversations';
14 | import { useQueryClient } from '@tanstack/react-query';
15 | import { use$ } from '@legendapp/state/react';
16 | import { demoConversations } from '@/democonversations';
17 |
18 | interface Props {
19 | conversationName: string;
20 | open: boolean;
21 | onOpenChange: (open: boolean) => void;
22 | onDelete: () => void;
23 | }
24 |
25 | export function DeleteConversationConfirmationDialog({
26 | conversationName,
27 | open,
28 | onOpenChange,
29 | onDelete,
30 | }: Props) {
31 | const { deleteConversation, connectionConfig, isConnected$ } = useApi();
32 | const queryClient = useQueryClient();
33 | const isConnected = use$(isConnected$);
34 | const [isDeleting, setIsDeleting] = useState(false);
35 | const [isError, setIsError] = useState(false);
36 | const [errorMessage, setErrorMessage] = useState(null);
37 |
38 | const handleDelete = async () => {
39 | // Show loading indicator
40 | setIsDeleting(true);
41 |
42 | // Delete conversation
43 | try {
44 | await deleteConversation(conversationName);
45 | } catch (error) {
46 | setIsError(true);
47 | if (error instanceof Error) {
48 | setErrorMessage(error.message);
49 | } else {
50 | setErrorMessage('An unknown error occurred');
51 | }
52 | setIsDeleting(false);
53 | return;
54 | }
55 | conversations$.delete(conversationName);
56 | queryClient.invalidateQueries({
57 | queryKey: ['conversations', connectionConfig.baseUrl, isConnected],
58 | });
59 | selectedConversation$.set(demoConversations[0].name);
60 |
61 | // Reset state
62 | await onDelete();
63 | setIsDeleting(false);
64 | onOpenChange(false);
65 | };
66 |
67 | return (
68 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/LeftSidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Plus, ExternalLink } from 'lucide-react';
2 | import { Button } from '@/components/ui/button';
3 | import { ConversationList } from './ConversationList';
4 | import { useApi } from '@/contexts/ApiContext';
5 | import { useToast } from '@/components/ui/use-toast';
6 | import { useNavigate } from 'react-router-dom';
7 | import type { ConversationItem } from './ConversationList';
8 | import { useQueryClient } from '@tanstack/react-query';
9 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
10 |
11 | import type { FC } from 'react';
12 | import { use$ } from '@legendapp/state/react';
13 | import { type Observable } from '@legendapp/state';
14 |
15 | interface Props {
16 | conversations: ConversationItem[];
17 | selectedConversationId$: Observable;
18 | onSelectConversation: (id: string) => void;
19 | isLoading?: boolean;
20 | isError?: boolean;
21 | error?: Error;
22 | onRetry?: () => void;
23 | route: string;
24 | }
25 |
26 | export const LeftSidebar: FC = ({
27 | conversations,
28 | selectedConversationId$,
29 | onSelectConversation,
30 | isLoading = false,
31 | isError = false,
32 | error,
33 | onRetry,
34 | route,
35 | }) => {
36 | const { api, isConnected$ } = useApi();
37 | const isConnected = use$(isConnected$);
38 | const { toast } = useToast();
39 | const navigate = useNavigate();
40 | const queryClient = useQueryClient();
41 |
42 | const handleNewConversation = async () => {
43 | const newId = Date.now().toString();
44 | // Navigate immediately for instant UI feedback
45 | navigate(`${route}?conversation=${newId}`);
46 |
47 | // Create conversation in background
48 | api
49 | .createConversation(newId, [])
50 | .then(() => {
51 | queryClient.invalidateQueries({ queryKey: ['conversations'] });
52 | toast({
53 | title: 'New conversation created',
54 | description: 'Starting a fresh conversation',
55 | });
56 | })
57 | .catch(() => {
58 | toast({
59 | variant: 'destructive',
60 | title: 'Error',
61 | description: 'Failed to create new conversation',
62 | });
63 | // Optionally navigate back on error
64 | navigate(route);
65 | });
66 | };
67 |
68 | return (
69 |
70 |
71 |
72 |
Conversations
73 |
74 |
75 |
76 |
77 |
88 |
89 |
90 | {!isConnected ? 'Connect to create new conversations' : 'Create new conversation'}
91 |
92 |
93 |
94 |
95 |
96 |
129 |
130 |
131 | );
132 | };
133 |
--------------------------------------------------------------------------------
/src/components/MenuBar.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeToggle } from './ThemeToggle';
2 | import { ConnectionButton } from './ConnectionButton';
3 | import { Button } from './ui/button';
4 | import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react';
5 | import {
6 | leftSidebarVisible$,
7 | rightSidebarVisible$,
8 | toggleLeftSidebar,
9 | toggleRightSidebar,
10 | } from '@/stores/sidebar';
11 | import { use$ } from '@legendapp/state/react';
12 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
13 |
14 | import type { FC } from 'react';
15 |
16 | export const MenuBar: FC = () => {
17 | const leftVisible = use$(leftSidebarVisible$);
18 | const rightVisible = use$(rightSidebarVisible$);
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
33 |
34 |
35 | {leftVisible ? 'Hide conversations' : 'Show conversations'}
36 |
37 |
38 |
39 |

40 |
gptme
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
55 |
56 |
57 | {rightVisible ? 'Hide sidebar' : 'Show sidebar'}
58 |
59 |
60 |
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/src/components/MessageAvatar.tsx:
--------------------------------------------------------------------------------
1 | import { Bot, User, Terminal } from 'lucide-react';
2 | import type { MessageRole } from '@/types/conversation';
3 | import { type Observable } from '@legendapp/state';
4 | import { use$ } from '@legendapp/state/react';
5 |
6 | interface MessageAvatarProps {
7 | role$: Observable;
8 | isError$?: Observable;
9 | isSuccess$?: Observable;
10 | chainType$: Observable<'start' | 'middle' | 'end' | 'standalone'>;
11 | }
12 |
13 | export function MessageAvatar({ role$, isError$, isSuccess$, chainType$ }: MessageAvatarProps) {
14 | const role = use$(role$);
15 | const isError = use$(isError$);
16 | const isSuccess = use$(isSuccess$);
17 | const chainType = use$(chainType$);
18 | // Only show avatar for standalone messages or the start of a chain
19 | if (chainType !== 'start' && chainType !== 'standalone') {
20 | return null;
21 | }
22 |
23 | const avatarClasses = `hidden md:flex mt-0.5 flex-shrink-0 w-8 h-8 rounded-full items-center justify-center absolute ${
24 | role === 'user'
25 | ? 'bg-blue-600 text-white right-0'
26 | : role === 'assistant'
27 | ? 'bg-gptme-600 text-white left-0'
28 | : isError
29 | ? 'bg-red-800 text-red-100'
30 | : isSuccess
31 | ? 'bg-green-800 text-green-100'
32 | : 'bg-slate-500 text-white left-0'
33 | }`;
34 |
35 | return (
36 |
37 | {role === 'assistant' ? (
38 |
39 | ) : role === 'system' ? (
40 |
41 | ) : (
42 |
43 | )}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/RightSidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Monitor, Settings, Globe, FolderOpen } from 'lucide-react';
2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3 | import { useState } from 'react';
4 | import type { FC } from 'react';
5 |
6 | interface Props {
7 | conversationId: string;
8 | }
9 | import { ConversationSettings } from './ConversationSettings';
10 | import { BrowserPreview } from './BrowserPreview';
11 | import { WorkspaceExplorer } from './workspace/WorkspaceExplorer';
12 |
13 | const VNC_URL = 'http://localhost:6080/vnc.html';
14 |
15 | export const RightSidebar: FC = ({ conversationId }) => {
16 | const [activeTab, setActiveTab] = useState('settings');
17 |
18 | return (
19 |
20 |
21 |
26 |
27 |
28 |
29 |
30 | Workspace
31 |
32 |
33 |
34 | Browser
35 |
36 |
37 |
38 | Computer
39 |
40 |
41 |
42 | Settings
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/components/TabbedCodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react';
2 | import { getCodeBlockEmoji } from '@/utils/markdownUtils';
3 | import * as smd from '@/utils/smd';
4 | import { customRenderer } from '@/utils/markdownRenderer';
5 |
6 | /**
7 | * Props for the TabbedCodeBlock component
8 | */
9 | interface TabbedCodeBlockProps {
10 | /** The language or file extension of the code */
11 | language?: string;
12 | /** The raw code content to display */
13 | codeText: string;
14 | /** The code element to display */
15 | code: HTMLElement;
16 | }
17 |
18 | /**
19 | * TabbedCodeBlock renders a code block with tabs for switching between
20 | * code view and preview. Preview is only available for markdown and HTML content.
21 | *
22 | * For markdown content, it uses the streaming markdown parser to render the preview.
23 | * For HTML content, it uses a sandboxed iframe to prevent XSS attacks.
24 | */
25 | export const TabbedCodeBlock: React.FC = ({ language, codeText, code }) => {
26 | const [activeTab, setActiveTab] = useState<'code' | 'preview'>('code');
27 | const previewRef = useRef(null);
28 | const [renderError, setRenderError] = useState(null);
29 |
30 | const emoji = getCodeBlockEmoji(language || '');
31 |
32 | // Check if this is a markdown code block
33 | const isMarkdown = language?.toLowerCase() === 'md' || language?.toLowerCase() === 'markdown';
34 |
35 | // Determine if preview should be available - only for markdown or HTML
36 | const hasPreview = isMarkdown || language?.toLowerCase() === 'html';
37 |
38 | // Handle markdown rendering when the preview tab is selected
39 | useEffect(() => {
40 | if (previewRef.current && codeText && isMarkdown) {
41 | try {
42 | // Clear previous content and error state
43 | previewRef.current.innerHTML = '';
44 | setRenderError(null);
45 |
46 | // Use streaming markdown parser for markdown content
47 | const renderer = customRenderer(previewRef.current);
48 | const parser = smd.parser(renderer);
49 | smd.parser_write(parser, codeText);
50 | smd.parser_end(parser);
51 | } catch (error) {
52 | console.error('Error rendering preview:', error);
53 | setRenderError('Failed to render preview');
54 | }
55 | }
56 | }, [codeText, language, isMarkdown]);
57 |
58 | return (
59 |
60 |
61 |
71 | {hasPreview && (
72 |
82 | )}
83 |
84 |
85 |
86 |
90 | {renderError && activeTab === 'preview' && (
91 |
92 | {renderError}
93 |
94 | )}
95 |
101 | {language === 'html' && (
102 |
108 | )}
109 |
110 |
111 | );
112 | };
113 |
--------------------------------------------------------------------------------
/src/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from 'lucide-react';
2 | import { Button } from '@/components/ui/button';
3 | import { useEffect, useState } from 'react';
4 | import type { FC } from 'react';
5 |
6 | export const ThemeToggle: FC = () => {
7 | const [theme, setTheme] = useState<'light' | 'dark'>('light');
8 |
9 | useEffect(() => {
10 | const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
11 | setTheme(isDark ? 'dark' : 'light');
12 | }, []);
13 |
14 | useEffect(() => {
15 | document.documentElement.classList.toggle('dark', theme === 'dark');
16 | }, [theme]);
17 |
18 | return (
19 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/__tests__/ChatMessage.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { ChatMessage } from '../ChatMessage';
3 | import '@testing-library/jest-dom';
4 | import type { Message } from '@/types/conversation';
5 | import { observable } from '@legendapp/state';
6 |
7 | // Mock the ApiContext
8 | jest.mock('@/contexts/ApiContext', () => ({
9 | useApi: () => ({
10 | baseUrl: 'http://localhost:5700',
11 | }),
12 | }));
13 |
14 | describe('ChatMessage', () => {
15 | const testConversationId = 'test-conversation';
16 |
17 | it('renders user message', () => {
18 | const message$ = observable({
19 | role: 'user',
20 | content: 'Hello!',
21 | timestamp: new Date().toISOString(),
22 | });
23 |
24 | render();
25 | expect(screen.getByText('Hello!')).toBeInTheDocument();
26 | });
27 |
28 | it('renders assistant message', () => {
29 | const message$ = observable({
30 | role: 'assistant',
31 | content: 'Hi there!',
32 | timestamp: new Date().toISOString(),
33 | });
34 |
35 | render();
36 | expect(screen.getByText('Hi there!')).toBeInTheDocument();
37 | });
38 |
39 | it('renders system message with monospace font', () => {
40 | const message$ = observable({
41 | role: 'system',
42 | content: 'System message',
43 | timestamp: new Date().toISOString(),
44 | });
45 |
46 | const { container } = render(
47 |
48 | );
49 | const messageElement = container.querySelector('.font-mono');
50 | expect(messageElement).toBeInTheDocument();
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/components/settings/EnvironmentVariables.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { FormDescription, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
3 | import { Input } from '@/components/ui/input';
4 | import { Plus, X } from 'lucide-react';
5 | import { useState } from 'react';
6 | import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form';
7 | import type { FormSchema } from '@/schemas/conversationSettings';
8 |
9 | interface EnvironmentVariablesProps {
10 | form: UseFormReturn;
11 | fieldArray: UseFieldArrayReturn;
12 | isSubmitting: boolean;
13 | description?: string;
14 | className?: string;
15 | }
16 |
17 | export const EnvironmentVariables = ({
18 | form,
19 | fieldArray,
20 | isSubmitting,
21 | description,
22 | className,
23 | }: EnvironmentVariablesProps) => {
24 | const [newEnvKey, setNewEnvKey] = useState('');
25 | const [newEnvValue, setNewEnvValue] = useState('');
26 | const { fields, append, remove } = fieldArray;
27 |
28 | const handleAddEnvVar = () => {
29 | const trimmedKey = newEnvKey.trim();
30 | if (trimmedKey) {
31 | append({ key: trimmedKey, value: newEnvValue });
32 | setNewEnvKey('');
33 | setNewEnvValue('');
34 | }
35 | };
36 |
37 | return (
38 |
39 | Environment Variables
40 |
68 |
100 | {description && {description}}
101 | {form.formState.errors.chat?.env && (
102 | {form.formState.errors.chat.env.message}
103 | )}
104 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/src/components/settings/ToolsConfiguration.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import {
3 | FormDescription,
4 | FormField,
5 | FormItem,
6 | FormLabel,
7 | FormControl,
8 | FormMessage,
9 | } from '@/components/ui/form';
10 | import { Input } from '@/components/ui/input';
11 | import {
12 | Select,
13 | SelectContent,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from '@/components/ui/select';
18 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
19 | import { ChevronDown, ChevronRight, X } from 'lucide-react';
20 | import { useState } from 'react';
21 | import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form';
22 | import type { FormSchema } from '@/schemas/conversationSettings';
23 | import { ToolFormat } from '@/types/api';
24 |
25 | interface ToolsConfigurationProps {
26 | form: UseFormReturn;
27 | toolFields: UseFieldArrayReturn;
28 | isSubmitting: boolean;
29 | }
30 |
31 | export const ToolsConfiguration = ({ form, toolFields, isSubmitting }: ToolsConfigurationProps) => {
32 | const [toolsOpen, setToolsOpen] = useState(false);
33 | const [newToolName, setNewToolName] = useState('');
34 | const { fields, append, remove } = toolFields;
35 |
36 | const handleAddTool = () => {
37 | const trimmedName = newToolName.trim();
38 | if (trimmedName) {
39 | append({ name: trimmedName });
40 | setNewToolName('');
41 | }
42 | };
43 |
44 | return (
45 |
46 |
Tools
47 |
48 |
(
52 |
53 | Tool Format
54 |
72 |
73 |
74 | )}
75 | />
76 |
77 |
78 |
79 |
80 |
81 | Enabled Tools
82 | {toolsOpen ? (
83 |
84 | ) : (
85 |
86 | )}
87 |
88 |
89 | List of tools that the agent can use.
90 |
91 |
92 |
93 |
94 | {fields.map((field, index) => (
95 |
96 | {field.name}
97 |
107 |
108 | ))}
109 |
110 |
111 | setNewToolName(e.target.value)}
115 | disabled={isSubmitting}
116 | onKeyDown={(e) => {
117 | if (e.key === 'Enter') {
118 | e.preventDefault();
119 | handleAddTool();
120 | }
121 | }}
122 | />
123 |
131 |
132 |
133 |
134 |
135 |
136 | );
137 | };
138 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as AccordionPrimitive from '@radix-ui/react-accordion';
3 | import { ChevronDown } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Accordion = AccordionPrimitive.Root;
8 |
9 | const AccordionItem = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
14 | ));
15 | AccordionItem.displayName = 'AccordionItem';
16 |
17 | const AccordionTrigger = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, children, ...props }, ref) => (
21 |
22 | svg]:rotate-180',
26 | className
27 | )}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 | ));
35 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
36 |
37 | const AccordionContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, children, ...props }, ref) => (
41 |
46 | {children}
47 |
48 | ));
49 |
50 | AccordionContent.displayName = AccordionPrimitive.Content.displayName;
51 |
52 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
53 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
3 |
4 | import { cn } from '@/lib/utils';
5 | import { buttonVariants } from '@/components/ui/button';
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root;
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ));
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ));
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
45 |
46 | const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
47 |
48 | );
49 | AlertDialogHeader.displayName = 'AlertDialogHeader';
50 |
51 | const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
52 |
56 | );
57 | AlertDialogFooter.displayName = 'AlertDialogFooter';
58 |
59 | const AlertDialogTitle = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ));
69 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
70 |
71 | const AlertDialogDescription = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
80 | ));
81 | AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
82 |
83 | const AlertDialogAction = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
88 | ));
89 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
90 |
91 | const AlertDialogCancel = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ));
101 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
102 |
103 | export {
104 | AlertDialog,
105 | AlertDialogPortal,
106 | AlertDialogOverlay,
107 | AlertDialogTrigger,
108 | AlertDialogContent,
109 | AlertDialogHeader,
110 | AlertDialogFooter,
111 | AlertDialogTitle,
112 | AlertDialogDescription,
113 | AlertDialogAction,
114 | AlertDialogCancel,
115 | };
116 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const alertVariants = cva(
7 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-background text-foreground',
12 | destructive:
13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
14 | },
15 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | }
20 | );
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
27 | ));
28 | Alert.displayName = 'Alert';
29 |
30 | const AlertTitle = React.forwardRef>(
31 | ({ className, ...props }, ref) => (
32 |
37 | )
38 | );
39 | AlertTitle.displayName = 'AlertTitle';
40 |
41 | const AlertDescription = React.forwardRef<
42 | HTMLParagraphElement,
43 | React.HTMLAttributes
44 | >(({ className, ...props }, ref) => (
45 |
46 | ));
47 | AlertDescription.displayName = 'AlertDescription';
48 |
49 | export { Alert, AlertTitle, AlertDescription };
50 |
--------------------------------------------------------------------------------
/src/components/ui/aspect-ratio.tsx:
--------------------------------------------------------------------------------
1 | import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
2 |
3 | const AspectRatio = AspectRatioPrimitive.Root;
4 |
5 | export { AspectRatio };
6 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
15 | ));
16 | Avatar.displayName = AvatarPrimitive.Root.displayName;
17 |
18 | const AvatarImage = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
27 | ));
28 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
29 |
30 | const AvatarFallback = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
42 | ));
43 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
44 |
45 | export { Avatar, AvatarImage, AvatarFallback };
46 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
12 | secondary:
13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
14 | destructive:
15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
16 | outline: 'text-foreground',
17 | },
18 | },
19 | defaultVariants: {
20 | variant: 'default',
21 | },
22 | }
23 | );
24 |
25 | export interface BadgeProps
26 | extends React.HTMLAttributes,
27 | VariantProps {}
28 |
29 | function Badge({ className, variant, ...props }: BadgeProps) {
30 | return ;
31 | }
32 |
33 | export { Badge, badgeVariants };
34 |
--------------------------------------------------------------------------------
/src/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { ChevronRight, MoreHorizontal } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Breadcrumb = React.forwardRef<
8 | HTMLElement,
9 | React.ComponentPropsWithoutRef<'nav'> & {
10 | separator?: React.ReactNode;
11 | }
12 | >(({ ...props }, ref) => );
13 | Breadcrumb.displayName = 'Breadcrumb';
14 |
15 | const BreadcrumbList = React.forwardRef>(
16 | ({ className, ...props }, ref) => (
17 |
25 | )
26 | );
27 | BreadcrumbList.displayName = 'BreadcrumbList';
28 |
29 | const BreadcrumbItem = React.forwardRef>(
30 | ({ className, ...props }, ref) => (
31 |
32 | )
33 | );
34 | BreadcrumbItem.displayName = 'BreadcrumbItem';
35 |
36 | const BreadcrumbLink = React.forwardRef<
37 | HTMLAnchorElement,
38 | React.ComponentPropsWithoutRef<'a'> & {
39 | asChild?: boolean;
40 | }
41 | >(({ asChild, className, ...props }, ref) => {
42 | const Comp = asChild ? Slot : 'a';
43 |
44 | return (
45 |
50 | );
51 | });
52 | BreadcrumbLink.displayName = 'BreadcrumbLink';
53 |
54 | const BreadcrumbPage = React.forwardRef>(
55 | ({ className, ...props }, ref) => (
56 |
64 | )
65 | );
66 | BreadcrumbPage.displayName = 'BreadcrumbPage';
67 |
68 | const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
69 | svg]:size-3.5', className)}
73 | {...props}
74 | >
75 | {children ?? }
76 |
77 | );
78 | BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
79 |
80 | const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
81 |
87 |
88 | More
89 |
90 | );
91 | BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
92 |
93 | export {
94 | Breadcrumb,
95 | BreadcrumbList,
96 | BreadcrumbItem,
97 | BreadcrumbLink,
98 | BreadcrumbPage,
99 | BreadcrumbSeparator,
100 | BreadcrumbEllipsis,
101 | };
102 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
14 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
15 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
16 | ghost: 'hover:bg-accent hover:text-accent-foreground',
17 | link: 'text-primary underline-offset-4 hover:underline',
18 | },
19 | size: {
20 | default: 'h-10 px-4 py-2',
21 | xs: 'h-8 px-2 text-xs rounded',
22 | sm: 'h-9 rounded-md px-3',
23 | lg: 'h-11 rounded-md px-8',
24 | icon: 'h-10 w-10',
25 | },
26 | },
27 | defaultVariants: {
28 | variant: 'default',
29 | size: 'default',
30 | },
31 | }
32 | );
33 |
34 | export interface ButtonProps
35 | extends React.ButtonHTMLAttributes,
36 | VariantProps {
37 | asChild?: boolean;
38 | }
39 |
40 | const Button = React.forwardRef(
41 | ({ className, variant, size, asChild = false, ...props }, ref) => {
42 | const Comp = asChild ? Slot : 'button';
43 | return (
44 |
45 | );
46 | }
47 | );
48 | Button.displayName = 'Button';
49 |
50 | export { Button, buttonVariants };
51 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ChevronLeft, ChevronRight } from 'lucide-react';
3 | import { DayPicker } from 'react-day-picker';
4 |
5 | import { cn } from '@/lib/utils';
6 | import { buttonVariants } from '@/components/ui/button';
7 |
8 | export type CalendarProps = React.ComponentProps;
9 |
10 | function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
11 | return (
12 | ,
49 | IconRight: () => ,
50 | }}
51 | {...props}
52 | />
53 | );
54 | }
55 | Calendar.displayName = 'Calendar';
56 |
57 | export { Calendar };
58 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Card = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
12 | )
13 | );
14 | Card.displayName = 'Card';
15 |
16 | const CardHeader = React.forwardRef>(
17 | ({ className, ...props }, ref) => (
18 |
19 | )
20 | );
21 | CardHeader.displayName = 'CardHeader';
22 |
23 | const CardTitle = React.forwardRef>(
24 | ({ className, ...props }, ref) => (
25 |
30 | )
31 | );
32 | CardTitle.displayName = 'CardTitle';
33 |
34 | const CardDescription = React.forwardRef<
35 | HTMLParagraphElement,
36 | React.HTMLAttributes
37 | >(({ className, ...props }, ref) => (
38 |
39 | ));
40 | CardDescription.displayName = 'CardDescription';
41 |
42 | const CardContent = React.forwardRef>(
43 | ({ className, ...props }, ref) => (
44 |
45 | )
46 | );
47 | CardContent.displayName = 'CardContent';
48 |
49 | const CardFooter = React.forwardRef>(
50 | ({ className, ...props }, ref) => (
51 |
52 | )
53 | );
54 | CardFooter.displayName = 'CardFooter';
55 |
56 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
57 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
3 | import { Check } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
20 |
21 |
22 |
23 | ));
24 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
25 |
26 | export { Checkbox };
27 |
--------------------------------------------------------------------------------
/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
2 |
3 | const Collapsible = CollapsiblePrimitive.Root;
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
10 |
--------------------------------------------------------------------------------
/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { type DialogProps } from '@radix-ui/react-dialog';
3 | import { Command as CommandPrimitive } from 'cmdk';
4 | import { Search } from 'lucide-react';
5 |
6 | import { cn } from '@/lib/utils';
7 | import { Dialog, DialogContent } from '@/components/ui/dialog';
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ));
22 | Command.displayName = CommandPrimitive.displayName;
23 |
24 | type CommandDialogProps = DialogProps;
25 |
26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
27 | return (
28 |
35 | );
36 | };
37 |
38 | const CommandInput = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
43 |
44 |
52 |
53 | ));
54 |
55 | CommandInput.displayName = CommandPrimitive.Input.displayName;
56 |
57 | const CommandList = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
66 | ));
67 |
68 | CommandList.displayName = CommandPrimitive.List.displayName;
69 |
70 | const CommandEmpty = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >((props, ref) => (
74 |
75 | ));
76 |
77 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
78 |
79 | const CommandGroup = React.forwardRef<
80 | React.ElementRef,
81 | React.ComponentPropsWithoutRef
82 | >(({ className, ...props }, ref) => (
83 |
91 | ));
92 |
93 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
94 |
95 | const CommandSeparator = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
104 | ));
105 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
106 |
107 | const CommandItem = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ className, ...props }, ref) => (
111 |
119 | ));
120 |
121 | CommandItem.displayName = CommandPrimitive.Item.displayName;
122 |
123 | const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => {
124 | return (
125 |
129 | );
130 | };
131 | CommandShortcut.displayName = 'CommandShortcut';
132 |
133 | export {
134 | Command,
135 | CommandDialog,
136 | CommandInput,
137 | CommandList,
138 | CommandEmpty,
139 | CommandGroup,
140 | CommandItem,
141 | CommandShortcut,
142 | CommandSeparator,
143 | };
144 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as DialogPrimitive from '@radix-ui/react-dialog';
3 | import { X } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Dialog = DialogPrimitive.Root;
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger;
10 |
11 | const DialogPortal = DialogPrimitive.Portal;
12 |
13 | const DialogClose = DialogPrimitive.Close;
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ));
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
55 |
56 | );
57 | DialogHeader.displayName = 'DialogHeader';
58 |
59 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
60 |
64 | );
65 | DialogFooter.displayName = 'DialogFooter';
66 |
67 | const DialogTitle = React.forwardRef<
68 | React.ElementRef,
69 | React.ComponentPropsWithoutRef
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
78 |
79 | const DialogDescription = React.forwardRef<
80 | React.ElementRef,
81 | React.ComponentPropsWithoutRef
82 | >(({ className, ...props }, ref) => (
83 |
88 | ));
89 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
90 |
91 | export {
92 | Dialog,
93 | DialogPortal,
94 | DialogOverlay,
95 | DialogClose,
96 | DialogTrigger,
97 | DialogContent,
98 | DialogHeader,
99 | DialogFooter,
100 | DialogTitle,
101 | DialogDescription,
102 | };
103 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Drawer as DrawerPrimitive } from 'vaul';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Drawer = ({
7 | shouldScaleBackground = true,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
11 | );
12 | Drawer.displayName = 'Drawer';
13 |
14 | const DrawerTrigger = DrawerPrimitive.Trigger;
15 |
16 | const DrawerPortal = DrawerPrimitive.Portal;
17 |
18 | const DrawerClose = DrawerPrimitive.Close;
19 |
20 | const DrawerOverlay = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
31 |
32 | const DrawerContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 |
47 | {children}
48 |
49 |
50 | ));
51 | DrawerContent.displayName = 'DrawerContent';
52 |
53 | const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => (
54 |
55 | );
56 | DrawerHeader.displayName = 'DrawerHeader';
57 |
58 | const DrawerFooter = ({ className, ...props }: React.HTMLAttributes) => (
59 |
60 | );
61 | DrawerFooter.displayName = 'DrawerFooter';
62 |
63 | const DrawerTitle = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, ...props }, ref) => (
67 |
72 | ));
73 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
74 |
75 | const DrawerDescription = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef
78 | >(({ className, ...props }, ref) => (
79 |
84 | ));
85 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
86 |
87 | export {
88 | Drawer,
89 | DrawerPortal,
90 | DrawerOverlay,
91 | DrawerTrigger,
92 | DrawerClose,
93 | DrawerContent,
94 | DrawerHeader,
95 | DrawerFooter,
96 | DrawerTitle,
97 | DrawerDescription,
98 | };
99 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import type * as LabelPrimitive from '@radix-ui/react-label';
3 | import { Slot } from '@radix-ui/react-slot';
4 | import {
5 | Controller,
6 | type ControllerProps,
7 | type FieldPath,
8 | type FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from 'react-hook-form';
12 |
13 | import { cn } from '@/lib/utils';
14 | import { Label } from '@/components/ui/label';
15 |
16 | const Form = FormProvider;
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath,
21 | > = {
22 | name: TName;
23 | };
24 |
25 | const FormFieldContext = React.createContext({} as FormFieldContextValue);
26 |
27 | const FormField = <
28 | TFieldValues extends FieldValues = FieldValues,
29 | TName extends FieldPath = FieldPath,
30 | >({
31 | ...props
32 | }: ControllerProps) => {
33 | return (
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | const useFormField = () => {
41 | const fieldContext = React.useContext(FormFieldContext);
42 | const itemContext = React.useContext(FormItemContext);
43 | const { getFieldState, formState } = useFormContext();
44 |
45 | const fieldState = getFieldState(fieldContext.name, formState);
46 |
47 | if (!fieldContext) {
48 | throw new Error('useFormField should be used within ');
49 | }
50 |
51 | const { id } = itemContext;
52 |
53 | return {
54 | id,
55 | name: fieldContext.name,
56 | formItemId: `${id}-form-item`,
57 | formDescriptionId: `${id}-form-item-description`,
58 | formMessageId: `${id}-form-item-message`,
59 | ...fieldState,
60 | };
61 | };
62 |
63 | type FormItemContextValue = {
64 | id: string;
65 | };
66 |
67 | const FormItemContext = React.createContext({} as FormItemContextValue);
68 |
69 | const FormItem = React.forwardRef>(
70 | ({ className, ...props }, ref) => {
71 | const id = React.useId();
72 |
73 | return (
74 |
75 |
76 |
77 | );
78 | }
79 | );
80 | FormItem.displayName = 'FormItem';
81 |
82 | const FormLabel = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => {
86 | const { error, formItemId } = useFormField();
87 |
88 | return (
89 |
95 | );
96 | });
97 | FormLabel.displayName = 'FormLabel';
98 |
99 | const FormControl = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ ...props }, ref) => {
103 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
104 |
105 | return (
106 |
113 | );
114 | });
115 | FormControl.displayName = 'FormControl';
116 |
117 | const FormDescription = React.forwardRef<
118 | HTMLParagraphElement,
119 | React.HTMLAttributes
120 | >(({ className, ...props }, ref) => {
121 | const { formDescriptionId } = useFormField();
122 |
123 | return (
124 |
130 | );
131 | });
132 | FormDescription.displayName = 'FormDescription';
133 |
134 | const FormMessage = React.forwardRef<
135 | HTMLParagraphElement,
136 | React.HTMLAttributes
137 | >(({ className, children, ...props }, ref) => {
138 | const { error, formMessageId } = useFormField();
139 | const body = error ? String(error?.message) : children;
140 |
141 | if (!body) {
142 | return null;
143 | }
144 |
145 | return (
146 |
152 | {body}
153 |
154 | );
155 | });
156 | FormMessage.displayName = 'FormMessage';
157 |
158 | export {
159 | useFormField,
160 | Form,
161 | FormItem,
162 | FormLabel,
163 | FormControl,
164 | FormDescription,
165 | FormMessage,
166 | FormField,
167 | };
168 |
--------------------------------------------------------------------------------
/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const HoverCard = HoverCardPrimitive.Root;
7 |
8 | const HoverCardTrigger = HoverCardPrimitive.Trigger;
9 |
10 | const HoverCardContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
14 |
24 | ));
25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
26 |
27 | export { HoverCard, HoverCardTrigger, HoverCardContent };
28 |
--------------------------------------------------------------------------------
/src/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { OTPInput, OTPInputContext } from 'input-otp';
3 | import { Dot } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const InputOTP = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, containerClassName, ...props }, ref) => (
11 |
20 | ));
21 | InputOTP.displayName = 'InputOTP';
22 |
23 | const InputOTPGroup = React.forwardRef<
24 | React.ElementRef<'div'>,
25 | React.ComponentPropsWithoutRef<'div'>
26 | >(({ className, ...props }, ref) => (
27 |
28 | ));
29 | InputOTPGroup.displayName = 'InputOTPGroup';
30 |
31 | const InputOTPSlot = React.forwardRef<
32 | React.ElementRef<'div'>,
33 | React.ComponentPropsWithoutRef<'div'> & { index: number }
34 | >(({ index, className, ...props }, ref) => {
35 | const inputOTPContext = React.useContext(OTPInputContext);
36 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
37 |
38 | return (
39 |
48 | {char}
49 | {hasFakeCaret && (
50 |
53 | )}
54 |
55 | );
56 | });
57 | InputOTPSlot.displayName = 'InputOTPSlot';
58 |
59 | const InputOTPSeparator = React.forwardRef<
60 | React.ElementRef<'div'>,
61 | React.ComponentPropsWithoutRef<'div'>
62 | >(({ ...props }, ref) => (
63 |
64 |
65 |
66 | ));
67 | InputOTPSeparator.displayName = 'InputOTPSeparator';
68 |
69 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
70 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export type InputProps = React.InputHTMLAttributes;
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | );
20 | }
21 | );
22 | Input.displayName = 'Input';
23 |
24 | export { Input };
25 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as LabelPrimitive from '@radix-ui/react-label';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef & VariantProps
14 | >(({ className, ...props }, ref) => (
15 |
16 | ));
17 | Label.displayName = LabelPrimitive.Root.displayName;
18 |
19 | export { Label };
20 |
--------------------------------------------------------------------------------
/src/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
3 | import { cva } from 'class-variance-authority';
4 | import { ChevronDown } from 'lucide-react';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const NavigationMenu = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 | {children}
18 |
19 |
20 | ));
21 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
22 |
23 | const NavigationMenuList = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ));
33 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
34 |
35 | const NavigationMenuItem = NavigationMenuPrimitive.Item;
36 |
37 | const navigationMenuTriggerStyle = cva(
38 | 'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
39 | );
40 |
41 | const NavigationMenuTrigger = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
50 | {children}{' '}
51 |
55 |
56 | ));
57 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
58 |
59 | const NavigationMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
71 | ));
72 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
73 |
74 | const NavigationMenuLink = NavigationMenuPrimitive.Link;
75 |
76 | const NavigationMenuViewport = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
81 |
89 |
90 | ));
91 | NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
92 |
93 | const NavigationMenuIndicator = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
105 |
106 |
107 | ));
108 | NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
109 |
110 | export {
111 | navigationMenuTriggerStyle,
112 | NavigationMenu,
113 | NavigationMenuList,
114 | NavigationMenuItem,
115 | NavigationMenuContent,
116 | NavigationMenuTrigger,
117 | NavigationMenuLink,
118 | NavigationMenuIndicator,
119 | NavigationMenuViewport,
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
3 |
4 | import { cn } from '@/lib/utils';
5 | import { type ButtonProps, buttonVariants } from '@/components/ui/button';
6 |
7 | const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
8 |
14 | );
15 | Pagination.displayName = 'Pagination';
16 |
17 | const PaginationContent = React.forwardRef>(
18 | ({ className, ...props }, ref) => (
19 |
20 | )
21 | );
22 | PaginationContent.displayName = 'PaginationContent';
23 |
24 | const PaginationItem = React.forwardRef>(
25 | ({ className, ...props }, ref) =>
26 | );
27 | PaginationItem.displayName = 'PaginationItem';
28 |
29 | type PaginationLinkProps = {
30 | isActive?: boolean;
31 | } & Pick &
32 | React.ComponentProps<'a'>;
33 |
34 | const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => (
35 |
46 | );
47 | PaginationLink.displayName = 'PaginationLink';
48 |
49 | const PaginationPrevious = ({
50 | className,
51 | ...props
52 | }: React.ComponentProps) => (
53 |
59 |
60 | Previous
61 |
62 | );
63 | PaginationPrevious.displayName = 'PaginationPrevious';
64 |
65 | const PaginationNext = ({ className, ...props }: React.ComponentProps) => (
66 |
72 | Next
73 |
74 |
75 | );
76 | PaginationNext.displayName = 'PaginationNext';
77 |
78 | const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
79 |
84 |
85 | More pages
86 |
87 | );
88 | PaginationEllipsis.displayName = 'PaginationEllipsis';
89 |
90 | export {
91 | Pagination,
92 | PaginationContent,
93 | PaginationEllipsis,
94 | PaginationItem,
95 | PaginationLink,
96 | PaginationNext,
97 | PaginationPrevious,
98 | };
99 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as PopoverPrimitive from '@radix-ui/react-popover';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ));
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
28 |
29 | export { Popover, PopoverTrigger, PopoverContent };
30 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ProgressPrimitive from '@radix-ui/react-progress';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Progress = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, value, ...props }, ref) => (
10 |
15 |
19 |
20 | ));
21 | Progress.displayName = ProgressPrimitive.Root.displayName;
22 |
23 | export { Progress };
24 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
3 | import { Circle } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const RadioGroup = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => {
11 | return ;
12 | });
13 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
14 |
15 | const RadioGroupItem = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => {
19 | return (
20 |
28 |
29 |
30 |
31 |
32 | );
33 | });
34 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
35 |
36 | export { RadioGroup, RadioGroupItem };
37 |
--------------------------------------------------------------------------------
/src/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | import { GripVertical } from 'lucide-react';
2 | import * as ResizablePrimitive from 'react-resizable-panels';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const ResizablePanelGroup = ({
7 | className,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
14 | );
15 |
16 | const ResizablePanel = ResizablePrimitive.Panel;
17 |
18 | const ResizableHandle = ({
19 | withHandle,
20 | className,
21 | ...props
22 | }: React.ComponentProps & {
23 | withHandle?: boolean;
24 | }) => (
25 | div]:rotate-90',
28 | className
29 | )}
30 | {...props}
31 | >
32 | {withHandle && (
33 |
34 |
35 |
36 | )}
37 |
38 | );
39 |
40 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
41 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ));
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = 'vertical', ...props }, ref) => (
28 |
39 |
40 |
41 | ));
42 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
43 |
44 | export { ScrollArea, ScrollBar };
45 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SelectPrimitive from '@radix-ui/react-select';
3 | import { Check, ChevronDown, ChevronUp } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Select = SelectPrimitive.Root;
8 |
9 | const SelectGroup = SelectPrimitive.Group;
10 |
11 | const SelectValue = SelectPrimitive.Value;
12 |
13 | const SelectTrigger = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, children, ...props }, ref) => (
17 | span]:line-clamp-1',
21 | className
22 | )}
23 | {...props}
24 | >
25 | {children}
26 |
27 |
28 |
29 |
30 | ));
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
32 |
33 | const SelectScrollUpButton = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
42 |
43 |
44 | ));
45 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
46 |
47 | const SelectScrollDownButton = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
56 |
57 |
58 | ));
59 | SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
60 |
61 | const SelectContent = React.forwardRef<
62 | React.ElementRef,
63 | React.ComponentPropsWithoutRef
64 | >(({ className, children, position = 'popper', ...props }, ref) => (
65 |
66 |
77 |
78 |
85 | {children}
86 |
87 |
88 |
89 |
90 | ));
91 | SelectContent.displayName = SelectPrimitive.Content.displayName;
92 |
93 | const SelectLabel = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
102 | ));
103 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
104 |
105 | const SelectItem = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, children, ...props }, ref) => (
109 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | {children}
124 |
125 | ));
126 | SelectItem.displayName = SelectPrimitive.Item.displayName;
127 |
128 | const SelectSeparator = React.forwardRef<
129 | React.ElementRef,
130 | React.ComponentPropsWithoutRef
131 | >(({ className, ...props }, ref) => (
132 |
137 | ));
138 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
139 |
140 | export {
141 | Select,
142 | SelectGroup,
143 | SelectValue,
144 | SelectTrigger,
145 | SelectContent,
146 | SelectLabel,
147 | SelectItem,
148 | SelectSeparator,
149 | SelectScrollUpButton,
150 | SelectScrollDownButton,
151 | };
152 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SeparatorPrimitive from '@radix-ui/react-separator';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
10 |
21 | ));
22 | Separator.displayName = SeparatorPrimitive.Root.displayName;
23 |
24 | export { Separator };
25 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SheetPrimitive from '@radix-ui/react-dialog';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 | import { X } from 'lucide-react';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const Sheet = SheetPrimitive.Root;
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger;
11 |
12 | const SheetClose = SheetPrimitive.Close;
13 |
14 | const SheetPortal = SheetPrimitive.Portal;
15 |
16 | const SheetOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ));
29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
30 |
31 | const sheetVariants = cva(
32 | 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
33 | {
34 | variants: {
35 | side: {
36 | top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
37 | bottom:
38 | 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
39 | left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
40 | right:
41 | 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
42 | },
43 | },
44 | defaultVariants: {
45 | side: 'right',
46 | },
47 | }
48 | );
49 |
50 | interface SheetContentProps
51 | extends React.ComponentPropsWithoutRef,
52 | VariantProps {}
53 |
54 | const SheetContent = React.forwardRef<
55 | React.ElementRef,
56 | SheetContentProps
57 | >(({ side = 'right', className, children, ...props }, ref) => (
58 |
59 |
60 |
61 | {children}
62 |
63 |
64 | Close
65 |
66 |
67 |
68 | ));
69 | SheetContent.displayName = SheetPrimitive.Content.displayName;
70 |
71 | const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
72 |
73 | );
74 | SheetHeader.displayName = 'SheetHeader';
75 |
76 | const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
77 |
81 | );
82 | SheetFooter.displayName = 'SheetFooter';
83 |
84 | const SheetTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
93 | ));
94 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
95 |
96 | const SheetDescription = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, ...props }, ref) => (
100 |
105 | ));
106 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
107 |
108 | export {
109 | Sheet,
110 | SheetPortal,
111 | SheetOverlay,
112 | SheetTrigger,
113 | SheetClose,
114 | SheetContent,
115 | SheetHeader,
116 | SheetFooter,
117 | SheetTitle,
118 | SheetDescription,
119 | };
120 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return ;
5 | }
6 |
7 | export { Skeleton };
8 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SliderPrimitive from '@radix-ui/react-slider';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Slider = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
15 |
16 |
17 |
18 |
19 |
20 | ));
21 | Slider.displayName = SliderPrimitive.Root.displayName;
22 |
23 | export { Slider };
24 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes';
2 | import { Toaster as Sonner } from 'sonner';
3 |
4 | type ToasterProps = React.ComponentProps;
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = 'system' } = useTheme();
8 |
9 | return (
10 |
24 | );
25 | };
26 |
27 | export { Toaster };
28 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SwitchPrimitives from '@radix-ui/react-switch';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Table = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
10 | )
11 | );
12 | Table.displayName = 'Table';
13 |
14 | const TableHeader = React.forwardRef<
15 | HTMLTableSectionElement,
16 | React.HTMLAttributes
17 | >(({ className, ...props }, ref) => (
18 |
19 | ));
20 | TableHeader.displayName = 'TableHeader';
21 |
22 | const TableBody = React.forwardRef<
23 | HTMLTableSectionElement,
24 | React.HTMLAttributes
25 | >(({ className, ...props }, ref) => (
26 |
27 | ));
28 | TableBody.displayName = 'TableBody';
29 |
30 | const TableFooter = React.forwardRef<
31 | HTMLTableSectionElement,
32 | React.HTMLAttributes
33 | >(({ className, ...props }, ref) => (
34 | tr]:last:border-b-0', className)}
37 | {...props}
38 | />
39 | ));
40 | TableFooter.displayName = 'TableFooter';
41 |
42 | const TableRow = React.forwardRef>(
43 | ({ className, ...props }, ref) => (
44 |
52 | )
53 | );
54 | TableRow.displayName = 'TableRow';
55 |
56 | const TableHead = React.forwardRef<
57 | HTMLTableCellElement,
58 | React.ThHTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 | |
68 | ));
69 | TableHead.displayName = 'TableHead';
70 |
71 | const TableCell = React.forwardRef<
72 | HTMLTableCellElement,
73 | React.TdHTMLAttributes
74 | >(({ className, ...props }, ref) => (
75 | |
80 | ));
81 | TableCell.displayName = 'TableCell';
82 |
83 | const TableCaption = React.forwardRef<
84 | HTMLTableCaptionElement,
85 | React.HTMLAttributes
86 | >(({ className, ...props }, ref) => (
87 |
88 | ));
89 | TableCaption.displayName = 'TableCaption';
90 |
91 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
92 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as TabsPrimitive from '@radix-ui/react-tabs';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Tabs = TabsPrimitive.Root;
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | TabsList.displayName = TabsPrimitive.List.displayName;
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ));
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ));
51 | TabsContent.displayName = TabsPrimitive.Content.displayName;
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent };
54 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export type TextareaProps = React.TextareaHTMLAttributes;
6 |
7 | const Textarea = React.forwardRef(
8 | ({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | }
20 | );
21 | Textarea.displayName = 'Textarea';
22 |
23 | export { Textarea };
24 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ToastPrimitives from '@radix-ui/react-toast';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 | import { X } from 'lucide-react';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
27 | {
28 | variants: {
29 | variant: {
30 | default: 'border bg-background text-foreground',
31 | destructive:
32 | 'destructive group border-destructive bg-destructive text-destructive-foreground',
33 | },
34 | },
35 | defaultVariants: {
36 | variant: 'default',
37 | },
38 | }
39 | );
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef & VariantProps
44 | >(({ className, variant, ...props }, ref) => {
45 | return (
46 |
51 | );
52 | });
53 | Toast.displayName = ToastPrimitives.Root.displayName;
54 |
55 | const ToastAction = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 | ));
68 | ToastAction.displayName = ToastPrimitives.Action.displayName;
69 |
70 | const ToastClose = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, ...props }, ref) => (
74 |
83 |
84 |
85 | ));
86 | ToastClose.displayName = ToastPrimitives.Close.displayName;
87 |
88 | const ToastTitle = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
93 | ));
94 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
95 |
96 | const ToastDescription = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, ...props }, ref) => (
100 |
105 | ));
106 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
107 |
108 | type ToastProps = React.ComponentPropsWithoutRef;
109 |
110 | type ToastActionElement = React.ReactElement;
111 |
112 | export {
113 | type ToastProps,
114 | type ToastActionElement,
115 | ToastProvider,
116 | ToastViewport,
117 | Toast,
118 | ToastTitle,
119 | ToastDescription,
120 | ToastClose,
121 | ToastAction,
122 | };
123 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { useToast } from '@/hooks/use-toast';
2 | import {
3 | Toast,
4 | ToastClose,
5 | ToastDescription,
6 | ToastProvider,
7 | ToastTitle,
8 | ToastViewport,
9 | } from '@/components/ui/toast';
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast();
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title}}
21 | {description && {description}}
22 |
23 | {action}
24 |
25 |
26 | );
27 | })}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
3 | import { type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 | import { toggleVariants } from '@/components/ui/toggle';
7 |
8 | const ToggleGroupContext = React.createContext>({
9 | size: 'default',
10 | variant: 'default',
11 | });
12 |
13 | const ToggleGroup = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, variant, size, children, ...props }, ref) => (
18 |
23 | {children}
24 |
25 | ));
26 |
27 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
28 |
29 | const ToggleGroupItem = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, children, variant, size, ...props }, ref) => {
34 | const context = React.useContext(ToggleGroupContext);
35 |
36 | return (
37 |
48 | {children}
49 |
50 | );
51 | });
52 |
53 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
54 |
55 | export { ToggleGroup, ToggleGroupItem };
56 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as TogglePrimitive from '@radix-ui/react-toggle';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const toggleVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-transparent',
13 | outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
14 | },
15 | size: {
16 | default: 'h-10 px-3',
17 | sm: 'h-9 px-2.5',
18 | lg: 'h-11 px-5',
19 | },
20 | },
21 | defaultVariants: {
22 | variant: 'default',
23 | size: 'default',
24 | },
25 | }
26 | );
27 |
28 | const Toggle = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef & VariantProps
31 | >(({ className, variant, size, ...props }, ref) => (
32 |
37 | ));
38 |
39 | Toggle.displayName = TogglePrimitive.Root.displayName;
40 |
41 | export { Toggle, toggleVariants };
42 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ));
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
29 |
--------------------------------------------------------------------------------
/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | import { useToast, toast } from '@/hooks/use-toast';
2 |
3 | export { useToast, toast };
4 |
--------------------------------------------------------------------------------
/src/components/workspace/FileList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | FileTextIcon,
4 | FolderIcon,
5 | ArrowLeft,
6 | ImageIcon,
7 | FileCodeIcon,
8 | FileIcon,
9 | FileVideoIcon,
10 | FileAudioIcon,
11 | FileArchiveIcon,
12 | } from 'lucide-react';
13 | import type { LucideIcon } from 'lucide-react';
14 | import { ScrollArea } from '@/components/ui/scroll-area';
15 | import type { FileType } from '@/types/workspace';
16 |
17 | // prettier-ignore
18 | const CODE_EXTENSIONS = [
19 | // Web
20 | 'js', 'ts', 'jsx', 'tsx', 'html', 'css', 'scss', 'less',
21 | // Backend
22 | 'py', 'rb', 'php', 'java', 'go', 'rs', 'cs', 'cpp', 'c', 'h',
23 | // Config/Data
24 | 'json', 'yaml', 'yml', 'toml', 'xml', 'ini',
25 | // Shell
26 | 'sh', 'bash', 'zsh', 'fish'
27 | ];
28 |
29 | // prettier-ignore
30 | const ARCHIVE_EXTENSIONS = ['zip', 'tar', 'gz', 'tgz', '7z', 'rar', 'bz2', 'xz'];
31 |
32 | // prettier-ignore
33 | const DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'md', 'txt'];
34 |
35 | const getFileIcon = (file: FileType): LucideIcon => {
36 | // Get lowercase extension
37 | const ext = file.name.split('.').pop()?.toLowerCase() || '';
38 |
39 | // Check extension first for more specific matches
40 | if (CODE_EXTENSIONS.includes(ext)) {
41 | return FileCodeIcon;
42 | }
43 | if (ARCHIVE_EXTENSIONS.includes(ext)) {
44 | return FileArchiveIcon;
45 | }
46 | if (DOCUMENT_EXTENSIONS.includes(ext)) {
47 | return FileTextIcon;
48 | }
49 |
50 | // Then check MIME type for broader categories
51 | if (!file.mime_type) {
52 | return FileIcon;
53 | }
54 |
55 | const [type, subtype] = file.mime_type.split('/');
56 | switch (type) {
57 | case 'image':
58 | return ImageIcon;
59 | case 'video':
60 | return FileVideoIcon;
61 | case 'audio':
62 | return FileAudioIcon;
63 | case 'text':
64 | return FileTextIcon;
65 | case 'application':
66 | switch (subtype) {
67 | case 'x-archive':
68 | case 'zip':
69 | case 'x-tar':
70 | case 'x-gzip':
71 | return FileArchiveIcon;
72 | case 'x-httpd-php':
73 | case 'javascript':
74 | case 'typescript':
75 | return FileCodeIcon;
76 | default:
77 | if (subtype.includes('code') || subtype.includes('script')) {
78 | return FileCodeIcon;
79 | }
80 | }
81 | }
82 |
83 | // Default icon for unknown types
84 | return FileIcon;
85 | };
86 |
87 | interface FileListProps {
88 | files: FileType[];
89 | currentPath: string;
90 | onFileClick: (file: FileType) => void;
91 | onDirectoryClick: (path: string) => void;
92 | }
93 |
94 | export function FileList({ files, currentPath, onFileClick, onDirectoryClick }: FileListProps) {
95 | const goToParent = () => {
96 | if (!currentPath) return;
97 | const parentPath = currentPath.split('/').slice(0, -1).join('/');
98 | onDirectoryClick(parentPath);
99 | };
100 |
101 | return (
102 |
103 |
104 | {currentPath && (
105 |
114 | )}
115 | {files.map((file) => (
116 |
139 | ))}
140 |
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/src/components/workspace/FilePreview.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useCallback } from 'react';
2 | import { Loader2 } from 'lucide-react';
3 | import { formatDistanceToNow } from 'date-fns';
4 | import { useWorkspaceApi } from '@/utils/workspaceApi';
5 | import type { FileType, FilePreview } from '@/types/workspace';
6 | import { CodeDisplay } from '@/components/CodeDisplay';
7 |
8 | interface FilePreviewProps {
9 | file: FileType;
10 | conversationId: string;
11 | }
12 |
13 | export function FilePreview({ file, conversationId }: FilePreviewProps) {
14 | const [preview, setPreview] = useState(null);
15 | const [error, setError] = useState(null);
16 | const [loading, setLoading] = useState(true);
17 |
18 | const { previewFile } = useWorkspaceApi();
19 |
20 | const loadPreview = useCallback(async () => {
21 | try {
22 | setLoading(true);
23 | setError(null);
24 | const data = await previewFile(conversationId, file.path);
25 | setPreview(data);
26 | } catch (err) {
27 | setError(err instanceof Error ? err.message : 'Failed to load preview');
28 | } finally {
29 | setLoading(false);
30 | }
31 | }, [file.path, conversationId, previewFile]);
32 |
33 | useEffect(() => {
34 | loadPreview();
35 | }, [loadPreview]);
36 |
37 | if (loading) {
38 | return (
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | if (error) {
46 | return {error}
;
47 | }
48 |
49 | if (!preview) {
50 | return null;
51 | }
52 |
53 | switch (preview.type) {
54 | case 'text':
55 | return (
56 |
57 |
58 |
{file.name}
59 |
60 |
61 | {(file.size / 1024).toFixed(1)} KB • {file.mime_type || 'Unknown type'}
62 |
63 |
64 | Modified {formatDistanceToNow(new Date(file.modified), { addSuffix: true })}
65 |
66 |
67 |
68 |
69 |
74 |
75 |
76 | );
77 | case 'image':
78 | return (
79 |
80 |
81 |
{file.name}
82 |
83 |
84 | {(file.size / 1024).toFixed(1)} KB • {file.mime_type || 'Unknown type'}
85 |
86 |
87 | Modified {formatDistanceToNow(new Date(file.modified), { addSuffix: true })}
88 |
89 |
90 |
91 |
92 |

97 |
98 |
99 | );
100 | case 'binary':
101 | return (
102 |
103 |
104 |
{file.name}
105 |
106 |
107 | {(file.size / 1024).toFixed(1)} KB • {file.mime_type || 'Unknown type'}
108 |
109 |
110 | Modified {formatDistanceToNow(new Date(file.modified), { addSuffix: true })}
111 |
112 |
113 |
114 |
117 |
118 | );
119 | default:
120 | return null;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/workspace/PathSegments.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { ChevronRight } from 'lucide-react';
3 |
4 | interface PathSegmentsProps {
5 | path: string;
6 | onNavigate: (path: string) => void;
7 | }
8 |
9 | export function PathSegments({ path, onNavigate }: PathSegmentsProps) {
10 | const segments = path ? path.split('/') : [];
11 |
12 | return (
13 |
14 |
17 | {segments.map((segment, index) => {
18 | if (!segment) return null;
19 | const segmentPath = segments.slice(0, index + 1).join('/');
20 | return (
21 |
22 |
23 |
31 |
32 | );
33 | })}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/workspace/WorkspaceExplorer.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { Loader2 } from 'lucide-react';
3 | import { useWorkspaceApi } from '@/utils/workspaceApi';
4 | import { FileList } from './FileList';
5 | import { FilePreview } from './FilePreview';
6 | import { PathSegments } from './PathSegments';
7 | import { Switch } from '@/components/ui/switch';
8 | import { Label } from '@/components/ui/label';
9 | import type { FileType } from '@/types/workspace';
10 |
11 | interface WorkspaceExplorerProps {
12 | conversationId: string;
13 | }
14 |
15 | export function WorkspaceExplorer({ conversationId }: WorkspaceExplorerProps) {
16 | const [files, setFiles] = useState([]);
17 | const [currentPath, setCurrentPath] = useState('');
18 | const [selectedFile, setSelectedFile] = useState(null);
19 | const [showHidden, setShowHidden] = useState(false);
20 | const [loading, setLoading] = useState(true);
21 | const [error, setError] = useState(null);
22 | const { listWorkspace } = useWorkspaceApi();
23 |
24 | const loadFiles = useCallback(async () => {
25 | try {
26 | setLoading(true);
27 | setError(null);
28 | const data = await listWorkspace(conversationId, currentPath, showHidden);
29 | setFiles(data);
30 | } catch (err) {
31 | console.error('Error loading workspace:', err);
32 | setError(err instanceof Error ? err.message : 'Failed to load workspace');
33 | } finally {
34 | setLoading(false);
35 | }
36 | }, [conversationId, currentPath, showHidden, listWorkspace]);
37 |
38 | useEffect(() => {
39 | loadFiles();
40 | }, [loadFiles]);
41 |
42 | const handleFileClick = (file: FileType) => {
43 | setSelectedFile(file);
44 | };
45 |
46 | const handleDirectoryClick = (path: string) => {
47 | setCurrentPath(path);
48 | setSelectedFile(null);
49 | };
50 |
51 | if (error) {
52 | return {error}
;
53 | }
54 |
55 | return (
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | {loading ? (
68 |
69 |
70 |
71 | ) : (
72 |
78 | )}
79 |
80 |
81 | {selectedFile ? (
82 |
83 | ) : (
84 |
85 | Select a file to preview
86 |
87 | )}
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import * as React from 'react';
3 |
4 | import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | type ToastActionType = {
17 | ADD_TOAST: 'ADD_TOAST';
18 | UPDATE_TOAST: 'UPDATE_TOAST';
19 | DISMISS_TOAST: 'DISMISS_TOAST';
20 | REMOVE_TOAST: 'REMOVE_TOAST';
21 | };
22 |
23 | let count = 0;
24 |
25 | function genId(): string {
26 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
27 | return count.toString();
28 | }
29 |
30 | type Action =
31 | | {
32 | type: ToastActionType['ADD_TOAST'];
33 | toast: ToasterToast;
34 | }
35 | | {
36 | type: ToastActionType['UPDATE_TOAST'];
37 | toast: Partial;
38 | }
39 | | {
40 | type: ToastActionType['DISMISS_TOAST'];
41 | toastId?: ToasterToast['id'];
42 | }
43 | | {
44 | type: ToastActionType['REMOVE_TOAST'];
45 | toastId?: ToasterToast['id'];
46 | };
47 |
48 | interface State {
49 | toasts: ToasterToast[];
50 | }
51 |
52 | const toastTimeouts = new Map>();
53 |
54 | const addToRemoveQueue = (toastId: string) => {
55 | if (toastTimeouts.has(toastId)) {
56 | return;
57 | }
58 |
59 | const timeout = setTimeout(() => {
60 | toastTimeouts.delete(toastId);
61 | dispatch({
62 | type: 'REMOVE_TOAST',
63 | toastId: toastId,
64 | });
65 | }, TOAST_REMOVE_DELAY);
66 |
67 | toastTimeouts.set(toastId, timeout);
68 | };
69 |
70 | export const reducer = (state: State, action: Action): State => {
71 | switch (action.type) {
72 | case 'ADD_TOAST':
73 | return {
74 | ...state,
75 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
76 | };
77 |
78 | case 'UPDATE_TOAST':
79 | return {
80 | ...state,
81 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
82 | };
83 |
84 | case 'DISMISS_TOAST': {
85 | const { toastId } = action;
86 |
87 | // ! Side effects ! - This could be extracted into a dismissToast() action,
88 | // but I'll keep it here for simplicity
89 | if (toastId) {
90 | addToRemoveQueue(toastId);
91 | } else {
92 | state.toasts.forEach((toast) => {
93 | addToRemoveQueue(toast.id);
94 | });
95 | }
96 |
97 | return {
98 | ...state,
99 | toasts: state.toasts.map((t) =>
100 | t.id === toastId || toastId === undefined
101 | ? {
102 | ...t,
103 | open: false,
104 | }
105 | : t
106 | ),
107 | };
108 | }
109 | case 'REMOVE_TOAST':
110 | if (action.toastId === undefined) {
111 | return {
112 | ...state,
113 | toasts: [],
114 | };
115 | }
116 | return {
117 | ...state,
118 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
119 | };
120 | }
121 | };
122 |
123 | const listeners: Array<(state: State) => void> = [];
124 |
125 | let memoryState: State = { toasts: [] };
126 |
127 | function dispatch(action: Action): void {
128 | memoryState = reducer(memoryState, action);
129 | listeners.forEach((listener) => {
130 | listener(memoryState);
131 | });
132 | }
133 |
134 | type Toast = Omit;
135 |
136 | function toast(props: Toast): {
137 | id: string;
138 | dismiss: () => void;
139 | update: (props: ToasterToast) => void;
140 | } {
141 | const id = genId();
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: 'UPDATE_TOAST',
146 | toast: { ...props, id },
147 | });
148 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
149 |
150 | dispatch({
151 | type: 'ADD_TOAST',
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: (open) => {
157 | if (!open) dismiss();
158 | },
159 | },
160 | });
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | };
167 | }
168 |
169 | interface ToastReturn extends State {
170 | toast: typeof toast;
171 | dismiss: (toastId?: string) => void;
172 | }
173 |
174 | function useToast(): ToastReturn {
175 | const [state, setState] = React.useState(memoryState);
176 |
177 | useEffect(() => {
178 | listeners.push(setState);
179 | return () => {
180 | const index = listeners.indexOf(setState);
181 | if (index > -1) {
182 | listeners.splice(index, 1);
183 | }
184 | };
185 | }, []);
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
191 | };
192 | }
193 |
194 | export { useToast, toast };
195 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://rsms.me/inter/inter.css');
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | @layer base {
8 | :root {
9 | --background: 0 0% 100%;
10 | --foreground: 240 10% 3.9%;
11 | --card: 0 0% 100%;
12 | --card-foreground: 240 10% 3.9%;
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 | --primary: 142.1 76.2% 36.3%;
16 | --primary-foreground: 355.7 100% 97.3%;
17 | --secondary: 240 4.8% 95.9%;
18 | --secondary-foreground: 240 5.9% 10%;
19 | --muted: 240 4.8% 95.9%;
20 | --muted-foreground: 240 3.8% 46.1%;
21 | --accent: 240 4.8% 95.9%;
22 | --accent-foreground: 240 5.9% 10%;
23 | --destructive: 0 84.2% 60.2%;
24 | --destructive-foreground: 0 0% 98%;
25 | --border: 240 5.9% 90%;
26 | --input: 240 5.9% 90%;
27 | --ring: 142.1 76.2% 36.3%;
28 | --code-background: 0 0% 13%;
29 | --codesnip: 0 0% 0%;
30 | --codesnip-background: 0 0% 90%;
31 | --radius: 0.75rem;
32 | }
33 |
34 | .dark {
35 | --background: 0 0% 8%;
36 | --foreground: 0 0% 98%;
37 | --card: 0 0% 10%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 0 0% 10%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 142.1 70.6% 45.3%;
42 | --primary-foreground: 144.9 80.4% 10%;
43 | --secondary: 0 0% 13%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 0 0% 13%;
46 | --muted-foreground: 0 0% 65%;
47 | --accent: 0 0% 13%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 85.7% 97.3%;
51 | --border: 0 0% 13%;
52 | --input: 0 0% 23%;
53 | --ring: 142.4 71.8% 29.2%;
54 | --code-background: 0 0% 3%;
55 | --codesnip: 0 0% 100%;
56 | --codesnip-background: 0 0% 6%;
57 | }
58 | }
59 |
60 | @layer base {
61 | * {
62 | @apply border-border;
63 | }
64 | body {
65 | @apply bg-background text-sm text-foreground;
66 | font-feature-settings: 'ss01', 'ss02', 'cv01', 'cv02', 'cv03';
67 | }
68 | }
69 |
70 | .chat-message {
71 | @apply py-1.5;
72 | }
73 |
74 | .chat-message a {
75 | @apply text-blue-300;
76 |
77 | &:hover {
78 | @apply text-blue-600;
79 | }
80 |
81 | &:visited {
82 | @apply text-purple-300;
83 | }
84 |
85 | &:visited:hover {
86 | @apply text-purple-600;
87 | }
88 | }
89 |
90 | .chat-message > p {
91 | @apply py-0.5 text-sm leading-relaxed;
92 | }
93 |
94 | .role-system .chat-message > p {
95 | @apply py-0.5 text-xs leading-relaxed;
96 | }
97 |
98 | .chat-message h1 {
99 | @apply mt-6 border-b border-border pb-2 text-2xl font-bold;
100 | }
101 |
102 | .chat-message h2 {
103 | @apply mb-2 mt-5 border-b border-border pb-1 text-xl font-bold;
104 | }
105 |
106 | .chat-message h3 {
107 | @apply mb-2 mt-4 pb-1 text-lg font-bold;
108 | }
109 |
110 | .chat-message h4 {
111 | @apply mt-3 pb-2 text-base font-bold;
112 | }
113 |
114 | .chat-message h5 {
115 | @apply mt-3 pb-2 text-sm font-bold;
116 | }
117 |
118 | .chat-message h6 {
119 | @apply mt-3 pb-2 text-xs font-bold;
120 | }
121 |
122 | .chat-message details > pre {
123 | @apply rounded-b-lg;
124 | }
125 |
126 | .chat-message pre {
127 | @apply overflow-x-auto text-white;
128 | background-color: hsl(var(--code-background));
129 | }
130 |
131 | .chat-message code {
132 | @apply rounded-lg text-xs text-white;
133 | background-color: hsl(var(--code-background));
134 | }
135 |
136 | .chat-message *:not(pre) > code {
137 | @apply rounded-sm border-border px-[.4rem] py-1 text-[0.7rem] text-gray-900;
138 | color: hsl(var(--codesnip));
139 | background-color: hsl(var(--codesnip-background));
140 | }
141 |
142 | .chat-message details {
143 | @apply my-2 overflow-hidden rounded-md border border-border bg-white font-sans text-sm dark:border-stone-950 dark:bg-slate-900;
144 | }
145 |
146 | .chat-message details summary {
147 | @apply cursor-pointer rounded-md bg-muted px-3 py-2 text-xs transition-colors;
148 | user-select: none;
149 | }
150 |
151 | .chat-message details:not([open]) summary {
152 | @apply hover:ring hover:ring-inset hover:ring-ring;
153 | }
154 |
155 | .chat-message details[open] {
156 | @apply hover:bg-secondary hover:text-secondary-foreground;
157 | }
158 |
159 | .chat-message details[type='thinking'][open] summary {
160 | @apply bg-transparent;
161 | }
162 |
163 | .chat-message details[type='thinking'][open] {
164 | @apply text-xs;
165 | box-shadow: 0 0 10px 4px hsl(var(--border)) inset;
166 | /*
167 | inset box shadow
168 | */
169 | }
170 |
171 | .chat-message details[type='thinking'] > *:not(summary) {
172 | @apply italic text-gray-400;
173 | }
174 |
175 | .chat-message details[open] {
176 | @apply rounded-lg border border-border;
177 | }
178 |
179 | .chat-message details[open] > summary {
180 | @apply rounded-b-none;
181 | }
182 |
183 | details > details {
184 | @apply mx-4 my-2;
185 | }
186 |
187 | .chat-message details[open] > *:not(summary):not(pre):not(details) {
188 | @apply px-3 py-1;
189 | }
190 |
191 | .chat-message pre {
192 | @apply block;
193 | }
194 |
195 | .chat-message ul {
196 | @apply my-2 ml-5 list-disc space-y-1;
197 | }
198 |
199 | .chat-message ol {
200 | @apply my-2 ml-5 list-decimal space-y-1;
201 | }
202 |
203 | /* Syntax highlighting colors */
204 | .hljs {
205 | @apply text-white;
206 | }
207 |
208 | .hljs-keyword {
209 | @apply text-purple-400;
210 | }
211 |
212 | .hljs-string {
213 | @apply text-green-400;
214 | }
215 |
216 | .hljs-comment {
217 | @apply text-gray-500;
218 | }
219 |
220 | .hljs-function {
221 | @apply text-blue-400;
222 | }
223 |
224 | .hljs-number {
225 | @apply text-orange-400;
226 | }
227 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]): string {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import App from './App.tsx';
3 | import './index.css';
4 |
5 | createRoot(document.getElementById('root')!).render();
6 |
--------------------------------------------------------------------------------
/src/pages/Index.tsx:
--------------------------------------------------------------------------------
1 | import { type FC } from 'react';
2 | import { MenuBar } from '@/components/MenuBar';
3 | import Conversations from '@/components/Conversations';
4 |
5 | interface Props {
6 | className?: string;
7 | }
8 |
9 | const Index: FC = () => {
10 | return (
11 |
12 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default Index;
19 |
--------------------------------------------------------------------------------
/src/schemas/conversationSettings.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { ToolFormat } from '@/types/api';
3 |
4 | export const mcpServerSchema = z.object({
5 | name: z.string().min(1, 'Server name cannot be empty'),
6 | enabled: z.boolean(),
7 | command: z.string().min(1, 'Command cannot be empty'),
8 | args: z.string(),
9 | env: z
10 | .array(
11 | z.object({
12 | key: z.string().min(1, 'Variable name cannot be empty'),
13 | value: z.string(),
14 | })
15 | )
16 | .optional(),
17 | });
18 |
19 | export const formSchema = z.object({
20 | chat: z.object({
21 | model: z.string().optional(),
22 | tools: z.array(z.object({ name: z.string().min(1, 'Tool name cannot be empty') })).optional(),
23 | tool_format: z.nativeEnum(ToolFormat).nullable().optional(),
24 | stream: z.boolean(),
25 | interactive: z.boolean(),
26 | workspace: z.string().min(1, 'Workspace directory is required'),
27 | env: z
28 | .array(
29 | z.object({ key: z.string().min(1, 'Variable name cannot be empty'), value: z.string() })
30 | )
31 | .optional(),
32 | }),
33 | mcp: z.object({
34 | enabled: z.boolean(),
35 | auto_start: z.boolean(),
36 | servers: z.array(mcpServerSchema).optional(),
37 | }),
38 | });
39 |
40 | export type FormSchema = z.infer;
41 |
42 | export const defaultMcpServer: z.infer = {
43 | name: '',
44 | enabled: true,
45 | command: '',
46 | args: '',
47 | env: [],
48 | };
49 |
--------------------------------------------------------------------------------
/src/stores/conversations.ts:
--------------------------------------------------------------------------------
1 | import { mergeIntoObservable, observable } from '@legendapp/state';
2 | import type { ChatConfig, ConversationResponse } from '@/types/api';
3 | import type { Message, StreamingMessage, ToolUse } from '@/types/conversation';
4 | import { demoConversations } from '@/democonversations';
5 | import type { DemoConversation } from '@/democonversations';
6 |
7 | export interface PendingTool {
8 | id: string;
9 | tooluse: ToolUse;
10 | }
11 |
12 | export interface ConversationState {
13 | // The conversation data
14 | data: ConversationResponse;
15 | // Whether this conversation is currently generating
16 | isGenerating: boolean;
17 | // Whether this conversation has an active event stream
18 | isConnected: boolean;
19 | // Any pending tool
20 | pendingTool: PendingTool | null;
21 | // Last received message
22 | lastMessage?: Message;
23 | // Whether to show the initial system message
24 | showInitialSystem: boolean;
25 | // The chat config
26 | chatConfig: ChatConfig | null;
27 | }
28 |
29 | // Central store for all conversations
30 | export const conversations$ = observable(new Map());
31 |
32 | // Currently selected conversation
33 | export const selectedConversation$ = observable(demoConversations[0].name);
34 |
35 | // Helper functions
36 | export function updateConversation(id: string, update: Partial) {
37 | if (!conversations$.get(id)) {
38 | // Initialize with defaults if conversation doesn't exist
39 | conversations$.set(id, {
40 | data: { log: [], logfile: id, branches: {} },
41 | isGenerating: false,
42 | isConnected: false,
43 | pendingTool: null,
44 | showInitialSystem: false,
45 | chatConfig: null,
46 | });
47 | }
48 | mergeIntoObservable(conversations$.get(id), update);
49 | }
50 |
51 | export function addMessage(id: string, message: Message | StreamingMessage) {
52 | const conv = conversations$.get(id);
53 | if (conv) {
54 | conv.data.log.push(message);
55 | }
56 | }
57 |
58 | export function setGenerating(id: string, isGenerating: boolean) {
59 | updateConversation(id, { isGenerating });
60 | }
61 |
62 | export function setConnected(id: string, isConnected: boolean) {
63 | updateConversation(id, { isConnected });
64 | }
65 |
66 | export function setPendingTool(id: string, toolId: string | null, tooluse: ToolUse | null) {
67 | updateConversation(id, {
68 | pendingTool: toolId && tooluse ? { id: toolId, tooluse } : null,
69 | });
70 | }
71 |
72 | // Initialize a new conversation in the store
73 | export function initConversation(id: string, data?: ConversationResponse) {
74 | const initial: ConversationState = {
75 | data: data || { log: [], logfile: id, branches: {} },
76 | isGenerating: false,
77 | isConnected: false,
78 | pendingTool: null,
79 | showInitialSystem: false,
80 | chatConfig: null,
81 | };
82 | conversations$.set(id, initial);
83 | }
84 |
85 | // Update conversation data in the store
86 | export function updateConversationData(id: string, data: ConversationResponse) {
87 | conversations$.get(id)?.data.set(data);
88 | }
89 |
90 | // Initialize conversations with their data
91 | export async function initializeConversations(
92 | api: { getConversation: (id: string) => Promise