first
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `Combobox.tsx` - Searchable select
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function ExperimentsLibrary() {
|
||||
const { experiments } = useExperimentsList();
|
||||
const dataController = useDataControl<Experiment>(experiments || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Experiment>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'name', label: 'Name', sortKey: 'name' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell experiment={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `useWebsockets` - WebSocket connection
|
||||
|
||||
## State Management
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/apps/dashboard/state/experiments/useExperiment.ts
|
||||
const { data, isLoading } = useExperiment(experimentId);
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useExperiment`, etc.) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useExperimentsList } from '@/state/experiments/useExperimentsList';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useExperimentsList>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## App-Specific Docs
|
||||
|
||||
- [Dashboard](./dashboard/CLAUDE.md) - Admin UI specifics
|
||||
- [Editor](./editor/CLAUDE.md) - Visual editor specifics
|
||||
- [Runtime](./runtime/CLAUDE.md) - Injected scripts specifics
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
import { LandingPage, AuthLayout } from './Screens/LandingPage';
|
||||
import { Home } from './Screens/Dashboard/Home';
|
||||
import { Profile } from './Screens/Dashboard/Profile';
|
||||
import { ClaudeSessions, ClaudeChat, OpenCodeChat, NewChat } from './Screens/Dashboard/Chat';
|
||||
import { Plans } from './Screens/Dashboard/Plans';
|
||||
import { Skills } from './Screens/Dashboard/Skills';
|
||||
import { Tasks } from './Screens/Dashboard/Tasks';
|
||||
import { Processes } from './Screens/Dashboard/Processes';
|
||||
import { TaskLogs } from './Screens/Dashboard/TaskLogs';
|
||||
import { SignoutScreen } from './Screens/Dashboard/SignoutScreen';
|
||||
import { Screen as Files } from 'plugins/FileBrowser/client';
|
||||
import { Screen as Terminal } from 'plugins/Terminal/client';
|
||||
import { AISettings } from './Screens/Dashboard/Settings/AISettings';
|
||||
import { ServerSettings } from './Screens/Dashboard/ServerSettings';
|
||||
import { Applications } from './Screens/Dashboard/Applications';
|
||||
import { OnboardingAdmin } from './Screens/Dashboard/OnboardingAdmin';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
|
||||
useInitialData();
|
||||
|
||||
if (isLoading || isServerSettingsLoading) return null;
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{!isAuthenticated && (
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/auth/*" element={<AuthLayout />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)}
|
||||
{isAuthenticated && !onboardingComplete && (
|
||||
<Routes>
|
||||
<Route path="/onboarding-admin" element={<OnboardingAdmin />} />
|
||||
<Route path="/auth/signout" element={<SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/onboarding-admin" replace />} />
|
||||
</Routes>
|
||||
)}
|
||||
{isAuthenticated && onboardingComplete && (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/settings/profile" element={<Profile />} />
|
||||
<Route path="/chat" element={<ClaudeSessions />} />
|
||||
<Route path="/chat/new" element={<NewChat />} />
|
||||
<Route path="/chat/:sessionId" element={<ClaudeChat />} />
|
||||
<Route path="/chat/opencode/new" element={<OpenCodeChat />} />
|
||||
<Route path="/chat/opencode/:sessionId" element={<OpenCodeChat />} />
|
||||
{plugins?.FileBrowser !== false && <Route path="/files" element={<Files />} />}
|
||||
{plugins?.Terminal !== false && <Route path="/terminal" element={<Terminal />} />}
|
||||
<Route path="/settings/ai" element={<AISettings />} />
|
||||
<Route path="/settings/server" element={<ServerSettings />} />
|
||||
<Route path="/settings/applications" element={<Applications />} />
|
||||
<Route path="/plans" element={<Plans />} />
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/tasks" element={<Tasks />} />
|
||||
<Route path="/processes" element={<Processes />} />
|
||||
<Route path="/task-logs" element={<TaskLogs />} />
|
||||
<Route path="/auth/signout" element={<SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
|
||||
type AppStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
running: boolean | null;
|
||||
hasInstall: boolean;
|
||||
hasUpdate: boolean;
|
||||
manualInstallCommand: string | null;
|
||||
manualUpdateCommand: string | null;
|
||||
};
|
||||
|
||||
const CopyCommand = ({ command }: { command: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Applications = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['APPLICATIONS'],
|
||||
queryFn: () => client.get<AppStatus[]>('/server-settings/applications'),
|
||||
});
|
||||
|
||||
const runAction = async (id: string, action: 'install' | 'update') => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await client.post<AppStatus>(`/server-settings/applications/${id}/${action}`);
|
||||
await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] });
|
||||
toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `${action} failed`;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getManualCommand = (app: AppStatus): string | null => {
|
||||
if (!app.installed) return app.manualInstallCommand;
|
||||
return app.manualUpdateCommand ?? app.manualInstallCommand;
|
||||
};
|
||||
|
||||
const hasAutoAction = (app: AppStatus): boolean => {
|
||||
if (!app.installed) return app.hasInstall && !app.manualInstallCommand;
|
||||
return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<h2 className="text-lg font-bold text-duck-dark mb-1">Applications</h2>
|
||||
<p className="text-sm text-duck-dark/60 mb-6">System tools and dependencies used by Officer.dev</p>
|
||||
|
||||
{isLoading && <p className="text-sm text-duck-dark/50">Checking applications...</p>}
|
||||
|
||||
{apps && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{apps.map((app: AppStatus) => {
|
||||
const manualCmd = getManualCommand(app);
|
||||
const canAutoRun = hasAutoAction(app);
|
||||
|
||||
return (
|
||||
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{app.name}</span>
|
||||
{app.installed && (
|
||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
||||
{app.version}
|
||||
</span>
|
||||
)}
|
||||
{!app.installed && (
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">
|
||||
Not installed
|
||||
</span>
|
||||
)}
|
||||
{app.running !== null && (
|
||||
<Circle
|
||||
className={`h-2.5 w-2.5 ${app.running ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 mt-0.5">{app.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{canAutoRun && !app.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
disabled={actionInProgress === app.id}
|
||||
onClick={() => runAction(app.id, 'install')}
|
||||
>
|
||||
{actionInProgress === app.id ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actionInProgress === app.id ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{canAutoRun && app.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionInProgress === app.id}
|
||||
onClick={() => runAction(app.id, 'update')}
|
||||
>
|
||||
{actionInProgress === app.id ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{actionInProgress === app.id ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manualCmd && (
|
||||
<div className="mt-2 text-xs text-duck-dark/50">
|
||||
{app.installed ? 'Update' : 'Install'} manually:
|
||||
<CopyCommand command={manualCmd} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { KeyboardEvent, RefObject } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { FileText, Image, Link, Loader2, Paperclip, Send, Square, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from '../types';
|
||||
import type { Attachment } from './index';
|
||||
import { Settings } from './Settings';
|
||||
|
||||
type InputAreaProps = {
|
||||
input: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onKeyDown: (ev: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
isGenerating: boolean;
|
||||
isConnected: boolean;
|
||||
commandFeedback: string | null;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
provider: 'claude' | 'opencode';
|
||||
messages: ChatMessage[];
|
||||
onProviderChange?: (provider: 'claude' | 'opencode') => void;
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
model: string | null;
|
||||
attachments: Attachment[];
|
||||
onAttachWebpage: (url: string) => void;
|
||||
onAttachImage: (file: File) => void;
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
};
|
||||
|
||||
export const InputArea = ({
|
||||
input,
|
||||
onInputChange,
|
||||
onKeyDown,
|
||||
onSend,
|
||||
onStop,
|
||||
isGenerating,
|
||||
isConnected,
|
||||
commandFeedback,
|
||||
textareaRef,
|
||||
provider,
|
||||
messages,
|
||||
onProviderChange,
|
||||
availableModels,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
model,
|
||||
attachments,
|
||||
onAttachWebpage,
|
||||
onAttachImage,
|
||||
onRemoveAttachment,
|
||||
}: InputAreaProps) => {
|
||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUrlSubmit = () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
onAttachWebpage(url);
|
||||
setUrlInput('');
|
||||
setUrlDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-duck-dark/10 bg-white/60 p-3">
|
||||
{commandFeedback && (
|
||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||
>
|
||||
{a.loading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : a.type === 'image' && a.dataUrl ? (
|
||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||
) : a.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<Link className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveAttachment(i)}
|
||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 h-9 w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="z-[800]">
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) onAttachImage(file);
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => onInputChange(ev.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(ev) => {
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
ev.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) onAttachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-duck-dark/20 bg-white/80 px-3 py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-9 w-9 cursor-pointer">
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
className="shrink-0 h-9 w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Settings
|
||||
provider={provider}
|
||||
messages={messages}
|
||||
onProviderChange={onProviderChange}
|
||||
availableModels={availableModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
model={model}
|
||||
isConnected={isConnected}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
|
||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attach Webpage</DialogTitle>
|
||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleUrlSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUrlSubmit}
|
||||
disabled={!urlInput.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { MessageBubble, StreamingBubble } from '../MessageBubble';
|
||||
|
||||
type MessageListProps = {
|
||||
messages: ChatMessage[];
|
||||
streamingText: string;
|
||||
isGenerating: boolean;
|
||||
showJumpToBottom: boolean;
|
||||
onJumpToBottom: () => void;
|
||||
onQuestionAnswer?: (text: string) => void;
|
||||
scrollViewportRef: RefObject<HTMLDivElement | null>;
|
||||
bottomRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export const MessageList = ({
|
||||
messages,
|
||||
streamingText,
|
||||
isGenerating,
|
||||
showJumpToBottom,
|
||||
onJumpToBottom,
|
||||
onQuestionAnswer,
|
||||
scrollViewportRef,
|
||||
bottomRef,
|
||||
}: MessageListProps) => (
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
|
||||
<div className="p-4 space-y-3">
|
||||
{messages.length === 0 && !isGenerating && (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm">
|
||||
Send a message to start
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} onAnswer={onQuestionAnswer} />
|
||||
))}
|
||||
{isGenerating && <StreamingBubble text={streamingText} />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showJumpToBottom && (
|
||||
<button
|
||||
onClick={onJumpToBottom}
|
||||
className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-duck-teal text-white rounded-full p-1.5 shadow-lg hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Link } from 'react-router';
|
||||
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
|
||||
|
||||
type SessionBarProps = {
|
||||
listPath: string;
|
||||
provider: 'claude' | 'opencode';
|
||||
sessionTitle: string | undefined;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
fullscreen: boolean;
|
||||
onArchive: (() => void) | undefined;
|
||||
onDelete: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
export const SessionBar = ({
|
||||
listPath,
|
||||
provider,
|
||||
sessionTitle,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
fullscreen,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onToggleFullscreen,
|
||||
}: SessionBarProps) => (
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
{provider === 'claude' && onArchive && (
|
||||
<button
|
||||
onClick={onArchive}
|
||||
className="p-1 text-duck-dark/40 hover:text-duck-teal transition-colors cursor-pointer"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 truncate px-3">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50">
|
||||
{!isConnected ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||
) : isGenerating ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||
) : (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { OpenCodeModelPicker } from '../OpenCodeModelPicker';
|
||||
|
||||
type SettingsProps = {
|
||||
provider: 'claude' | 'opencode';
|
||||
messages: ChatMessage[];
|
||||
onProviderChange?: (provider: 'claude' | 'opencode') => void;
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
model: string | null;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
};
|
||||
|
||||
export const Settings = ({
|
||||
provider,
|
||||
messages,
|
||||
onProviderChange,
|
||||
availableModels,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
model,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
}: SettingsProps) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const fallbackModelId = availableModels[0]?.id ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
{messages.length > 0 ? (
|
||||
<span className="rounded-md bg-duck-dark/80 px-3 py-1 text-xs font-medium text-white">
|
||||
{provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
|
||||
{(['claude', 'opencode'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onProviderChange?.(value)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
provider === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
|
||||
} ${!onProviderChange ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-duck-dark/50">
|
||||
{availableModels.length > 0 && provider === 'opencode' ? (
|
||||
<OpenCodeModelPicker
|
||||
models={availableModels}
|
||||
selectedModel={selectedModel ?? fallbackModelId}
|
||||
onSelect={onModelChange}
|
||||
isConnected={isConnected}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
) : availableModels.length > 0 ? (
|
||||
<Select
|
||||
value={selectedModel ?? fallbackModelId ?? undefined}
|
||||
onValueChange={(v) => onModelChange(v)}
|
||||
disabled={isGenerating || !isConnected}
|
||||
>
|
||||
<SelectTrigger className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[800]" side="top">
|
||||
{availableModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span>{model ?? (provider === 'claude' ? 'Claude' : 'OpenCode')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useSessions } from '@/state/useSessions';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
|
||||
import type { useClaude } from '../useClaude';
|
||||
import { useSlashCommands } from '@/state/useSlashCommands';
|
||||
import { Card } from '@/components/Card';
|
||||
import { SessionBar } from './SessionBar';
|
||||
import { EmbeddableChat } from '../EmbeddableChat';
|
||||
|
||||
export type { Attachment } from '../EmbeddableChat';
|
||||
|
||||
type ChatPanelProps = {
|
||||
chat: ReturnType<typeof useClaude>;
|
||||
provider?: 'claude' | 'opencode';
|
||||
availableModels?: ModelOption[];
|
||||
onProviderChange?: (provider: 'claude' | 'opencode') => void;
|
||||
};
|
||||
|
||||
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onProviderChange }: ChatPanelProps) => {
|
||||
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const initialSentRef = useRef(false);
|
||||
|
||||
const claudeSessions = useSessions();
|
||||
const opencodeSessions = useOpenCodeSessions();
|
||||
const { archiveSession, deleteSession } =
|
||||
provider === 'claude'
|
||||
? claudeSessions
|
||||
: { archiveSession: undefined, deleteSession: opencodeSessions.deleteSession };
|
||||
const sessions = provider === 'claude' ? claudeSessions.sessions : opencodeSessions.sessions;
|
||||
const slashCommands = useSlashCommands({ sessionId });
|
||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
||||
const listPath = '/chat';
|
||||
|
||||
// Capture prefill input from location.state (one-time, before first render completes)
|
||||
const locationState = location.state as {
|
||||
initialMessage?: string;
|
||||
prefillInput?: string;
|
||||
model?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
attachmentIds?: string[];
|
||||
images?: { filename: string; dataUrl: string }[];
|
||||
} | null;
|
||||
const initialPrefill = useRef(locationState?.prefillInput ?? '');
|
||||
|
||||
const handleBeforeSend = async (text: string) => {
|
||||
if (text.startsWith('/')) {
|
||||
const result = await slashCommands.execute(text);
|
||||
if (result.handled) {
|
||||
setCommandFeedback(result.feedback);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
setCommandFeedback(null);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Auto-send initial message from Home launcher
|
||||
useEffect(() => {
|
||||
const state = location.state as typeof locationState;
|
||||
if (!state || initialSentRef.current) return;
|
||||
if (state.prefillInput) {
|
||||
initialSentRef.current = true;
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
if (!state.initialMessage || !isConnected) return;
|
||||
initialSentRef.current = true;
|
||||
if (state.model) setSelectedModel(state.model);
|
||||
sendPrompt(state.initialMessage, state.attachmentIds, state.images, state.cwd);
|
||||
// Clear the location state so refresh doesn't re-send
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
}, [isConnected, location.state]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${
|
||||
fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
|
||||
}`}
|
||||
>
|
||||
<SessionBar
|
||||
listPath={listPath}
|
||||
provider={provider}
|
||||
sessionTitle={sessionTitle}
|
||||
isConnected={isConnected}
|
||||
isGenerating={isGenerating}
|
||||
fullscreen={fullscreen}
|
||||
onArchive={
|
||||
archiveSession && sessionId
|
||||
? async () => {
|
||||
await archiveSession(sessionId);
|
||||
navigate(listPath);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDelete={async () => {
|
||||
if (!sessionId) return;
|
||||
await deleteSession(sessionId);
|
||||
navigate(listPath);
|
||||
}}
|
||||
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
||||
/>
|
||||
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
provider={provider}
|
||||
availableModels={availableModels}
|
||||
onProviderChange={onProviderChange}
|
||||
onBeforeSend={handleBeforeSend}
|
||||
commandFeedback={commandFeedback}
|
||||
defaultInput={initialPrefill.current}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { useClaude } from './useClaude';
|
||||
import { MessageList } from './ChatPanel/MessageList';
|
||||
import { InputArea } from './ChatPanel/InputArea';
|
||||
|
||||
export type Attachment =
|
||||
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
||||
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
||||
|
||||
type EmbeddableChatProps = {
|
||||
chat: ReturnType<typeof useClaude>;
|
||||
provider?: 'claude' | 'opencode';
|
||||
availableModels?: ModelOption[];
|
||||
onProviderChange?: (provider: 'claude' | 'opencode') => void;
|
||||
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
||||
commandFeedback?: string | null;
|
||||
defaultInput?: string;
|
||||
className?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
autoSend?: boolean;
|
||||
};
|
||||
|
||||
export const EmbeddableChat = ({
|
||||
chat,
|
||||
provider = 'claude',
|
||||
availableModels = [],
|
||||
onProviderChange,
|
||||
onBeforeSend,
|
||||
commandFeedback = null,
|
||||
defaultInput = '',
|
||||
className,
|
||||
cwd,
|
||||
autoSend = false,
|
||||
}: EmbeddableChatProps) => {
|
||||
const {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
} = chat;
|
||||
|
||||
const client = useClient();
|
||||
const [input, setInput] = useState(defaultInput);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const userScrolledRef = useRef(false);
|
||||
|
||||
const handleAttachWebpage = async (url: string) => {
|
||||
const idx = attachments.length;
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
||||
]);
|
||||
|
||||
try {
|
||||
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
||||
url,
|
||||
sessionId: sessionId ?? undefined,
|
||||
provider,
|
||||
});
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, i) =>
|
||||
i === idx
|
||||
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
toast.error('Failed to scrape webpage');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachImage = async (file: File) => {
|
||||
const idx = attachments.length;
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
||||
]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (sessionId) formData.append('sessionId', sessionId);
|
||||
formData.append('provider', provider);
|
||||
|
||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, i) =>
|
||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
toast.error('Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isGenerating) return;
|
||||
|
||||
if (onBeforeSend) {
|
||||
const handled = await onBeforeSend(text);
|
||||
if (handled) {
|
||||
setInput('');
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend attachment content to the prompt
|
||||
let prompt = text;
|
||||
const ids: string[] = [];
|
||||
const images: { filename: string; dataUrl: string }[] = [];
|
||||
for (const a of attachments) {
|
||||
if (a.loading) continue;
|
||||
if (a.type === 'webpage' && a.content) {
|
||||
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
||||
} else if (a.type === 'image' && a.dataUrl) {
|
||||
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
||||
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
||||
}
|
||||
ids.push(a.attachmentId);
|
||||
}
|
||||
|
||||
// On first message (no sessionId), include attachmentIds so server can relocate tmp files
|
||||
const cwdForFirst = !sessionId ? cwd : undefined;
|
||||
sendPrompt(
|
||||
prompt,
|
||||
!sessionId && ids.length > 0 ? ids : undefined,
|
||||
images.length > 0 ? images : undefined,
|
||||
cwdForFirst,
|
||||
);
|
||||
setAttachments([]);
|
||||
setInput('');
|
||||
userScrolledRef.current = false;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
}, [input]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (!userScrolledRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, streamingText]);
|
||||
|
||||
// Detect user scrolling up
|
||||
useEffect(() => {
|
||||
const viewport = scrollViewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
|
||||
userScrolledRef.current = !atBottom;
|
||||
setShowJumpToBottom(!atBottom);
|
||||
};
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll);
|
||||
return () => viewport.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const jumpToBottom = () => {
|
||||
userScrolledRef.current = false;
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Focus textarea on mount
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Auto-send first message when autoSend is enabled
|
||||
const autoSentRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
|
||||
autoSentRef.current = true;
|
||||
handleSend();
|
||||
}
|
||||
}, [autoSend, isConnected, messages.length, input]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${className ?? ''}`}>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingText={streamingText}
|
||||
isGenerating={isGenerating}
|
||||
showJumpToBottom={showJumpToBottom}
|
||||
onJumpToBottom={jumpToBottom}
|
||||
onQuestionAnswer={(text) => sendPrompt(text)}
|
||||
scrollViewportRef={scrollViewportRef}
|
||||
bottomRef={bottomRef}
|
||||
/>
|
||||
|
||||
<InputArea
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
onStop={stopGeneration}
|
||||
isGenerating={isGenerating}
|
||||
isConnected={isConnected}
|
||||
commandFeedback={commandFeedback}
|
||||
textareaRef={textareaRef}
|
||||
provider={provider}
|
||||
messages={messages}
|
||||
onProviderChange={onProviderChange}
|
||||
availableModels={availableModels}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
model={model}
|
||||
attachments={attachments}
|
||||
onAttachWebpage={handleAttachWebpage}
|
||||
onAttachImage={handleAttachImage}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import type { ChatMessage } from './types';
|
||||
import { ToolActivity } from './ToolActivity';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
|
||||
type MessageBubbleProps = {
|
||||
message: ChatMessage;
|
||||
onAnswer?: (text: string) => void;
|
||||
};
|
||||
|
||||
export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
const text = formatText(message.text);
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-duck-dark">
|
||||
{message.images?.map((img, i) => (
|
||||
<img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
|
||||
))}
|
||||
<div className="whitespace-pre-wrap">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'assistant':
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'tool':
|
||||
if (message.toolName === 'question' && onAnswer) {
|
||||
return <QuestionActivity message={message} onAnswer={onAnswer} />;
|
||||
}
|
||||
return <ToolActivity message={message} />;
|
||||
|
||||
case 'result':
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
Done · ${message.costUsd.toFixed(3)} · {(message.durationMs / 1000).toFixed(1)}s · {message.numTurns} turn
|
||||
{message.numTurns !== 1 ? 's' : ''}
|
||||
{message.isError ? ' (with errors)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-700">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function formatText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return '';
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
type StreamingBubbleProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import { useRecentModels } from '@/state/useRecentModels';
|
||||
|
||||
type OpenCodeModelPickerProps = {
|
||||
models: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onSelect: (modelId: string) => void;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
};
|
||||
|
||||
export const OpenCodeModelPicker = ({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
}: OpenCodeModelPickerProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { recents, addRecent } = useRecentModels();
|
||||
|
||||
const selected = models.find((m) => m.id === selectedModel);
|
||||
|
||||
const groupedByProvider = useMemo(() => {
|
||||
const groups: Record<string, ModelOption[]> = {};
|
||||
for (const m of models) {
|
||||
const provider = m.provider ?? 'Other';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider].push(m);
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, items]) => ({
|
||||
provider,
|
||||
models: items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
const handleSelect = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
onSelect(model.id);
|
||||
addRecent(model);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={isGenerating || !isConnected}
|
||||
className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer hover:bg-transparent hover:text-duck-dark/70"
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
{selected.name}
|
||||
{selected.provider && <span className="hidden md:inline"> ({selected.provider})</span>}
|
||||
</>
|
||||
) : (
|
||||
'select model'
|
||||
)}
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-[800] w-[320px] p-0" align="end" side="top">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList className="max-h-[400px]">
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
{recents.length > 0 && (
|
||||
<CommandGroup heading="Recent">
|
||||
{recents.map((m) => (
|
||||
<CommandItem
|
||||
key={`recent-${m.id}`}
|
||||
value={`${m.name} ${m.provider ?? ''}`}
|
||||
onSelect={() => handleSelect(m.id)}
|
||||
>
|
||||
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate font-bold">{m.name}</span>
|
||||
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{groupedByProvider.map(({ provider, models: providerModels }) => (
|
||||
<CommandGroup key={provider} heading={provider}>
|
||||
{providerModels.map((m) => (
|
||||
<CommandItem key={m.id} value={`${m.name} ${m.provider ?? ''}`} onSelect={() => handleSelect(m.id)}>
|
||||
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate font-bold">{m.name}</span>
|
||||
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageCircleQuestion, Check } from 'lucide-react';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
type QuestionOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type Question = {
|
||||
question: string;
|
||||
header: string;
|
||||
multiple: boolean;
|
||||
options: QuestionOption[];
|
||||
};
|
||||
|
||||
type QuestionActivityProps = {
|
||||
message: ToolMessage;
|
||||
onAnswer: (text: string) => void;
|
||||
};
|
||||
|
||||
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
|
||||
const [otherText, setOtherText] = useState('');
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [answeredText, setAnsweredText] = useState('');
|
||||
|
||||
const input = message.toolInput as { questions?: Question[] };
|
||||
const questions = input.questions;
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
const pending = message.output === undefined;
|
||||
|
||||
const handleSelect = (question: Question, label: string) => {
|
||||
if (answered || !pending) return;
|
||||
|
||||
if (question.multiple) {
|
||||
setSelectedOptions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const text = label;
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMultiple = () => {
|
||||
if (selectedOptions.size === 0 || answered || !pending) return;
|
||||
const text = Array.from(selectedOptions).join(', ');
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
};
|
||||
|
||||
const handleSubmitOther = () => {
|
||||
const text = otherText.trim();
|
||||
if (!text || answered || !pending) return;
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
};
|
||||
|
||||
const isDisabled = answered || !pending;
|
||||
|
||||
return (
|
||||
<div className="my-1 space-y-3">
|
||||
{questions.map((q, qi) => (
|
||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-white/90 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
|
||||
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-sm text-duck-dark font-medium">{q.question}</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{q.options.map((opt) => {
|
||||
const isSelected = answered
|
||||
? answeredText === opt.label || answeredText.split(', ').includes(opt.label)
|
||||
: selectedOptions.has(opt.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleSelect(q, opt.label)}
|
||||
disabled={isDisabled}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'border-duck-teal bg-duck-teal/10 text-duck-dark'
|
||||
: isDisabled
|
||||
? 'border-duck-dark/10 bg-duck-dark/5 text-duck-dark/40 cursor-not-allowed'
|
||||
: 'border-duck-dark/15 hover:border-duck-teal/40 hover:bg-duck-teal/5 text-duck-dark cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
|
||||
<div>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
{opt.description && <span className="text-duck-dark/50 ml-1.5">— {opt.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* "Other" free-text option */}
|
||||
{!isDisabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={otherText}
|
||||
onChange={(ev) => setOtherText(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmitOther();
|
||||
}
|
||||
}}
|
||||
placeholder="Other..."
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-duck-dark/15 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:border-duck-teal/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmitOther}
|
||||
disabled={!otherText.trim()}
|
||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button for multi-select */}
|
||||
{q.multiple && !isDisabled && (
|
||||
<button
|
||||
onClick={handleSubmitMultiple}
|
||||
disabled={selectedOptions.size === 0}
|
||||
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Submit ({selectedOptions.size} selected)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Answered indicator */}
|
||||
{isDisabled && answeredText && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>Answered: {answeredText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useSessions } from '@/state/useSessions';
|
||||
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
|
||||
|
||||
type Filter = 'all' | 'claude' | 'opencode';
|
||||
|
||||
export const SessionList = () => {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const claude = useSessions();
|
||||
const opencode = useOpenCodeSessions();
|
||||
|
||||
const merged = useMemo(
|
||||
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
||||
[claude.sessions, opencode.sessions],
|
||||
);
|
||||
|
||||
const filtered = filter === 'all' ? merged : merged.filter((s) => s.provider === filter);
|
||||
|
||||
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
|
||||
if (provider === 'claude') claude.deleteSession(id);
|
||||
else opencode.deleteSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center p-4 md:p-6">
|
||||
<Card className="w-full max-w-2xl flex flex-col gap-4 h-full p-4 md:p-6 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-duck-dark/80">Sessions</h2>
|
||||
<Button asChild className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer gap-2">
|
||||
<Link to="/chat/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Chat
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Radio filter */}
|
||||
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
|
||||
{(['all', 'claude', 'opencode'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setFilter(value)}
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
||||
filter === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
|
||||
}`}
|
||||
>
|
||||
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-16 text-duck-dark/30 text-sm">No sessions yet. Start a new chat!</div>
|
||||
)}
|
||||
|
||||
{filtered.map((session) => (
|
||||
<div
|
||||
key={`${session.provider}-${session.id}`}
|
||||
className="group flex items-center gap-3 rounded-lg border border-duck-dark/10 bg-white/80 hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<Link
|
||||
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
|
||||
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-duck-dark/80 truncate">{session.title}</div>
|
||||
<div className="text-xs text-duck-dark/40">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<span
|
||||
className={`ml-2 text-xs font-medium ${
|
||||
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
|
||||
}`}
|
||||
>
|
||||
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-duck-dark/25">{session.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(session.id, session.provider)}
|
||||
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
type ToolActivityProps = {
|
||||
message: ToolMessage;
|
||||
};
|
||||
|
||||
const toolIcons: Record<string, typeof FileText> = {
|
||||
Read: FileText,
|
||||
Edit: Pencil,
|
||||
Write: Pencil,
|
||||
Bash: Terminal,
|
||||
Grep: Search,
|
||||
Glob: Search,
|
||||
WebFetch: Globe,
|
||||
WebSearch: Globe,
|
||||
};
|
||||
|
||||
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
|
||||
switch (toolName) {
|
||||
case 'Read':
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
return (toolInput.file_path as string) ?? '';
|
||||
case 'Bash':
|
||||
return truncate((toolInput.command as string) ?? '', 80);
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return (toolInput.pattern as string) ?? '';
|
||||
case 'WebFetch':
|
||||
return (toolInput.url as string) ?? '';
|
||||
case 'WebSearch':
|
||||
return (toolInput.query as string) ?? '';
|
||||
default:
|
||||
return (
|
||||
Object.values(toolInput)
|
||||
.find((v) => typeof v === 'string')
|
||||
?.toString() ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const Icon = toolIcons[message.toolName] ?? Wrench;
|
||||
const summary = getToolSummary(message.toolName, message.toolInput);
|
||||
const pending = message.output === undefined;
|
||||
const isError = message.isError === true;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-md hover:bg-duck-dark/5 transition-colors cursor-pointer text-sm"
|
||||
>
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
|
||||
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
|
||||
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
|
||||
<span className="shrink-0">
|
||||
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
|
||||
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
|
||||
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="ml-7 mt-1 space-y-2 text-xs">
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
|
||||
{message.toolName === 'Bash' ? (
|
||||
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
|
||||
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className="font-mono whitespace-pre-wrap break-all text-duck-dark/70">
|
||||
{Object.entries(message.toolInput)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message.output !== undefined && (
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
|
||||
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ToolOutputProps = {
|
||||
toolName: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxLines = 20;
|
||||
const lines = output.split('\n');
|
||||
const needsTruncation = lines.length > maxLines;
|
||||
const displayText = expanded ? output : lines.slice(0, maxLines).join('\n');
|
||||
|
||||
const isBash = toolName === 'Bash';
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre
|
||||
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
||||
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 text-red-700' : 'text-duck-dark/70'
|
||||
}`}
|
||||
>
|
||||
{displayText}
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-duck-teal hover:underline text-[11px] mt-1 cursor-pointer"
|
||||
>
|
||||
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { useClaude } from './useClaude';
|
||||
import { useOpenCode } from './useOpenCode';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { SessionList } from './SessionList';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import type { SessionEntry } from './types';
|
||||
|
||||
export const ClaudeSessions = () => {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<SessionList />
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClaudeChat = () => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const sessions = queryClient.getQueryData<SessionEntry[]>(['SESSIONS']);
|
||||
const sessionModel = sessions?.find((s) => s.id === sessionId)?.model;
|
||||
const claude = useClaude(sessionId, sessionModel);
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
|
||||
return (
|
||||
<DashboardLayout mobileFull>
|
||||
<div className="flex items-center justify-center h-full md:p-4">
|
||||
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
|
||||
<ChatPanel chat={claude} provider="claude" availableModels={claudeModels} />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpenCodeChat = () => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const sessions = queryClient.getQueryData<SessionEntry[]>(['OC_SESSIONS']);
|
||||
const sessionModel = sessions?.find((s) => s.id === sessionId)?.model;
|
||||
const opencode = useOpenCode(sessionId, sessionModel);
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
|
||||
return (
|
||||
<DashboardLayout mobileFull>
|
||||
<div className="flex items-center justify-center h-full md:p-4">
|
||||
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
|
||||
<ChatPanel chat={opencode} provider="opencode" availableModels={openCodeModels} />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ClaudeNewChatInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
|
||||
const claude = useClaude();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
return (
|
||||
<ChatPanel chat={claude} provider="claude" availableModels={claudeModels} onProviderChange={onProviderChange} />
|
||||
);
|
||||
};
|
||||
|
||||
const OpenCodeNewChatInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
|
||||
const opencode = useOpenCode();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
return (
|
||||
<ChatPanel
|
||||
chat={opencode}
|
||||
provider="opencode"
|
||||
availableModels={openCodeModels}
|
||||
onProviderChange={onProviderChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewChat = () => {
|
||||
const [provider, setProvider] = useState<'claude' | 'opencode'>('claude');
|
||||
|
||||
return (
|
||||
<DashboardLayout mobileFull>
|
||||
<div className="flex items-center justify-center h-full md:p-4">
|
||||
<div className="flex flex-col w-full h-full md:w-3/4 md:h-3/4 lg:w-1/2 lg:h-1/2">
|
||||
{provider === 'claude' ? (
|
||||
<ClaudeNewChatInner key="claude" onProviderChange={setProvider} />
|
||||
) : (
|
||||
<OpenCodeNewChatInner key="opencode" onProviderChange={setProvider} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export type SessionEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
provider: 'claude' | 'opencode';
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessage =
|
||||
| { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] }
|
||||
| { role: 'assistant'; text: string }
|
||||
| {
|
||||
role: 'tool';
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
toolUseId: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
||||
| { role: 'error'; text: string };
|
||||
|
||||
export type TaskInfo = {
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
};
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: 'session:init'; sessionId: string; model: string }
|
||||
| { type: 'assistant:text'; text: string }
|
||||
| { type: 'assistant:partial'; text: string }
|
||||
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
|
||||
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
|
||||
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useSessions } from '@/state/useSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
|
||||
type ResourceChatStorage = {
|
||||
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
|
||||
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type UseClaudeOptions = {
|
||||
replaceUrl?: boolean;
|
||||
storage?: ResourceChatStorage;
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
export const useClaude = (initialSessionId?: string, initialModel?: string | null, options?: UseClaudeOptions) => {
|
||||
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const { getMessages, saveMessages } = useSessions();
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/harness/claudecode/ws?token=${token}`;
|
||||
|
||||
const flushStreaming = () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setStreamingText(streamingRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const commitStreaming = () => {
|
||||
if (!streamingRef.current) return;
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as ServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
setModel(msg.model);
|
||||
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'assistant:partial':
|
||||
streamingRef.current += msg.text;
|
||||
flushStreaming();
|
||||
break;
|
||||
|
||||
case 'assistant:text':
|
||||
if (streamingRef.current) {
|
||||
commitStreaming();
|
||||
} else {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool:use':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'result',
|
||||
costUsd: msg.costUsd,
|
||||
durationMs: msg.durationMs,
|
||||
numTurns: msg.numTurns,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]);
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
commitStreaming();
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
|
||||
|
||||
// Load messages from server on mount when resuming a session
|
||||
useEffect(() => {
|
||||
if (storage) {
|
||||
storage
|
||||
.load()
|
||||
.then(({ sessionId: sid, messages: msgs }) => {
|
||||
if (sid) {
|
||||
sessionIdRef.current = sid;
|
||||
setSessionId(sid);
|
||||
}
|
||||
if (msgs.length > 0) setMessages(msgs);
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!initialSessionId) return;
|
||||
getMessages(initialSessionId)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [initialSessionId]);
|
||||
|
||||
// Debounced save messages to server
|
||||
useEffect(() => {
|
||||
if (!sessionIdRef.current || messages.length === 0) return;
|
||||
|
||||
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||
|
||||
const sid = sessionIdRef.current;
|
||||
const snapshot = messages;
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
if (storage) {
|
||||
storage.save(sid, snapshot).catch(() => {});
|
||||
} else {
|
||||
saveMessages(sid, snapshot).catch(() => {});
|
||||
}
|
||||
saveTimerRef.current = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current !== null) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
// Clean up RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendPrompt = (
|
||||
text: string,
|
||||
attachmentIds?: string[],
|
||||
images?: { filename: string; dataUrl: string }[],
|
||||
cwd?: { root?: string; path: string },
|
||||
) => {
|
||||
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
|
||||
setIsGenerating(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
|
||||
// Parse dataUrls into { mediaType, data } for the server
|
||||
const imageData = images
|
||||
?.map((img) => {
|
||||
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
return match ? { mediaType: match[1], data: match[2] } : null;
|
||||
})
|
||||
.filter((x): x is { mediaType: string; data: string } => x !== null);
|
||||
|
||||
send({
|
||||
type: 'chat',
|
||||
prompt: text,
|
||||
sessionId: sessionIdRef.current,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
...(resourceChatDir ? { resourceChatDir } : {}),
|
||||
...(taskInfo ? { taskInfo } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
send({ type: 'stop' });
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
|
||||
|
||||
type UseOpenCodeOptions = {
|
||||
replaceUrl?: boolean;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
export const useOpenCode = (initialSessionId?: string, initialModel?: string | null, options?: UseOpenCodeOptions) => {
|
||||
const { replaceUrl = true, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
const selectedModelRef = useRef<string | null>(initialModel ?? null);
|
||||
|
||||
const updateSelectedModel = (value: string | null) => {
|
||||
selectedModelRef.current = value;
|
||||
setSelectedModel(value);
|
||||
};
|
||||
|
||||
const { getMessages } = useOpenCodeSessions();
|
||||
const { settings } = useSettings();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/harness/opencode/ws?token=${token}`;
|
||||
|
||||
const flushStreaming = () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setStreamingText(streamingRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const commitStreaming = () => {
|
||||
// Cancel any pending RAF to prevent stale reads of cleared streamingRef
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
if (!streamingRef.current) return;
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as ServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
setModel(msg.model);
|
||||
if (replaceUrl) window.history.replaceState(null, '', `/chat/opencode/${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'assistant:partial':
|
||||
streamingRef.current += msg.text;
|
||||
flushStreaming();
|
||||
break;
|
||||
|
||||
case 'assistant:text':
|
||||
// Server sends the final complete text — discard streaming and use this instead
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
|
||||
break;
|
||||
|
||||
case 'tool:use':
|
||||
commitStreaming();
|
||||
setMessages((prev) => {
|
||||
const existing = prev.find((m) => m.role === 'tool' && m.toolUseId === msg.toolUseId);
|
||||
if (existing) {
|
||||
// Update input (running event sends actual input after pending)
|
||||
return prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId
|
||||
? { ...m, toolName: msg.toolName, toolInput: msg.toolInput }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
|
||||
];
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'result',
|
||||
costUsd: msg.costUsd,
|
||||
durationMs: msg.durationMs,
|
||||
numTurns: msg.numTurns,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]);
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
commitStreaming();
|
||||
setIsGenerating(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
|
||||
|
||||
// Load messages from OpenCode on mount when resuming a session
|
||||
useEffect(() => {
|
||||
if (!initialSessionId) return;
|
||||
getMessages(initialSessionId)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [initialSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
|
||||
// Seed default model for OpenCode if none selected
|
||||
useEffect(() => {
|
||||
if (selectedModel) return;
|
||||
if (settings.chat.defaultProvider !== 'opencode' || !settings.chat.defaultModel) return;
|
||||
if (!openCodeModels.some((m) => m.id === settings.chat.defaultModel)) return;
|
||||
updateSelectedModel(settings.chat.defaultModel);
|
||||
}, [openCodeModels, selectedModel, settings.chat.defaultModel, settings.chat.defaultProvider]);
|
||||
|
||||
// Clean up RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendPrompt = (text: string, attachmentIds?: string[], images?: { filename: string; dataUrl: string }[]) => {
|
||||
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
|
||||
setIsGenerating(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
|
||||
// Parse dataUrls into { mediaType, data } for the server
|
||||
const imageData = images
|
||||
?.map((img) => {
|
||||
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
return match ? { mediaType: match[1], data: match[2] } : null;
|
||||
})
|
||||
.filter((x): x is { mediaType: string; data: string } => x !== null);
|
||||
|
||||
const modelId = selectedModelRef.current;
|
||||
const selectedOption = modelId ? openCodeModels.find((m) => m.id === modelId) : undefined;
|
||||
const payload = {
|
||||
type: 'chat',
|
||||
prompt: text,
|
||||
sessionId: sessionIdRef.current,
|
||||
...(modelId
|
||||
? {
|
||||
model: {
|
||||
modelID: modelId,
|
||||
...(selectedOption?.providerId ? { providerID: selectedOption.providerId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
...(taskInfo ? { taskInfo } : {}),
|
||||
};
|
||||
console.log('[opencode-ui] ws send', payload);
|
||||
send(payload);
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
send({ type: 'stop' });
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
setSelectedModel: updateSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type DockItem = {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DockProps = {
|
||||
items: DockItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ICON_SIZE = 48;
|
||||
const ICON_GAP = 24;
|
||||
const DOCK_PADDING = 12;
|
||||
const MAX_SCALE = 1.5;
|
||||
const MAX_DISTANCE = 150;
|
||||
|
||||
const getScale = (mouseX: number | null, iconCenterX: number) => {
|
||||
if (mouseX === null) return 1;
|
||||
const distance = Math.abs(mouseX - iconCenterX);
|
||||
if (distance > MAX_DISTANCE) return 1;
|
||||
return 1 + (MAX_SCALE - 1) * Math.cos((distance / MAX_DISTANCE) * (Math.PI / 2));
|
||||
};
|
||||
|
||||
export const Dock = ({ items, className }: DockProps) => {
|
||||
const [mouseX, setMouseX] = useState<number | null>(null);
|
||||
const dockRef = useRef<HTMLDivElement | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (to: string) => location.pathname.startsWith(to);
|
||||
|
||||
const handleMouseMove = (ev: React.MouseEvent) => {
|
||||
const rect = dockRef.current?.getBoundingClientRect();
|
||||
if (rect) setMouseX(ev.clientX - rect.left);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => setMouseX(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`fixed bottom-4 left-1/2 -translate-x-1/2 z-[550] items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border border-white/10 bg-black/15 backdrop-blur-xl shadow-lg ${className ?? 'flex'}`}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
|
||||
const scale = getScale(mouseX, iconCenter);
|
||||
const active = isActive(item.to);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="group relative flex flex-col items-center"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'bottom center',
|
||||
transition: 'transform 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<span className="absolute -top-9 px-2 py-1 rounded-md bg-black/75 text-white text-xs whitespace-nowrap hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-150 pointer-events-none">
|
||||
{item.label}
|
||||
</span>
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
|
||||
style={{
|
||||
background: active ? `${item.color}55` : `${item.color}30`,
|
||||
boxShadow: active ? `0 0 12px ${item.color}30` : 'none',
|
||||
}}
|
||||
>
|
||||
<item.icon className="h-5 w-5 md:h-6 md:w-6" style={{ color: active ? item.color : `${item.color}cc` }} />
|
||||
</div>
|
||||
{active && (
|
||||
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useSessions } from '@/state/useSessions';
|
||||
import { useOpenCodeSessions } from '@/state/useOpenCodeSessions';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
|
||||
export const ChatHistory = () => {
|
||||
const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
|
||||
const claude = useSessions();
|
||||
const opencode = useOpenCodeSessions();
|
||||
|
||||
const sessions = useMemo(
|
||||
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
||||
[claude.sessions, opencode.sessions],
|
||||
);
|
||||
|
||||
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
|
||||
if (provider === 'claude') claude.deleteSession(id);
|
||||
else opencode.deleteSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
|
||||
<Link to="/chat" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
|
||||
Chat History
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No sessions yet</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={`${session.provider}-${session.id}`}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
|
||||
>
|
||||
<Link
|
||||
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
|
||||
className="flex items-center gap-2 flex-1 min-w-0"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
|
||||
<span className="text-xs text-duck-dark/40 truncate block">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<span
|
||||
className={`ml-1.5 font-medium ${
|
||||
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
|
||||
}`}
|
||||
>
|
||||
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(session.id, session.provider)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import {
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
Paperclip,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
X,
|
||||
FileText,
|
||||
Image,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import type { Attachment } from '../Chat/ChatPanel';
|
||||
|
||||
export const ChatLauncher = () => {
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettings();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
|
||||
const client = useClient();
|
||||
const [provider, setProvider] = useState<'claude' | 'opencode'>(settings.chat.defaultProvider);
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [collapsed, setCollapsed] = useUserState('widget:chatLauncher:collapsed', true);
|
||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(settings.chat.defaultProvider);
|
||||
setModel(settings.chat.defaultModel);
|
||||
}, [settings.chat.defaultProvider, settings.chat.defaultModel]);
|
||||
|
||||
const models = provider === 'claude' ? claudeModels : openCodeModels;
|
||||
|
||||
const handleAttachWebpage = async (url: string) => {
|
||||
const idx = attachments.length;
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
||||
]);
|
||||
|
||||
try {
|
||||
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
||||
url,
|
||||
provider,
|
||||
});
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, i) =>
|
||||
i === idx
|
||||
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
toast.error('Failed to scrape webpage');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachImage = async (file: File) => {
|
||||
const idx = attachments.length;
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
||||
]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('provider', provider);
|
||||
|
||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, i) =>
|
||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
toast.error('Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlSubmit = () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
handleAttachWebpage(url);
|
||||
setUrlInput('');
|
||||
setUrlDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
|
||||
let prompt = text;
|
||||
const attachmentIds: string[] = [];
|
||||
const images: { filename: string; dataUrl: string }[] = [];
|
||||
for (const a of attachments) {
|
||||
if (a.loading) continue;
|
||||
if (a.type === 'webpage' && a.content) {
|
||||
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
||||
} else if (a.type === 'image' && a.dataUrl) {
|
||||
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
||||
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
||||
}
|
||||
attachmentIds.push(a.attachmentId);
|
||||
}
|
||||
|
||||
const route = provider === 'claude' ? '/chat/new' : '/chat/opencode/new';
|
||||
navigate(route, {
|
||||
state: {
|
||||
initialMessage: prompt,
|
||||
model,
|
||||
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
|
||||
<Link
|
||||
to="/chat/new"
|
||||
className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline"
|
||||
>
|
||||
Start Chat
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="p-4 pb-2 pt-1">
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||
>
|
||||
{a.loading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : a.type === 'image' && a.dataUrl ? (
|
||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||
) : a.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<LinkIcon className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="z-[600]">
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) handleAttachImage(file);
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(ev) => {
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
ev.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) handleAttachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="What do you want to work on now?"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
size="icon"
|
||||
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
|
||||
{(['claude', 'opencode'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setProvider(value);
|
||||
setModel(null);
|
||||
}}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
provider === value
|
||||
? 'bg-white text-duck-dark shadow-sm'
|
||||
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
||||
}`}
|
||||
>
|
||||
{value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
|
||||
{models.find((m) => m.id === (model ?? models[0]?.id))?.name ?? models[0]?.name}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
|
||||
{models.map((m) => (
|
||||
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
|
||||
<Check
|
||||
className={`mr-2 h-3 w-3 ${(model ?? models[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-bold">{m.name}</span>
|
||||
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attach Webpage</DialogTitle>
|
||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleUrlSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUrlSubmit}
|
||||
disabled={!urlInput.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { ChatLauncher } from './ChatLauncher';
|
||||
import { Widget as FileBrowser } from 'plugins/FileBrowser/client';
|
||||
import { ChatHistory } from './ChatHistory';
|
||||
import { Catalog } from 'sounds';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
|
||||
export const Home = () => {
|
||||
const { plugins } = useServerSettings();
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-8 h-full p-4 pt-6 md:p-8 md:pt-12 overflow-y-auto">
|
||||
<div className="flex flex-col gap-8 flex-1 min-w-0">
|
||||
<ChatLauncher />
|
||||
{plugins?.FileBrowser !== false && <FileBrowser />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 flex-1 min-w-0">
|
||||
<ChatHistory />
|
||||
<Catalog />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
User,
|
||||
LogOut,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Server,
|
||||
Package,
|
||||
Bot,
|
||||
Sparkles,
|
||||
ClipboardList,
|
||||
ScrollText,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UserWithToken } from 'hooks/useAuth';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { PixelGrid } from '../LandingPage/components/PixelGrid';
|
||||
import { useIsProduction } from 'hooks/useIsProduction';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
import { PasskeyGate } from './PasskeyGate';
|
||||
import { Dock, type DockItem } from './Dock';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: ReactNode;
|
||||
mobileFull?: boolean;
|
||||
};
|
||||
|
||||
export function DashboardLayout({ children, mobileFull }: DashboardLayoutProps) {
|
||||
const { user } = useAuth();
|
||||
const isProduction = useIsProduction();
|
||||
const { plugins } = useServerSettings();
|
||||
const passkeyCount = (user as UserWithToken & { passkeyCount?: number })?.passkeyCount ?? 0;
|
||||
|
||||
const dockItems: DockItem[] = [
|
||||
{ label: 'Chat', to: '/chat', icon: Terminal, color: '#60a5fa' },
|
||||
...(plugins?.FileBrowser !== false
|
||||
? [{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' } as DockItem]
|
||||
: []),
|
||||
...(plugins?.Terminal !== false
|
||||
? [{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' } as DockItem]
|
||||
: []),
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Skills', to: '/skills', icon: Sparkles, color: '#c084fc' },
|
||||
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
||||
{ label: 'Processes', to: '/processes', icon: Workflow, color: '#2dd4bf' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden h-dvh outline-none fixed inset-0">
|
||||
<PixelGrid />
|
||||
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage: 'url(/static/landscape1.jpg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content layer - above pixel grid */}
|
||||
<div className="absolute inset-0 z-[520] flex flex-col">
|
||||
<header className="shrink-0 border-b border-white/20 bg-white/10 backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/static/officer-logo.svg"
|
||||
alt="Officer"
|
||||
className="h-8 md:h-12 w-auto"
|
||||
style={{ transform: 'skew(-15deg, -2deg)' }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-duck-yellow cursor-pointer">
|
||||
<Avatar className="h-9 w-9 rounded-full">
|
||||
<AvatarImage src={user?.avatar ?? undefined} />
|
||||
<AvatarFallback className="bg-duck-teal text-duck-yellow text-sm font-bold rounded-full">
|
||||
{user?.name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[600] w-48">
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/ai">
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/server">
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
Server Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/applications">
|
||||
<Package className="mr-2 h-4 w-4" />
|
||||
Applications
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/auth/signout">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
<div className={`flex-1 min-h-0 ${mobileFull ? 'pb-0 md:pb-20' : 'pb-16 md:pb-20'}`}>
|
||||
{isProduction && passkeyCount === 0 ? <PasskeyGate /> : children}
|
||||
</div>
|
||||
<Dock items={dockItems} className={mobileFull ? 'hidden md:flex' : 'flex'} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react';
|
||||
import { Terminal, Copy, Check } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type Harnesses = {
|
||||
claudeCode: boolean;
|
||||
opencode: boolean;
|
||||
};
|
||||
|
||||
type AIHarnessesCardProps = {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
saveSettings: (settings: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCardProps) => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [harnesses, setHarnesses] = useState<Harnesses>({ claudeCode: false, opencode: false });
|
||||
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({
|
||||
claudeCode: false,
|
||||
opencode: false,
|
||||
});
|
||||
|
||||
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
||||
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
|
||||
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
|
||||
|
||||
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
|
||||
queryKey: ['CLAUDE_CODE_VERSION'],
|
||||
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
|
||||
enabled: harnesses.claudeCode,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
return data?.version && !data?.globalPath ? 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
|
||||
queryKey: ['OPENCODE_VERSION'],
|
||||
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
|
||||
enabled: harnesses.opencode,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
return data?.version && !data?.globalPath ? 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: claudeAuth } = useQuery({
|
||||
queryKey: ['CLAUDE_CODE_AUTH'],
|
||||
queryFn: () => client.get<ClaudeAuthInfo>('/server-settings/claude-code/auth'),
|
||||
enabled: !!claudeVersion?.version,
|
||||
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
|
||||
});
|
||||
|
||||
const { data: opencodeAuth } = useQuery({
|
||||
queryKey: ['OPENCODE_AUTH'],
|
||||
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
|
||||
enabled: !!opencodeVersion?.version,
|
||||
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
|
||||
});
|
||||
|
||||
const installClaude = async () => {
|
||||
setInstalling((prev) => ({ ...prev, claudeCode: true }));
|
||||
try {
|
||||
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
|
||||
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
|
||||
} finally {
|
||||
setInstalling((prev) => ({ ...prev, claudeCode: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const installOpencode = async () => {
|
||||
setInstalling((prev) => ({ ...prev, opencode: true }));
|
||||
try {
|
||||
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
|
||||
queryClient.setQueryData(['OPENCODE_VERSION'], result);
|
||||
} finally {
|
||||
setInstalling((prev) => ({ ...prev, opencode: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(text);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const CopyCommand = ({ command }: { command: string }) => (
|
||||
<div className="mt-2 text-xs text-amber-600">
|
||||
Not globally accessible. Run:
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(command)}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied === command ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const claudeReady =
|
||||
!harnesses.claudeCode || (!!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated);
|
||||
const opencodeReady =
|
||||
!harnesses.opencode ||
|
||||
(!!opencodeVersion?.version && !!opencodeVersion?.globalPath && !!opencodeAuth?.authenticated);
|
||||
const canProceed = (harnesses.claudeCode || harnesses.opencode) && claudeReady && opencodeReady;
|
||||
|
||||
const handleNext = async () => {
|
||||
await saveSettings({ aiHarnesses: harnesses, onboardingComplete: true });
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Terminal className="h-5 w-5 text-duck-forest" />
|
||||
<h2 className="text-xl font-bold text-duck-dark">AI Harnesses</h2>
|
||||
</div>
|
||||
<p className="text-duck-dark/70 text-sm mb-6">Which AI coding tools do you use?</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={harnesses.opencode}
|
||||
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, opencode: !!checked }))}
|
||||
/>
|
||||
<span className="text-sm font-medium text-duck-dark">Opencode</span>
|
||||
</label>
|
||||
{harnesses.opencode && (
|
||||
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
|
||||
{opencodeLoading ? (
|
||||
'Checking version...'
|
||||
) : opencodeVersion?.version ? (
|
||||
<>
|
||||
<div>{opencodeVersion.version}</div>
|
||||
<div>{opencodeVersion.path}</div>
|
||||
{opencodeAuth && (
|
||||
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{opencodeAuth.authenticated ? (
|
||||
`Logged in (${opencodeAuth.providers.join(', ')})`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Not logged in</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={() => client.post('/server-settings/opencode/auth/login')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!opencodeVersion.globalPath && opencodeVersion.path && (
|
||||
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={installOpencode}
|
||||
disabled={installing.opencode}
|
||||
>
|
||||
{installing.opencode ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={harnesses.claudeCode}
|
||||
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, claudeCode: !!checked }))}
|
||||
/>
|
||||
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
|
||||
</label>
|
||||
{harnesses.claudeCode && (
|
||||
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
|
||||
{claudeLoading ? (
|
||||
'Checking version...'
|
||||
) : claudeVersion?.version ? (
|
||||
<>
|
||||
<div>{claudeVersion.version}</div>
|
||||
<div>{claudeVersion.path}</div>
|
||||
{claudeAuth && (
|
||||
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{claudeAuth.authenticated ? (
|
||||
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Not logged in</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={() => client.post('/server-settings/claude-code/auth/login')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!claudeVersion.globalPath && claudeVersion.path && (
|
||||
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={installOpencode}
|
||||
disabled={installing.opencode}
|
||||
>
|
||||
{installing.opencode ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button disabled={!canProceed} onClick={handleNext}>
|
||||
Complete Setup
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { Building2, UserRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
type AccountMode = 'organization' | 'single' | null;
|
||||
|
||||
type ServerTypeCardProps = {
|
||||
onNext: () => void;
|
||||
saveSettings: (settings: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ServerTypeCard = ({ onNext, saveSettings }: ServerTypeCardProps) => {
|
||||
const [accountMode, setAccountMode] = useState<AccountMode>(null);
|
||||
|
||||
const handleNext = async () => {
|
||||
await saveSettings({ accountMode });
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-bold text-duck-dark mb-2">Account Type</h2>
|
||||
<p className="text-duck-dark/70 text-sm mb-6">How will you be using Officer?</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccountMode('single')}
|
||||
className={`flex flex-col items-center gap-3 p-6 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
accountMode === 'single'
|
||||
? 'border-duck-teal bg-duck-teal/10'
|
||||
: 'border-duck-dark/20 hover:border-duck-dark/40'
|
||||
}`}
|
||||
>
|
||||
<UserRound className={`h-8 w-8 ${accountMode === 'single' ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
||||
<span className={`text-sm font-medium ${accountMode === 'single' ? 'text-duck-teal' : 'text-duck-dark'}`}>
|
||||
Single User
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/50 text-center">Just me, personal use</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccountMode('organization')}
|
||||
className={`flex flex-col items-center gap-3 p-6 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
accountMode === 'organization'
|
||||
? 'border-duck-teal bg-duck-teal/10'
|
||||
: 'border-duck-dark/20 hover:border-duck-dark/40'
|
||||
}`}
|
||||
>
|
||||
<Building2 className={`h-8 w-8 ${accountMode === 'organization' ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
||||
<span
|
||||
className={`text-sm font-medium ${accountMode === 'organization' ? 'text-duck-teal' : 'text-duck-dark'}`}
|
||||
>
|
||||
Organization
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/50 text-center">Multiple users and teams</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button disabled={!accountMode} onClick={handleNext}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { ServerTypeCard } from './ServerTypeCard';
|
||||
import { AIHarnessesCard } from './AIHarnessesCard';
|
||||
|
||||
const STEPS = ['server-type', 'ai-harnesses'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
const getStepFromHash = (): Step => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (STEPS.includes(hash as Step)) return hash as Step;
|
||||
return STEPS[0]!;
|
||||
};
|
||||
|
||||
const setHash = (step: Step) => {
|
||||
window.location.hash = step;
|
||||
};
|
||||
|
||||
export const OnboardingAdmin = () => {
|
||||
const { saveSettings } = useServerSettings();
|
||||
const [step, setStep] = useState<Step>(getStepFromHash);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setStep(getStepFromHash());
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHash(step);
|
||||
}, [step]);
|
||||
|
||||
const currentIndex = STEPS.indexOf(step);
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentIndex < STEPS.length - 1) {
|
||||
setStep(STEPS[currentIndex + 1]!);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentIndex > 0) {
|
||||
setStep(STEPS[currentIndex - 1]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<div className="w-full max-w-lg">
|
||||
{step === 'server-type' && <ServerTypeCard onNext={nextStep} saveSettings={saveSettings} />}
|
||||
{step === 'ai-harnesses' && (
|
||||
<AIHarnessesCard onNext={nextStep} onBack={prevStep} saveSettings={saveSettings} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
export const PasskeyGate = () => {
|
||||
const { user, createPasskeyCredentials } = useAuth();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!user || isRegistering) return;
|
||||
|
||||
setIsRegistering(true);
|
||||
try {
|
||||
await createPasskeyCredentials(user);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to register passkey. Please try again.');
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="rounded-full bg-duck-teal/10 p-4">
|
||||
<KeyRound className="h-8 w-8 text-duck-teal" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2>
|
||||
|
||||
<p className="text-duck-dark/70">
|
||||
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
|
||||
passkey.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleRegister}
|
||||
disabled={isRegistering}
|
||||
className="w-full h-11 mt-2 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isRegistering ? 'Registering...' : 'Register Passkey'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
|
||||
export const Plans = () => {
|
||||
const client = useClient();
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['plans'],
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (plans.length > 0 && !selectedPlan) {
|
||||
setSelectedPlan(plans[0]!);
|
||||
}
|
||||
}, [plans, selectedPlan]);
|
||||
|
||||
const { data: content = '' } = useQuery<string>({
|
||||
queryKey: ['plans', selectedPlan],
|
||||
queryFn: () => client.getText(`/plans/${selectedPlan}`),
|
||||
enabled: !!selectedPlan,
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col h-full p-4">
|
||||
<Card className="flex-1 overflow-hidden">
|
||||
{/* Header with plan selector */}
|
||||
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-white/60">
|
||||
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
|
||||
{plans.length > 1 && (
|
||||
<select
|
||||
value={selectedPlan ?? ''}
|
||||
onChange={(ev) => setSelectedPlan(ev.target.value)}
|
||||
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-white/80"
|
||||
>
|
||||
{plans.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Markdown content */}
|
||||
<div className="overflow-y-auto h-full p-6">
|
||||
<div className="prose prose-sm max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none prose-td:text-sm prose-th:text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ResourcePage } from '../ResourcePage';
|
||||
|
||||
export const Processes = () => <ResourcePage kind="Process" endpoint="/processes" queryKey="processes" />;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
type PasswordFormState = {
|
||||
password?: string;
|
||||
newPassword?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
const validate = (state: Partial<PasswordFormState>) => {
|
||||
const { password, newPassword, confirmPassword } = state;
|
||||
return !!(password && newPassword && confirmPassword && newPassword === confirmPassword);
|
||||
};
|
||||
|
||||
export const ChangePassword = () => {
|
||||
const { changePassword } = useAuth();
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const form = useForm<PasswordFormState>({}, validate);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!form.isValid || isChanging) return;
|
||||
|
||||
setIsChanging(true);
|
||||
try {
|
||||
await changePassword({
|
||||
password: form.state.password!,
|
||||
newPassword: form.state.newPassword!,
|
||||
confirmPassword: form.state.confirmPassword!,
|
||||
});
|
||||
toast.success('Password changed');
|
||||
form.update({ password: '', newPassword: '', confirmPassword: '' });
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to change password');
|
||||
form.update({ password: '', newPassword: '', confirmPassword: '' });
|
||||
} finally {
|
||||
setIsChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form ref={form.formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Current Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">New Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="newPassword"
|
||||
placeholder="New password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Confirm New Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.isValid || isChanging}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isChanging ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'pt', label: 'Portuguese' },
|
||||
{ code: 'es', label: 'Spanish' },
|
||||
{ code: 'fr', label: 'French' },
|
||||
{ code: 'de', label: 'German' },
|
||||
{ code: 'it', label: 'Italian' },
|
||||
{ code: 'nl', label: 'Dutch' },
|
||||
{ code: 'ru', label: 'Russian' },
|
||||
{ code: 'zh', label: 'Chinese' },
|
||||
{ code: 'ja', label: 'Japanese' },
|
||||
{ code: 'ko', label: 'Korean' },
|
||||
{ code: 'ar', label: 'Arabic' },
|
||||
{ code: 'hi', label: 'Hindi' },
|
||||
{ code: 'tr', label: 'Turkish' },
|
||||
{ code: 'pl', label: 'Polish' },
|
||||
{ code: 'sv', label: 'Swedish' },
|
||||
{ code: 'da', label: 'Danish' },
|
||||
{ code: 'no', label: 'Norwegian' },
|
||||
{ code: 'fi', label: 'Finnish' },
|
||||
{ code: 'uk', label: 'Ukrainian' },
|
||||
{ code: 'cs', label: 'Czech' },
|
||||
{ code: 'ro', label: 'Romanian' },
|
||||
{ code: 'el', label: 'Greek' },
|
||||
{ code: 'he', label: 'Hebrew' },
|
||||
{ code: 'th', label: 'Thai' },
|
||||
{ code: 'vi', label: 'Vietnamese' },
|
||||
{ code: 'id', label: 'Indonesian' },
|
||||
{ code: 'ms', label: 'Malay' },
|
||||
];
|
||||
|
||||
const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code;
|
||||
|
||||
export const Languages = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const { spoken, default: defaultLang, translateTo } = settings.languages;
|
||||
const [addingLang, setAddingLang] = useState('');
|
||||
|
||||
const save = (languages: typeof settings.languages) => {
|
||||
saveSettings({ ...settings, languages });
|
||||
};
|
||||
|
||||
const addSpoken = (code: string) => {
|
||||
if (!code || spoken.includes(code)) return;
|
||||
save({ ...settings.languages, spoken: [...spoken, code] });
|
||||
setAddingLang('');
|
||||
};
|
||||
|
||||
const removeSpoken = (code: string) => {
|
||||
const next = spoken.filter((s) => s !== code);
|
||||
const updates = { ...settings.languages, spoken: next };
|
||||
if (defaultLang === code) updates.default = next[0] ?? 'en';
|
||||
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
|
||||
save(updates);
|
||||
};
|
||||
|
||||
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code));
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{/* Spoken languages */}
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark/70">Languages you speak</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{spoken.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
|
||||
>
|
||||
{getLabel(code)}
|
||||
{spoken.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeSpoken(code)}
|
||||
className="hover:text-red-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{availableToAdd.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<select
|
||||
value={addingLang}
|
||||
onChange={(ev) => addSpoken(ev.target.value)}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-white/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
<option value="">Add a language...</option>
|
||||
{availableToAdd.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default language */}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Default language</span>
|
||||
<select
|
||||
value={defaultLang}
|
||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-white/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{spoken.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{getLabel(code)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-duck-dark/40">Used for future UI localization.</span>
|
||||
</Label>
|
||||
|
||||
{/* Translate from */}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Translate to</span>
|
||||
<select
|
||||
value={translateTo}
|
||||
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-white/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-duck-dark/40">Target language when translating content you don't speak.</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
|
||||
export const TaskDefaults = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [model, setModel] = useState<string | null>(settings.tasks.defaultModel);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(settings.tasks.defaultModel);
|
||||
}, [settings]);
|
||||
|
||||
const openCodeGroups = useMemo(() => {
|
||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||
for (const m of openCodeModels) {
|
||||
const provider = m.provider ?? 'OpenCode';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider].push({ id: m.id, name: m.name });
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
}, [openCodeModels]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const isOpenCode = openCodeModels.some((m) => m.id === model);
|
||||
const defaultProvider = isOpenCode ? ('opencode' as const) : ('claude' as const);
|
||||
await saveSettings({ ...settings, tasks: { defaultProvider, defaultModel: model } });
|
||||
toast.success('Task defaults saved');
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Default Model</span>
|
||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectValue placeholder="Same as chat default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
{claudeModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Claude</SelectLabel>
|
||||
{claudeModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{openCodeGroups.map(({ provider, models }) => (
|
||||
<SelectGroup key={provider}>
|
||||
<SelectLabel>{provider} (OpenCode)</SelectLabel>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={`${provider}:${m.id}`} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
type ProfileFormState = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const MAX_AVATAR_SIZE = 384_000; // ~384KB to stay under 512KB varchar after base64 overhead
|
||||
|
||||
const readFileAsBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const UserData = () => {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const profileForm = useForm<ProfileFormState>({ name: user?.name ?? '' });
|
||||
|
||||
const handleAvatarChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
toast.error('Image must be smaller than 384KB');
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = await readFileAsBase64(file);
|
||||
setAvatarPreview(base64);
|
||||
};
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateUser({
|
||||
name: profileForm.state.name ?? '',
|
||||
avatar: avatarPreview ?? user?.avatar ?? '',
|
||||
});
|
||||
toast.success('Profile updated');
|
||||
setAvatarPreview(null);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to update profile');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayAvatar = avatarPreview ?? user?.avatar ?? undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-center mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className="relative group cursor-pointer rounded-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Avatar className="h-20 w-20 rounded-full">
|
||||
<AvatarImage src={displayAvatar} />
|
||||
<AvatarFallback className="bg-duck-teal text-duck-yellow text-2xl font-bold rounded-full">
|
||||
{user?.name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Camera className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
||||
</div>
|
||||
|
||||
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
value={user?.email ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Search, User, Lock, Globe, ListChecks } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { Card } from '@/components/Card';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { UserData } from './UserData';
|
||||
import { ChangePassword } from './ChangePassword';
|
||||
import { Languages } from './Languages';
|
||||
import { TaskDefaults } from './TaskDefaults';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: User,
|
||||
title: 'Profile',
|
||||
description: 'Update your name and avatar.',
|
||||
content: <UserData />,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: ListChecks,
|
||||
title: 'Tasks',
|
||||
description: 'Default model for file browser tasks.',
|
||||
content: <TaskDefaults />,
|
||||
},
|
||||
{
|
||||
key: 'languages',
|
||||
icon: Globe,
|
||||
title: 'Languages',
|
||||
description: 'Set your spoken languages and translation preferences.',
|
||||
content: <Languages />,
|
||||
},
|
||||
{
|
||||
key: 'change-password',
|
||||
icon: Lock,
|
||||
title: 'Change Password',
|
||||
description: 'Update your account password.',
|
||||
content: <ChangePassword />,
|
||||
},
|
||||
];
|
||||
|
||||
const allKeys = sections.map((s) => s.key);
|
||||
|
||||
export const Profile = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<string[]>([]);
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const matchingKeys = useMemo(() => {
|
||||
if (!search) return allKeys;
|
||||
const query = search.toLowerCase();
|
||||
return sections
|
||||
.filter((s) => {
|
||||
const el = sectionRefs.current[s.key];
|
||||
return (el?.textContent?.toLowerCase() ?? '').includes(query);
|
||||
})
|
||||
.map((s) => s.key);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) setExpanded(matchingKeys);
|
||||
}, [search, matchingKeys]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
|
||||
<Input
|
||||
placeholder="Search settings..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" value={expanded} onValueChange={setExpanded}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
ref={(el) => {
|
||||
sectionRefs.current[section.key] = el;
|
||||
}}
|
||||
className={search && !matchingKeys.includes(section.key) ? 'hidden' : ''}
|
||||
>
|
||||
<AccordionItem value={section.key}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<section.icon className="h-5 w-5 text-duck-forest shrink-0" />
|
||||
<div className="text-base font-bold text-duck-dark">{section.title}</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>{section.content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useVisibleClaudeModels } from '@/state/useModels';
|
||||
import { Card } from '@/components/Card';
|
||||
import { DashboardLayout } from './Layout';
|
||||
import { useClaude } from './Chat/useClaude';
|
||||
import { EmbeddableChat } from './Chat/EmbeddableChat';
|
||||
import type { ChatMessage } from './Chat/types';
|
||||
|
||||
type ResourceSummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: 'global' | 'user';
|
||||
};
|
||||
|
||||
type ResourceDetail = ResourceSummary & {
|
||||
body: string;
|
||||
filePath: string;
|
||||
chatSessionId: string | null;
|
||||
};
|
||||
|
||||
type ResourcePageProps = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
queryKey: string;
|
||||
};
|
||||
|
||||
type ResourceChatProps = {
|
||||
kind: string;
|
||||
endpoint: string;
|
||||
dirName: string;
|
||||
filePath: string;
|
||||
resourceDir: string;
|
||||
chatSessionId: string | null;
|
||||
isNew?: boolean;
|
||||
onResponseEnd?: () => void;
|
||||
};
|
||||
|
||||
const ResourceChat = ({
|
||||
kind,
|
||||
endpoint,
|
||||
dirName,
|
||||
filePath,
|
||||
resourceDir,
|
||||
chatSessionId,
|
||||
isNew,
|
||||
onResponseEnd,
|
||||
}: ResourceChatProps) => {
|
||||
const client = useClient();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
const defaultInput = chatSessionId
|
||||
? undefined
|
||||
: isNew
|
||||
? `Help me create the content for this new ${kind} file: ${filePath}`
|
||||
: `Help me understand and improve this ${kind} file: ${filePath}`;
|
||||
|
||||
const storage = useMemo(
|
||||
() => ({
|
||||
load: async () => {
|
||||
const data = await client.get<{ sessionId: string | null; messages: ChatMessage[] }>(
|
||||
`${endpoint}/${dirName}/chat`,
|
||||
);
|
||||
return { sessionId: data.sessionId, messages: data.messages ?? [] };
|
||||
},
|
||||
save: async (sessionId: string, messages: ChatMessage[]) => {
|
||||
await client.put(`${endpoint}/${dirName}/chat`, { sessionId, messages });
|
||||
},
|
||||
}),
|
||||
[endpoint, dirName],
|
||||
);
|
||||
|
||||
const claude = useClaude(chatSessionId ?? undefined, undefined, {
|
||||
replaceUrl: false,
|
||||
storage,
|
||||
resourceChatDir: resourceDir,
|
||||
});
|
||||
|
||||
const onResponseEndRef = useRef(onResponseEnd);
|
||||
onResponseEndRef.current = onResponseEnd;
|
||||
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !claude.isGenerating) {
|
||||
onResponseEndRef.current?.();
|
||||
}
|
||||
wasGenerating.current = claude.isGenerating;
|
||||
}, [claude.isGenerating]);
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={claude}
|
||||
provider="claude"
|
||||
availableModels={claudeModels}
|
||||
defaultInput={defaultInput}
|
||||
className="h-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) => {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const newNameRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { data: items = [] } = useQuery<ResourceSummary[]>({
|
||||
queryKey: [queryKey],
|
||||
queryFn: () => client.get<ResourceSummary[]>(endpoint),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !selected) {
|
||||
setSelected(items[0]!.dirName);
|
||||
}
|
||||
}, [items, selected]);
|
||||
|
||||
const { data: detail } = useQuery<ResourceDetail>({
|
||||
queryKey: [queryKey, selected],
|
||||
queryFn: () => client.get<ResourceDetail>(`${endpoint}/${selected}`),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
const selectItem = (dirName: string) => {
|
||||
setSelected(dirName);
|
||||
setShowDetail(true);
|
||||
setIsNew(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await client.post<{ name: string; dirName: string }>(endpoint, { name });
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setSelected(res.dirName);
|
||||
setShowDetail(true);
|
||||
setIsNew(true);
|
||||
setEditing(true);
|
||||
} catch {
|
||||
toast.error(`Failed to create ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await client.delete(`${endpoint}/${selected}`);
|
||||
setDeleteConfirm(false);
|
||||
setEditing(false);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout mobileFull={editing}>
|
||||
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
||||
{/* Left panel — list */}
|
||||
<Card
|
||||
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
|
||||
{!creating && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreating(true);
|
||||
setTimeout(() => newNameRef.current?.focus(), 0);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-duck-dark/50" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
|
||||
<input
|
||||
ref={newNameRef}
|
||||
value={newName}
|
||||
onChange={(ev) => setNewName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
}
|
||||
}}
|
||||
placeholder={`${kind} name...`}
|
||||
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-white px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim()}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
}}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
||||
className="w-full rounded border border-duck-dark/15 bg-white/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.dirName}
|
||||
onClick={() => selectItem(item.dirName)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
||||
selected === item.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
|
||||
<span
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
item.scope === 'user' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
|
||||
}`}
|
||||
>
|
||||
{item.scope}
|
||||
</span>
|
||||
</div>
|
||||
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
|
||||
</button>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
||||
)}
|
||||
{items.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right panel — detail + chat */}
|
||||
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
||||
<Card className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowDetail(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
||||
</span>
|
||||
{detail && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{detail?.body ? (
|
||||
<article className="skill-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{detail.body}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : detail ? (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
||||
Select a {kind.toLowerCase()} to view its contents
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{editing && detail?.filePath && selected && (
|
||||
<Card className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail?.name ?? 'Chat'}</span>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
</button>
|
||||
</div>
|
||||
<ResourceChat
|
||||
kind={kind.toLowerCase()}
|
||||
key={detail.filePath}
|
||||
endpoint={endpoint}
|
||||
dirName={selected}
|
||||
filePath={detail.filePath}
|
||||
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
||||
chatSessionId={detail.chatSessionId}
|
||||
isNew={isNew}
|
||||
onResponseEnd={() => qc.invalidateQueries({ queryKey: [queryKey, selected] })}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {kind}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{detail?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
|
||||
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
||||
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
|
||||
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
|
||||
|
||||
export const AIHarnessesSection = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { aiHarnesses, saveSettings } = useServerSettings();
|
||||
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({
|
||||
claudeCode: false,
|
||||
opencode: false,
|
||||
});
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
|
||||
queryKey: ['OPENCODE_VERSION'],
|
||||
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
|
||||
enabled: !!aiHarnesses?.opencode,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
return data?.version && !data?.globalPath ? 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
|
||||
queryKey: ['CLAUDE_CODE_VERSION'],
|
||||
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
|
||||
enabled: !!aiHarnesses?.claudeCode,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
return data?.version && !data?.globalPath ? 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: opencodeAuth } = useQuery({
|
||||
queryKey: ['OPENCODE_AUTH'],
|
||||
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
|
||||
enabled: !!opencodeVersion?.version,
|
||||
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
|
||||
});
|
||||
|
||||
const { data: claudeAuth } = useQuery({
|
||||
queryKey: ['CLAUDE_CODE_AUTH'],
|
||||
queryFn: () => client.get<ClaudeAuthInfo>('/server-settings/claude-code/auth'),
|
||||
enabled: !!claudeVersion?.version,
|
||||
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
|
||||
});
|
||||
|
||||
const toggleHarness = (key: 'claudeCode' | 'opencode', checked: boolean) => {
|
||||
const updated = { ...aiHarnesses, [key]: checked };
|
||||
saveSettings({ aiHarnesses: updated });
|
||||
};
|
||||
|
||||
const installClaude = async () => {
|
||||
setInstalling((prev) => ({ ...prev, claudeCode: true }));
|
||||
try {
|
||||
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
|
||||
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
|
||||
} finally {
|
||||
setInstalling((prev) => ({ ...prev, claudeCode: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const installOpencode = async () => {
|
||||
setInstalling((prev) => ({ ...prev, opencode: true }));
|
||||
try {
|
||||
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
|
||||
queryClient.setQueryData(['OPENCODE_VERSION'], result);
|
||||
} finally {
|
||||
setInstalling((prev) => ({ ...prev, opencode: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(text);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const CopyCommand = ({ command }: { command: string }) => (
|
||||
<div className="mt-2 text-xs text-amber-600">
|
||||
Not globally accessible. Run:
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(command)}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied === command ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!aiHarnesses?.opencode}
|
||||
onCheckedChange={(checked) => toggleHarness('opencode', !!checked)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-duck-dark">Opencode</span>
|
||||
</label>
|
||||
{aiHarnesses?.opencode && (
|
||||
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
|
||||
{opencodeLoading ? (
|
||||
'Checking version...'
|
||||
) : opencodeVersion?.version ? (
|
||||
<>
|
||||
<div>{opencodeVersion.version}</div>
|
||||
<div>{opencodeVersion.path}</div>
|
||||
{opencodeAuth && (
|
||||
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{opencodeAuth.authenticated ? (
|
||||
`Logged in (${opencodeAuth.providers.join(', ')})`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Not logged in</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={() => client.post('/server-settings/opencode/auth/login')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!opencodeVersion.globalPath && opencodeVersion.path && (
|
||||
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={installOpencode}
|
||||
disabled={installing.opencode}
|
||||
>
|
||||
{installing.opencode ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!aiHarnesses?.claudeCode}
|
||||
onCheckedChange={(checked) => toggleHarness('claudeCode', !!checked)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
|
||||
</label>
|
||||
{aiHarnesses?.claudeCode && (
|
||||
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
|
||||
{claudeLoading ? (
|
||||
'Checking version...'
|
||||
) : claudeVersion?.version ? (
|
||||
<>
|
||||
<div>{claudeVersion.version}</div>
|
||||
<div>{claudeVersion.path}</div>
|
||||
{claudeAuth && (
|
||||
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{claudeAuth.authenticated ? (
|
||||
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Not logged in</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={() => client.post('/server-settings/claude-code/auth/login')}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!claudeVersion.globalPath && claudeVersion.path && (
|
||||
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={installClaude}
|
||||
disabled={installing.claudeCode}
|
||||
>
|
||||
{installing.claudeCode ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
|
||||
type PluginInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export const PluginsSection = () => {
|
||||
const client = useClient();
|
||||
const { plugins, saveSettings } = useServerSettings();
|
||||
|
||||
const { data: pluginList } = useQuery({
|
||||
queryKey: ['PLUGINS_LIST'],
|
||||
queryFn: () => client.get<PluginInfo[]>('/server-settings/plugins'),
|
||||
});
|
||||
|
||||
const togglePlugin = (id: string, enabled: boolean) => {
|
||||
saveSettings({ plugins: { ...plugins, [id]: enabled } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{pluginList?.map((p: PluginInfo) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-duck-dark">{p.name}</div>
|
||||
<div className="text-xs text-duck-dark/50">{p.description}</div>
|
||||
</div>
|
||||
<Switch checked={plugins?.[p.id] !== false} onCheckedChange={(checked) => togglePlugin(p.id, !!checked)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
|
||||
export const TerminalSection = () => {
|
||||
const { terminalSandboxed, saveSettings } = useServerSettings();
|
||||
|
||||
const toggleSandbox = (checked: boolean) => {
|
||||
saveSettings({ terminalSandboxed: checked });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-duck-dark">Sandbox terminal (Docker)</div>
|
||||
<div className="text-xs text-duck-dark/50">Restrict terminal access to the user's home directory.</div>
|
||||
</div>
|
||||
<Switch checked={terminalSandboxed === true} onCheckedChange={toggleSandbox} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Search, Terminal, Puzzle, Shield } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { Card } from '@/components/Card';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { AIHarnessesSection } from './AIHarnessesSection';
|
||||
import { PluginsSection } from './PluginsSection';
|
||||
import { TerminalSection } from './TerminalSection';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'AI Harnesses',
|
||||
description: 'Which AI coding tools do you use?',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
icon: Puzzle,
|
||||
title: 'Plugins',
|
||||
description: 'Enable or disable installed plugins.',
|
||||
content: <PluginsSection />,
|
||||
},
|
||||
{
|
||||
key: 'terminal',
|
||||
icon: Shield,
|
||||
title: 'Terminal',
|
||||
description: 'Sandbox and access controls for the Terminal plugin.',
|
||||
content: <TerminalSection />,
|
||||
},
|
||||
];
|
||||
|
||||
const allKeys = sections.map((s) => s.key);
|
||||
|
||||
export const ServerSettings = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<string[]>(allKeys);
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const matchingKeys = useMemo(() => {
|
||||
if (!search) return allKeys;
|
||||
const query = search.toLowerCase();
|
||||
return sections
|
||||
.filter((s) => {
|
||||
const el = sectionRefs.current[s.key];
|
||||
return (el?.textContent?.toLowerCase() ?? '').includes(query);
|
||||
})
|
||||
.map((s) => s.key);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(search ? matchingKeys : allKeys);
|
||||
}, [search, matchingKeys]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
|
||||
<Input
|
||||
placeholder="Search settings..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" value={expanded} onValueChange={setExpanded}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
ref={(el) => {
|
||||
sectionRefs.current[section.key] = el;
|
||||
}}
|
||||
className={search && !matchingKeys.includes(section.key) ? 'hidden' : ''}
|
||||
>
|
||||
<AccordionItem value={section.key}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<section.icon className="h-5 w-5 text-duck-forest shrink-0" />
|
||||
<div className="text-left">
|
||||
<div className="text-base font-bold text-duck-dark">{section.title}</div>
|
||||
<div className="text-sm font-normal text-duck-dark/70">{section.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent forceMount>{section.content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Terminal, Eye, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card } from '@/components/Card';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import {
|
||||
useClaudeModels,
|
||||
useOpenCodeModels,
|
||||
useVisibleClaudeModels,
|
||||
useVisibleOpenCodeModels,
|
||||
} from '@/state/useModels';
|
||||
import type { UserSettings } from '@/state/types/user-settings';
|
||||
|
||||
export const AISettings = () => {
|
||||
const [mainTab, setMainTab] = useUserState('ai-settings-tab', 'chat-defaults');
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<Tabs value={mainTab} onValueChange={setMainTab}>
|
||||
<TabsList className="w-full mb-6">
|
||||
<TabsTrigger value="chat-defaults" className="flex-1 gap-2 cursor-pointer">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Chat Defaults
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model-visibility" className="flex-1 gap-2 cursor-pointer">
|
||||
<Eye className="h-4 w-4" />
|
||||
Model Visibility
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chat-defaults">
|
||||
<ChatDefaultsSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="model-visibility">
|
||||
<ModelVisibilitySection />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatDefaultsSection = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
const [systemPrompt, setSystemPrompt] = useState(settings.chat.systemPrompt);
|
||||
const [temperature, setTemperature] = useState(settings.chat.temperature);
|
||||
const [defaultPwd, setDefaultPwd] = useState(settings.chat.defaultPwd);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(settings.chat.defaultModel);
|
||||
setSystemPrompt(settings.chat.systemPrompt);
|
||||
setTemperature(settings.chat.temperature);
|
||||
setDefaultPwd(settings.chat.defaultPwd);
|
||||
}, [settings]);
|
||||
|
||||
const allModels = useMemo(
|
||||
() => [
|
||||
...claudeModels.map((m) => ({ ...m, provider: 'Claude' })),
|
||||
...openCodeModels.map((m) => ({ ...m, provider: m.provider ?? 'OpenCode' })),
|
||||
],
|
||||
[claudeModels, openCodeModels],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const isOpenCode = openCodeModels.some((m) => m.id === model);
|
||||
const defaultProvider = isOpenCode ? ('opencode' as const) : ('claude' as const);
|
||||
const updated: UserSettings = {
|
||||
...settings,
|
||||
chat: { defaultProvider, defaultModel: model, systemPrompt, temperature, defaultPwd },
|
||||
};
|
||||
await saveSettings(updated);
|
||||
toast.success('Chat defaults saved');
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Model</span>
|
||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
{allModels.map((m) => (
|
||||
<SelectItem key={`${m.provider}:${m.id}`} value={m.id}>
|
||||
<span className="font-bold">{m.name}</span>
|
||||
<span className="text-duck-dark/50 ml-1">({m.provider})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">System Prompt</span>
|
||||
<Textarea
|
||||
className="bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
|
||||
placeholder="Custom instructions for the AI..."
|
||||
value={systemPrompt}
|
||||
onChange={(ev) => setSystemPrompt(ev.target.value)}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-duck-dark/70">Temperature</span>
|
||||
<span className="text-sm text-duck-dark/50">{temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider min={0} max={2} step={0.1} value={[temperature]} onValueChange={([v]) => setTemperature(v ?? 1)} />
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Default Working Directory</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
value={defaultPwd}
|
||||
onChange={(ev) => setDefaultPwd(ev.target.value)}
|
||||
placeholder="~"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ModelVisibilitySection = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useClaudeModels();
|
||||
const openCodeModels = useOpenCodeModels();
|
||||
const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'opencode');
|
||||
|
||||
const enabledModels = settings.ai?.enabledModels ?? [];
|
||||
const enabledProviders = settings.ai?.enabledProviders ?? [];
|
||||
|
||||
const toggleModel = async (key: string) => {
|
||||
const isEnabled = enabledModels.includes(key);
|
||||
const newEnabled = isEnabled ? enabledModels.filter((id) => id !== key) : [...enabledModels, key];
|
||||
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: newEnabled } });
|
||||
};
|
||||
|
||||
const ocGroups = useMemo(() => {
|
||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||
for (const m of openCodeModels) {
|
||||
const provider = m.provider ?? 'Other';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider].push({ id: m.id, name: m.name });
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
}, [openCodeModels]);
|
||||
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
const [selectedNewProvider, setSelectedNewProvider] = useState<string>('');
|
||||
const disabledProviders = ocGroups.filter((g) => !enabledProviders.includes(g.provider));
|
||||
|
||||
const handleEnableProvider = async () => {
|
||||
if (!selectedNewProvider) return;
|
||||
await saveSettings({
|
||||
...settings,
|
||||
ai: { ...settings.ai, enabledProviders: [...enabledProviders, selectedNewProvider] },
|
||||
});
|
||||
setAddingProvider(false);
|
||||
setSelectedNewProvider('');
|
||||
};
|
||||
|
||||
const handleRemoveProvider = async (provider: string) => {
|
||||
await saveSettings({
|
||||
...settings,
|
||||
ai: { ...settings.ai, enabledProviders: enabledProviders.filter((p) => p !== provider) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={subTab} onValueChange={setSubTab}>
|
||||
<TabsList className="w-full mb-4">
|
||||
<TabsTrigger value="opencode" className="flex-1 cursor-pointer">
|
||||
OpenCode
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="claude" className="flex-1 cursor-pointer">
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
<div className="grid gap-1">
|
||||
{claudeModels.map((m) => (
|
||||
<label
|
||||
key={m.id}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-duck-dark/5 cursor-pointer"
|
||||
>
|
||||
<span className="text-sm text-duck-dark">{m.name}</span>
|
||||
<Switch checked={enabledModels.includes(m.id)} onCheckedChange={() => toggleModel(m.id)} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="opencode">
|
||||
<div className="flex justify-end items-center gap-2 mb-3">
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Select value={selectedNewProvider} onValueChange={setSelectedNewProvider}>
|
||||
<SelectTrigger className="h-9 flex-1 bg-white/60 border-duck-dark/20 text-duck-dark text-sm">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
{disabledProviders.map((g) => (
|
||||
<SelectItem key={g.provider} value={g.provider}>
|
||||
{g.provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!selectedNewProvider}
|
||||
onClick={handleEnableProvider}
|
||||
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold disabled:opacity-50"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setAddingProvider(true)}
|
||||
disabled={disabledProviders.length === 0}
|
||||
>
|
||||
Add Provider
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{ocGroups.length === 0 ? (
|
||||
<p className="text-sm text-duck-dark/40">No OpenCode models available.</p>
|
||||
) : (
|
||||
<OpenCodeProviderList
|
||||
groups={ocGroups}
|
||||
enabledProviders={enabledProviders}
|
||||
enabledModels={enabledModels}
|
||||
onToggleModel={toggleModel}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
|
||||
|
||||
type OpenCodeProviderListProps = {
|
||||
groups: ProviderGroup[];
|
||||
enabledProviders: string[];
|
||||
enabledModels: string[];
|
||||
onToggleModel: (key: string) => void;
|
||||
onRemoveProvider: (provider: string) => void;
|
||||
};
|
||||
|
||||
const OpenCodeProviderList = ({
|
||||
groups,
|
||||
enabledProviders,
|
||||
enabledModels,
|
||||
onToggleModel,
|
||||
onRemoveProvider,
|
||||
}: OpenCodeProviderListProps) => {
|
||||
const [openProvider, setOpenProvider] = useUserState<string>('ai-settings-oc-accordion', '');
|
||||
const enabled = groups.filter((g) => enabledProviders.includes(g.provider));
|
||||
|
||||
if (enabled.length === 0) {
|
||||
return <p className="text-sm text-duck-dark/40">No providers enabled.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible value={openProvider} onValueChange={(v) => setOpenProvider(v ?? '')}>
|
||||
{enabled.map((g) => (
|
||||
<AccordionItem key={g.provider} value={g.provider}>
|
||||
<AccordionTrigger className="py-2 px-3 text-sm font-medium text-duck-dark hover:no-underline [&>svg]:ml-1">
|
||||
<span className="flex-1 text-left">{g.provider}</span>
|
||||
<span className="text-xs opacity-60 mr-5">{g.models.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onRemoveProvider(g.provider);
|
||||
}}
|
||||
className="p-1 text-duck-dark/30 hover:text-red-500 cursor-pointer transition-colors mr-3"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-2">
|
||||
<div className="grid gap-1 max-h-52 overflow-y-auto">
|
||||
{[...g.models]
|
||||
.sort((a, b) => {
|
||||
const aEnabled = enabledModels.includes(`${g.provider}:${a.id}`);
|
||||
const bEnabled = enabledModels.includes(`${g.provider}:${b.id}`);
|
||||
if (aEnabled !== bEnabled) return aEnabled ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((m) => {
|
||||
const key = `${g.provider}:${m.id}`;
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-duck-dark/5 cursor-pointer"
|
||||
>
|
||||
<span className="text-sm text-duck-dark/70">{m.name}</span>
|
||||
<Switch checked={enabledModels.includes(key)} onCheckedChange={() => onToggleModel(key)} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Palette, Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '@/components/ui/ThemeProvider';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
|
||||
export const Appearance = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleThemeChange = (newTheme: string) => {
|
||||
setTheme(newTheme);
|
||||
saveSettings({ ...settings, appearance: { ...settings.appearance, theme: newTheme } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 h-fit">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Palette className="h-5 w-5 text-duck-forest" />
|
||||
<h2 className="text-xl font-bold text-duck-dark">Appearance</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<span className="text-duck-dark/70 text-sm">Theme</span>
|
||||
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleThemeChange('light')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium cursor-pointer transition-colors ${
|
||||
theme === 'light' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium cursor-pointer transition-colors ${
|
||||
theme === 'dark' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Bot, ChevronRight } from 'lucide-react';
|
||||
import { Card } from '@/components/Card';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { Appearance } from './Appearance';
|
||||
|
||||
export const Settings = () => {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<div className="w-full max-w-4xl grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Link to="/settings/ai">
|
||||
<Card className="p-6 hover:border-duck-teal/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bot className="h-5 w-5 text-duck-forest" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-duck-dark">AI Settings</h2>
|
||||
<p className="text-sm text-duck-dark/60">Chat defaults, model visibility</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-duck-dark/40" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Appearance />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
export const SignoutScreen = () => {
|
||||
const { signout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const logout = async () => {
|
||||
await signout();
|
||||
window.location.href = '/';
|
||||
};
|
||||
logout();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ResourcePage } from '../ResourcePage';
|
||||
|
||||
export const Skills = () => <ResourcePage kind="Skill" endpoint="/skills" queryKey="skills" />;
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { DashboardLayout } from '../Layout';
|
||||
import { Card } from '@/components/Card';
|
||||
import { MessageBubble } from '../Chat/MessageBubble';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
provider: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type FullLog = LogMetadata & {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const ProviderBadge = ({ provider }: { provider: string }) => (
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 text-orange-700' : 'bg-blue-100 text-blue-700'}`}
|
||||
>
|
||||
{provider}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const TaskLogs = () => {
|
||||
const client = useClient();
|
||||
const [logs, setLogs] = useState<LogMetadata[]>([]);
|
||||
const [selectedFilename, setSelectedFilename] = useState<string | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState<FullLog | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
client
|
||||
.get<LogMetadata[]>('/task-logs')
|
||||
.then((data) => {
|
||||
setLogs(data);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFilename) {
|
||||
setSelectedLog(null);
|
||||
return;
|
||||
}
|
||||
client
|
||||
.get<FullLog>(`/task-logs/${selectedFilename}`)
|
||||
.then(setSelectedLog)
|
||||
.catch(() => setSelectedLog(null));
|
||||
}, [selectedFilename]);
|
||||
|
||||
const filtered = search
|
||||
? logs.filter((l) => {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
l.taskName.toLowerCase().includes(q) ||
|
||||
l.entryName.toLowerCase().includes(q) ||
|
||||
l.provider.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: logs;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex h-full p-3 md:p-6 gap-4">
|
||||
{/* Left panel: list */}
|
||||
<Card
|
||||
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="p-3 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-white/60 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
|
||||
)}
|
||||
{filtered.map((log) => (
|
||||
<button
|
||||
key={log.filename}
|
||||
onClick={() => {
|
||||
setSelectedFilename(log.filename);
|
||||
setShowDetail(true);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedFilename === log.filename ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{log.isError ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
) : log.completedAt ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{log.taskName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-5.5">
|
||||
<span className="text-xs text-duck-dark/50 truncate">{log.entryName}</span>
|
||||
<ProviderBadge provider={log.provider} />
|
||||
</div>
|
||||
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right panel: log viewer */}
|
||||
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
||||
{!selectedLog && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
|
||||
Select a log to view
|
||||
<button onClick={() => setShowDetail(false)} className="md:hidden text-duck-teal text-xs cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 inline mr-1" />
|
||||
Back to list
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog && (
|
||||
<>
|
||||
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowDetail(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
|
||||
<ProviderBadge provider={selectedLog.provider} />
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/50 mt-0.5">
|
||||
{selectedLog.entryName} · {selectedLog.model} · {formatDate(selectedLog.startedAt)}
|
||||
{selectedLog.completedAt && ` — ${formatDate(selectedLog.completedAt)}`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog.isError && (
|
||||
<span className="text-xs text-red-600 bg-red-50 px-2 py-0.5 rounded-full">Error</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{selectedLog.messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ResourcePage } from '../ResourcePage';
|
||||
|
||||
export const Tasks = () => <ResourcePage kind="Task" endpoint="/tasks" queryKey="tasks" />;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AuthLayout } from './Layout';
|
||||
import { Hero } from './components/Hero';
|
||||
|
||||
export function LandingPage() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Hero />
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { PixelGrid } from './components/PixelGrid';
|
||||
import { DuckAvatar } from './components/DuckAvatar';
|
||||
import { SignupModal } from './components/SignupModal';
|
||||
import { LoginModal } from './components/LoginModal';
|
||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||
import { VerifyModal } from './components/VerifyModal';
|
||||
import { ResetPasswordModal } from './components/ResetPasswordModal';
|
||||
|
||||
type AuthLayoutProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { registrationOpen } = useAuth();
|
||||
|
||||
const signupOpen = registrationOpen && location.pathname === '/auth/register';
|
||||
const loginOpen = location.pathname === '/auth/login';
|
||||
const forgotPasswordOpen = location.pathname === '/auth/forgot-password';
|
||||
const verifyOpen = location.pathname === '/auth/verify';
|
||||
const resetPasswordOpen = location.pathname === '/auth/reset-password';
|
||||
|
||||
const closeModal = (open: boolean) => !open && navigate('/');
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden h-dvh outline-none fixed inset-0">
|
||||
<PixelGrid />
|
||||
<DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />
|
||||
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage: 'url(/static/landscape1.jpg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content layer - above duck and pixel grid */}
|
||||
<div className="absolute inset-0 z-[520]">{children}</div>
|
||||
</section>
|
||||
|
||||
<SignupModal open={signupOpen} onOpenChange={closeModal} />
|
||||
<LoginModal open={loginOpen} onOpenChange={closeModal} />
|
||||
<ForgotPasswordModal open={forgotPasswordOpen} onOpenChange={closeModal} />
|
||||
<VerifyModal open={verifyOpen} onOpenChange={closeModal} />
|
||||
<ResetPasswordModal open={resetPasswordOpen} onOpenChange={closeModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
type DebugPanelProps = {
|
||||
fullControlMode: boolean;
|
||||
cameraInfo: {
|
||||
cameraPosition: number[];
|
||||
target: number[];
|
||||
zoom: number;
|
||||
};
|
||||
modelInfo: {
|
||||
position: number[];
|
||||
rotation: number[];
|
||||
scale: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
||||
return (
|
||||
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
||||
<div className="font-bold text-blue-400">Controls:</div>
|
||||
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
||||
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
||||
</div>
|
||||
<div>* Left-click/Touch + drag: Rotate</div>
|
||||
{fullControlMode && (
|
||||
<>
|
||||
<div>* Right-click + drag: Pan</div>
|
||||
<div>* Scroll: Zoom</div>
|
||||
</>
|
||||
)}
|
||||
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
||||
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
||||
|
||||
<div className="font-bold text-green-400 pt-2">Camera:</div>
|
||||
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
||||
|
||||
<div className="font-bold text-yellow-400 pt-2">Model:</div>
|
||||
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Canvas, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { DuckModel } from './DuckModel';
|
||||
import { DebugPanel } from './DebugPanel';
|
||||
|
||||
type CameraInfoProps = {
|
||||
onUpdate: (info: { cameraPosition: number[]; target: number[]; zoom: number }) => void;
|
||||
initialTarget: [number, number, number];
|
||||
fullControlMode: boolean;
|
||||
initialPosition: [number, number, number];
|
||||
};
|
||||
|
||||
function CameraInfo({ onUpdate, initialTarget, fullControlMode, initialPosition }: CameraInfoProps) {
|
||||
const { camera } = useThree();
|
||||
const controlsRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.target.set(...initialTarget);
|
||||
camera.position.set(...initialPosition);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
}, [initialTarget, initialPosition, camera]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (controlsRef.current) {
|
||||
const controls = controlsRef.current;
|
||||
onUpdate({
|
||||
cameraPosition: camera.position.toArray(),
|
||||
target: controls.target.toArray(),
|
||||
zoom: camera.zoom,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [camera, onUpdate]);
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
enabled={fullControlMode}
|
||||
enableRotate={fullControlMode}
|
||||
enableZoom={fullControlMode}
|
||||
enablePan={fullControlMode}
|
||||
target={initialTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DuckAvatarProps = {
|
||||
showDebug: boolean;
|
||||
fullControlMode: boolean;
|
||||
currentSection: number;
|
||||
};
|
||||
|
||||
export function DuckAvatar({ showDebug, fullControlMode, currentSection }: DuckAvatarProps) {
|
||||
const isHeroSection = currentSection === 0;
|
||||
const [cameraInfo, setCameraInfo] = useState({
|
||||
cameraPosition: [2.89, 5.24, 7.38],
|
||||
target: [0.3, 3.29, -0.4],
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
const [modelInfo, setModelInfo] = useState({
|
||||
position: [0, -0.2, 0],
|
||||
rotation: [-0.1, -0.75, 0],
|
||||
scale: 4.5,
|
||||
});
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const heroClasses = 'fixed bottom-0 left-1/2 -translate-x-1/2 w-[60vh] h-[75vh]';
|
||||
const miniClasses = 'fixed bottom-4 right-4 w-[20vh] h-[25vh]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isHeroSection ? heroClasses : miniClasses} overflow-visible z-[510] pointer-events-none`}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full pointer-events-none overflow-visible" style={{ background: 'transparent' }}>
|
||||
<Canvas
|
||||
camera={{
|
||||
position: [2.89, 5.24, 7.38],
|
||||
fov: 65,
|
||||
near: 0.1,
|
||||
far: 1000,
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<ambientLight intensity={1.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={2} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={1} />
|
||||
<pointLight position={[0, 5, 0]} intensity={1.5} />
|
||||
<CameraInfo
|
||||
onUpdate={setCameraInfo}
|
||||
initialTarget={[0.3, 3.29, -0.4]}
|
||||
fullControlMode={fullControlMode}
|
||||
initialPosition={[2.89, 5.24, 7.38]}
|
||||
/>
|
||||
<DuckModel
|
||||
onUpdate={setModelInfo}
|
||||
onAssetsLoaded={() => {
|
||||
setIsLoaded(true);
|
||||
}}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
{showDebug && <DebugPanel fullControlMode={fullControlMode} cameraInfo={cameraInfo} modelInfo={modelInfo} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useGLTF, useAnimations } from '@react-three/drei';
|
||||
|
||||
type DuckModelProps = {
|
||||
onUpdate: (info: { position: number[]; rotation: number[]; scale: number }) => void;
|
||||
onAssetsLoaded: () => void;
|
||||
currentSection: number;
|
||||
};
|
||||
|
||||
export function DuckModel({ onUpdate, onAssetsLoaded, currentSection }: DuckModelProps) {
|
||||
const character = useGLTF('/static/duck3D/Character_output.glb');
|
||||
const animations = useGLTF('/static/duck3D/Meshy_Merged_Animations.glb');
|
||||
const meshRef = useRef<any>(null);
|
||||
const { actions } = useAnimations(animations.animations, meshRef);
|
||||
|
||||
useEffect(() => {
|
||||
onAssetsLoaded();
|
||||
}, [character, animations, onAssetsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actions) return;
|
||||
|
||||
const isContactSection = currentSection === 6;
|
||||
|
||||
if (isContactSection) {
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(0.4));
|
||||
return;
|
||||
}
|
||||
|
||||
const action = actions['Walking'];
|
||||
if (!action) return;
|
||||
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(0.4));
|
||||
|
||||
action.reset();
|
||||
action.setLoop(2201, Infinity);
|
||||
action.clampWhenFinished = false;
|
||||
action.fadeIn(0.4);
|
||||
action.play();
|
||||
}, [actions, currentSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (meshRef.current) {
|
||||
onUpdate({
|
||||
position: meshRef.current.position.toArray(),
|
||||
rotation: meshRef.current.rotation.toArray().slice(0, 3),
|
||||
scale: meshRef.current.scale.x,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [onUpdate]);
|
||||
|
||||
const isContactSection = currentSection === 6;
|
||||
const rotationY = isContactSection ? 0 : (10 * Math.PI) / 180;
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={meshRef}
|
||||
object={character.scene}
|
||||
position={[0, -0.2, 0]}
|
||||
scale={4.5}
|
||||
rotation={[-0.1, rotationY, 0]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DuckAvatar';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router';
|
||||
import { PaperDialog } from '@/components/Dialogs';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
type ForgotPasswordFormState = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<ForgotPasswordFormState>) => {
|
||||
return !!state.email;
|
||||
};
|
||||
|
||||
type ForgotPasswordModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ForgotPasswordModal = ({ open, onOpenChange }: ForgotPasswordModalProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<ForgotPasswordFormState>({}, validateForm);
|
||||
const { forgotPassword } = useAuth();
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await forgotPassword({ email: state.email! });
|
||||
setSent(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to send recovery email. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setSent(false);
|
||||
if (formRef.current) {
|
||||
update({ email: '' });
|
||||
}
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const isDisabled = !isValid || isSubmitting;
|
||||
|
||||
return (
|
||||
<PaperDialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
onOpenAutoFocus={(ev) => {
|
||||
if (!sent) {
|
||||
const firstInput = (ev.currentTarget as HTMLElement | null)?.querySelector('input');
|
||||
firstInput?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sent ? (
|
||||
<div className="text-center py-4">
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Email Sent</h2>
|
||||
<p className="mt-4 text-duck-dark/70">Please check your email for further instructions.</p>
|
||||
<Button
|
||||
onClick={() => handleClose(false)}
|
||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Forgot Password</DialogTitle>
|
||||
<DialogDescription className="text-duck-dark/60">
|
||||
Enter your email and we'll send you a recovery link
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send Recovery Email'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<p className="text-center text-sm text-duck-dark/60">
|
||||
Remember your password?{' '}
|
||||
<Link to="/auth/login" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</PaperDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OfficerSvgLogo } from '../Logos';
|
||||
|
||||
export function Hero() {
|
||||
const navigate = useNavigate();
|
||||
const { registrationOpen, signup } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const handleBootstrap = async () => {
|
||||
const trimmed = email.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await signup({ email: trimmed });
|
||||
setDone(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Bootstrap failed. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="pt-28 md:pt-20 lg:pt-24 xl:pt-0 z-20 flex justify-center">
|
||||
<div className="hidden md:block">
|
||||
<OfficerSvgLogo size="2xl" />
|
||||
</div>
|
||||
<div className="block md:hidden">
|
||||
<OfficerSvgLogo size="lg" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{registrationOpen ? (
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||
<div className="bg-white/90 border-2 border-duck-dark/20 rounded-2xl p-8 max-w-md w-full mx-4 shadow-xl">
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
<p className="text-sm text-duck-dark/60">
|
||||
A verification link has been sent to <strong>{email}</strong>. Click it to activate your account.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Welcome, Admin</h2>
|
||||
<p className="text-sm text-duck-dark/60 mb-6">Enter your email to create your administrator account.</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleBootstrap();
|
||||
}
|
||||
}}
|
||||
placeholder="admin@example.com"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border-2 border-duck-dark/20 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:border-duck-teal/50"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleBootstrap}
|
||||
disabled={!email.trim() || isSubmitting}
|
||||
className="bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-6 py-2.5 rounded-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Bootstrap'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-20">
|
||||
<Button
|
||||
size="default"
|
||||
onClick={() => navigate('/auth/login')}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow rounded-lg font-bold transition-all duration-200 hover:scale-105 cursor-pointer px-12 py-5 text-lg md:text-xl lg:text-2xl"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Hero';
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router';
|
||||
import { PaperDialog } from '@/components/Dialogs';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
type LoginFormState = {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<LoginFormState>) => {
|
||||
const { email, password } = state;
|
||||
if (!email || !password) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type LoginModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const LoginModal = ({ open, onOpenChange }: LoginModalProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<LoginFormState>({}, validateForm);
|
||||
const { signin } = useAuth();
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await signin({ email: state.email!, password: state.password! });
|
||||
window.location.href = '/';
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Login failed. Please try again.');
|
||||
update({ ...state, password: '' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (formRef.current) {
|
||||
update({ email: '', password: '' });
|
||||
}
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const isDisabled = !isValid || isSubmitting;
|
||||
|
||||
return (
|
||||
<PaperDialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
onOpenAutoFocus={(ev) => {
|
||||
const firstInput = (ev.currentTarget as HTMLElement | null)?.querySelector('input');
|
||||
firstInput?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Welcome Back</DialogTitle>
|
||||
<DialogDescription className="text-duck-dark/60">Sign in to your account</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<div className="text-center text-sm text-duck-dark/60 grid gap-1">
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/auth/register" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</PaperDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
type SvgLogoProps = {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
};
|
||||
|
||||
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/static/officer-icon-square.svg"
|
||||
alt="Officer Icon"
|
||||
style={{ width: config.width, height: 'auto', transform: 'skew(-15deg, -2deg)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
xs: { width: '120px' },
|
||||
sm: { width: '200px' },
|
||||
md: { width: '400px' },
|
||||
lg: { width: '600px' },
|
||||
xl: { width: '800px' },
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
type SvgLogoProps = {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
square?: boolean;
|
||||
};
|
||||
|
||||
export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
|
||||
if (square) {
|
||||
return <OfficerSvgLogoSquare size={size} />;
|
||||
}
|
||||
|
||||
return <OfficerSvgLogoRegular size={size} />;
|
||||
}
|
||||
|
||||
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
|
||||
const config = sizeConfig.regular[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/static/officer-logo.svg"
|
||||
alt="Officer Logo"
|
||||
style={{ width: config.width, height: 'auto', transform: 'skew(-15deg, -2deg)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
|
||||
const config = sizeConfig.square[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/static/officer-logo-square.svg"
|
||||
alt="Officer Logo"
|
||||
style={{ width: config.width, height: 'auto', transform: 'skew(-15deg, -2deg)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
regular: {
|
||||
xs: { width: '180px' },
|
||||
sm: { width: '300px' },
|
||||
md: { width: '600px' },
|
||||
lg: { width: '900px' },
|
||||
xl: { width: '1200px' },
|
||||
'2xl': { width: '1500px' },
|
||||
},
|
||||
square: {
|
||||
xs: { width: '120px' },
|
||||
sm: { width: '200px' },
|
||||
md: { width: '400px' },
|
||||
lg: { width: '600px' },
|
||||
xl: { width: '800px' },
|
||||
'2xl': { width: '1000px' },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './PastilhasSvgLogo';
|
||||
export * from './DevSvgLogo';
|
||||
@@ -0,0 +1,14 @@
|
||||
export function PixelGrid() {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none z-[500]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(20, 83, 45, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(20, 83, 45, 0.1) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router';
|
||||
import { PaperDialog } from '@/components/Dialogs';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
type ResetPasswordFormState = {
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<ResetPasswordFormState>) => {
|
||||
const { password, confirmPassword } = state;
|
||||
if (!password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type ResetPasswordModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ResetPasswordModal = ({ open, onOpenChange }: ResetPasswordModalProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [reset, setReset] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<ResetPasswordFormState>({}, validateForm);
|
||||
const { resetPassword } = useAuth();
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const verificationCode = new URL(window.location.href).searchParams.get('verificationCode');
|
||||
if (!verificationCode) throw new Error('Invalid verification code');
|
||||
await resetPassword({ password: state.password!, verificationCode });
|
||||
setReset(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Password reset failed. Please try again.');
|
||||
update({ password: '', confirmPassword: '' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setReset(false);
|
||||
if (formRef.current) {
|
||||
update({ password: '', confirmPassword: '' });
|
||||
}
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const isDisabled = !isValid || isSubmitting;
|
||||
|
||||
return (
|
||||
<PaperDialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
onOpenAutoFocus={(ev) => {
|
||||
if (!reset) {
|
||||
const firstInput = (ev.currentTarget as HTMLElement | null)?.querySelector('input');
|
||||
firstInput?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reset ? (
|
||||
<div className="text-center py-4">
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Password Reset</h2>
|
||||
<p className="mt-4 text-duck-dark/70">Your password has been reset. You can now sign in.</p>
|
||||
<Button
|
||||
onClick={() => (window.location.href = '/auth/login')}
|
||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Reset Password</DialogTitle>
|
||||
<DialogDescription className="text-duck-dark/60">Enter your new password</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">New Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 chars, mixed case, number, symbol"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<p className="text-center text-sm text-duck-dark/60">
|
||||
<Link to="/auth/login" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</PaperDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useLocation, Link } from 'react-router';
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PaperDialog } from '@/components/Dialogs';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
type SignupFormState = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<SignupFormState>) => {
|
||||
const { name, email, password, confirmPassword } = state;
|
||||
if (!name || !email || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type SignupModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const SignupModal = ({ open, onOpenChange }: SignupModalProps) => {
|
||||
const location = useLocation();
|
||||
const bootstrapEmail = (location.state as { email?: string } | null)?.email ?? '';
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [registered, setRegistered] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<SignupFormState>({ email: bootstrapEmail }, validateForm);
|
||||
const { signup } = useAuth();
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await signup({ name: state.name, email: state.email, password: state.password });
|
||||
setRegistered(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Signup failed. Please try again.');
|
||||
update({ ...state, password: '', confirmPassword: '' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setRegistered(false);
|
||||
if (formRef.current) {
|
||||
update({ name: '', email: '', password: '', confirmPassword: '' });
|
||||
}
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const isDisabled = !isValid || isSubmitting;
|
||||
|
||||
return (
|
||||
<PaperDialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
onOpenAutoFocus={(ev) => {
|
||||
if (!registered) {
|
||||
const firstInput = (ev.currentTarget as HTMLElement).querySelector('input');
|
||||
firstInput?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{registered ? (
|
||||
<div className="text-center py-4">
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Registration Successful</h2>
|
||||
<p className="mt-4 text-duck-dark/70">Please check your email to verify your account.</p>
|
||||
<Button
|
||||
onClick={() => handleClose(false)}
|
||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Create Account</DialogTitle>
|
||||
<DialogDescription className="text-duck-dark/60">Create a new account to get started</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
defaultValue={bootstrapEmail}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 chars, mixed case, number, symbol"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Sign Up'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<p className="text-center text-sm text-duck-dark/60">
|
||||
Already have an account?{' '}
|
||||
<Link to="/auth/login" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</PaperDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PaperDialog } from '@/components/Dialogs';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type VerifyFormState = {
|
||||
name?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||
const { name, password, confirmPassword } = state;
|
||||
if (!name || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type VerifyModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
|
||||
export const VerifyModal = ({ open, onOpenChange }: VerifyModalProps) => {
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { verify } = useAuth();
|
||||
const { state, formRef, update, isValid } = useForm<VerifyFormState>({}, validateForm);
|
||||
|
||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||
const [email, setEmail] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [resent, setResent] = useState(false);
|
||||
|
||||
// Validate token on mount and strip it from the URL
|
||||
useEffect(() => {
|
||||
if (!open || !verificationCode) {
|
||||
if (!verificationCode) setTokenStatus('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(null, '', '/auth/verify');
|
||||
|
||||
client
|
||||
.post<{ email: string }>('/verify-token', { verificationCode })
|
||||
.then((data) => {
|
||||
setEmail(data.email);
|
||||
setTokenStatus('valid');
|
||||
})
|
||||
.catch(() => {
|
||||
// Try to decode email from expired token for resend
|
||||
try {
|
||||
const payload = JSON.parse(atob(verificationCode.split('.')[1]!));
|
||||
if (payload.email) setEmail(payload.email);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setTokenStatus('invalid');
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
// Redirect after successful verification
|
||||
useEffect(() => {
|
||||
if (!verified) return;
|
||||
const timer = setTimeout(() => navigate('/'), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [verified]);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting || !verificationCode) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await verify({
|
||||
verificationCode,
|
||||
name: state.name,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
setVerified(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Verification failed. Please try again.');
|
||||
update({ ...state, password: '', confirmPassword: '' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email || resending) return;
|
||||
setResending(true);
|
||||
try {
|
||||
await client.post('/resend-verification', { email });
|
||||
setResent(true);
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to resend. Please try again.');
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (tokenStatus === 'loading') {
|
||||
return (
|
||||
<PaperDialog open={open} onOpenChange={onOpenChange}>
|
||||
<div className="text-center py-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Validating...</DialogTitle>
|
||||
<DialogDescription className="text-duck-dark/60">Please wait</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</PaperDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Invalid / expired token
|
||||
if (tokenStatus === 'invalid') {
|
||||
return (
|
||||
<PaperDialog open={open} onOpenChange={onOpenChange}>
|
||||
<div className="text-center py-4">
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Link Expired</h2>
|
||||
<p className="mt-4 text-duck-dark/70">
|
||||
This verification link is no longer valid.{' '}
|
||||
{email ? 'Click below to receive a new one.' : 'Please request a new one.'}
|
||||
</p>
|
||||
{resent ? (
|
||||
<p className="mt-6 text-sm text-duck-teal font-medium">
|
||||
A new verification email has been sent to <strong>{email}</strong>.
|
||||
</p>
|
||||
) : (
|
||||
email && (
|
||||
<Button
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="mt-6 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-8 py-3 transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{resending ? 'Sending...' : 'Resend Verification Email'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PaperDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Valid token — show form or success
|
||||
return (
|
||||
<PaperDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenAutoFocus={(ev) => {
|
||||
const firstInput = (ev.currentTarget as HTMLElement).querySelector('input:not([disabled])');
|
||||
(firstInput as HTMLElement)?.focus();
|
||||
}}
|
||||
>
|
||||
{verified ? (
|
||||
<div className="text-center py-4">
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Account Verified</h2>
|
||||
<p className="mt-4 text-duck-dark/70">
|
||||
Congratulations! Your account has been set up successfully. Redirecting...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-duck-dark text-2xl font-bold">Set Up Your Account</DialogTitle>
|
||||
<DialogDescription className="text-duck-dark/60">Complete your account details</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-duck-dark/5 border-duck-dark/10 text-duck-dark disabled:opacity-100"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 chars, mixed case, number, symbol"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</PaperDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LandingPage } from './LandingPage';
|
||||
export { AuthLayout } from './Layout';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@/components/ui/ThemeProvider';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import './styles/index.css';
|
||||
|
||||
const headLinks = [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/static/favicon.svg' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '96x96', href: '/static/favicon-96x96.png' },
|
||||
{ rel: 'apple-touch-icon', href: '/static/apple-touch-icon.png' },
|
||||
{ rel: 'manifest', href: '/static/site.webmanifest' },
|
||||
];
|
||||
|
||||
for (const attrs of headLinks) {
|
||||
const link = document.createElement('link');
|
||||
for (const [key, value] of Object.entries(attrs)) link.setAttribute(key, value);
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const elem = document.getElementById('root')!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="light" storageKey="officer-theme">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} />
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>officer.dev</title>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'claude' | 'opencode';
|
||||
defaultModel: string | null;
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
defaultPwd: string;
|
||||
};
|
||||
ai: {
|
||||
enabledModels: string[];
|
||||
enabledProviders: string[];
|
||||
};
|
||||
tasks: {
|
||||
defaultProvider: 'claude' | 'opencode';
|
||||
defaultModel: string | null;
|
||||
};
|
||||
appearance: {
|
||||
theme: string;
|
||||
};
|
||||
languages: {
|
||||
spoken: string[];
|
||||
default: string;
|
||||
translateTo: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserState = Record<string, unknown>;
|
||||
|
||||
export const DEFAULT_SETTINGS: UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'claude',
|
||||
defaultModel: null,
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
},
|
||||
ai: {
|
||||
enabledModels: ['claude-sonnet-4-5', 'claude-opus-4-6', 'claude-haiku-4-5'],
|
||||
enabledProviders: [],
|
||||
},
|
||||
tasks: {
|
||||
defaultProvider: 'claude',
|
||||
defaultModel: null,
|
||||
},
|
||||
appearance: {
|
||||
theme: 'light',
|
||||
},
|
||||
languages: {
|
||||
spoken: ['en'],
|
||||
default: 'en',
|
||||
translateTo: 'en',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSessions } from './useSessions';
|
||||
import { usePlans } from './usePlans';
|
||||
import { useSettings } from './useSettings';
|
||||
import { useThemeSync } from './useThemeSync';
|
||||
|
||||
export const useInitialData = () => {
|
||||
const { sessions } = useSessions();
|
||||
const { plans } = usePlans();
|
||||
const { settings } = useSettings();
|
||||
useThemeSync();
|
||||
|
||||
return { sessions, plans, settings };
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useSettings } from './useSettings';
|
||||
|
||||
export type ModelOption = { id: string; name: string; provider?: string; providerId?: string };
|
||||
|
||||
export const modelKey = (m: ModelOption) => (m.provider ? `${m.provider}:${m.id}` : m.id);
|
||||
|
||||
// Hardcoded fallback in case the API call fails
|
||||
const CLAUDE_MODELS: ModelOption[] = [
|
||||
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
|
||||
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
|
||||
];
|
||||
|
||||
export const useClaudeModels = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: models = CLAUDE_MODELS } = useQuery<ModelOption[]>({
|
||||
queryKey: ['CLAUDE_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const data = await client.get<ModelOption[]>('/claude/models');
|
||||
return data.length > 0 ? data : CLAUDE_MODELS;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
/** @deprecated Use useClaudeModels() instead */
|
||||
export const claudeModels = CLAUDE_MODELS;
|
||||
|
||||
export const useOpenCodeModels = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||
queryKey: ['OC_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<ModelOption[]>('/opencode/models'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
export const useVisibleClaudeModels = () => {
|
||||
const models = useClaudeModels();
|
||||
const { settings } = useSettings();
|
||||
const enabled = settings.ai?.enabledModels ?? [];
|
||||
return useMemo(() => models.filter((m) => enabled.includes(modelKey(m))), [models, enabled]);
|
||||
};
|
||||
|
||||
export const useVisibleOpenCodeModels = () => {
|
||||
const models = useOpenCodeModels();
|
||||
const { settings } = useSettings();
|
||||
const providers = settings.ai?.enabledProviders ?? [];
|
||||
const enabled = settings.ai?.enabledModels ?? [];
|
||||
return useMemo(
|
||||
() => models.filter((m) => providers.includes(m.provider ?? '') && enabled.includes(modelKey(m))),
|
||||
[models, providers, enabled],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SessionEntry, ChatMessage } from '@/Screens/Dashboard/Chat/types';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const useOpenCodeSessions = () => {
|
||||
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
|
||||
queryKey: ['OC_SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<RawSessionEntry[]>('/opencode/sessions'),
|
||||
select: (data) => data.map((s) => ({ ...s, provider: 'opencode' as const })),
|
||||
});
|
||||
|
||||
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/opencode/sessions/${sessionId}/messages`);
|
||||
|
||||
const renameSession = async (sessionId: string | null, title: string) => {
|
||||
if (!title) return;
|
||||
if (!sessionId) return;
|
||||
|
||||
await client.put(`/opencode/sessions/${sessionId}`, { title: title.slice(0, 200) });
|
||||
queryClient.setQueryData<SessionEntry[]>(
|
||||
['OC_SESSIONS'],
|
||||
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
||||
);
|
||||
};
|
||||
|
||||
const deleteSession = async (sessionId: string) => {
|
||||
await client.delete(`/opencode/sessions/${sessionId}`);
|
||||
queryClient.setQueryData<SessionEntry[]>(['OC_SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||
};
|
||||
|
||||
return { sessions, getMessages, renameSession, deleteSession };
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const usePlans = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['PLANS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
const getPlan = (name: string) => client.getText(`/plans/${name}`);
|
||||
|
||||
return { plans, getPlan };
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useUserState } from './useUserState';
|
||||
import type { ModelOption } from './useModels';
|
||||
|
||||
const MAX_RECENTS = 5;
|
||||
|
||||
export const useRecentModels = () => {
|
||||
const [recents, setRecents] = useUserState<ModelOption[]>('recentModels', []);
|
||||
const migrated = useRef(false);
|
||||
|
||||
// One-time migration from localStorage
|
||||
useEffect(() => {
|
||||
if (migrated.current) return;
|
||||
migrated.current = true;
|
||||
|
||||
const raw = localStorage.getItem('OC_RECENT_MODELS');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ModelOption[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setRecents(parsed.slice(0, MAX_RECENTS));
|
||||
localStorage.removeItem('OC_RECENT_MODELS');
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('OC_RECENT_MODELS');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRecent = useCallback(
|
||||
(model: ModelOption) => {
|
||||
setRecents((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== model.id);
|
||||
return [model, ...filtered].slice(0, MAX_RECENTS);
|
||||
});
|
||||
},
|
||||
[setRecents],
|
||||
);
|
||||
|
||||
return { recents, addRecent };
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type AIHarnesses = {
|
||||
claudeCode: boolean;
|
||||
opencode: boolean;
|
||||
};
|
||||
|
||||
type ServerSettings = {
|
||||
onboardingComplete?: boolean;
|
||||
accountMode?: 'organization' | 'single';
|
||||
aiHarnesses?: AIHarnesses;
|
||||
plugins?: Record<string, boolean>;
|
||||
terminalSandboxed?: boolean;
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = ['SERVER_SETTINGS'];
|
||||
|
||||
export const useServerSettings = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: SETTINGS_KEY,
|
||||
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
|
||||
});
|
||||
|
||||
const onboardingComplete = settings?.onboardingComplete ?? true;
|
||||
const accountMode = settings?.accountMode;
|
||||
const aiHarnesses = settings?.aiHarnesses;
|
||||
const plugins = settings?.plugins;
|
||||
const terminalSandboxed = settings?.terminalSandboxed;
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (update: Partial<ServerSettings>) => {
|
||||
const result = await client.put<ServerSettings>('/server-settings', update);
|
||||
queryClient.setQueryData(SETTINGS_KEY, result);
|
||||
},
|
||||
[client, queryClient],
|
||||
);
|
||||
|
||||
return { onboardingComplete, accountMode, aiHarnesses, plugins, terminalSandboxed, isLoading, saveSettings };
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SessionEntry, ChatMessage } from '@/Screens/Dashboard/Chat/types';
|
||||
import type { SlashCommandResult } from './useSlashCommands';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const useSessions = () => {
|
||||
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
|
||||
queryKey: ['SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<RawSessionEntry[]>('/sessions'),
|
||||
select: (data) => data.map((s) => ({ ...s, provider: 'claude' as const })),
|
||||
});
|
||||
|
||||
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/sessions/${sessionId}/messages`);
|
||||
|
||||
const saveMessages = (sessionId: string, messages: ChatMessage[]) =>
|
||||
client.put(`/sessions/${sessionId}/messages`, messages);
|
||||
|
||||
const renameSession = async (sessionId: string | null, args: string): Promise<SlashCommandResult> => {
|
||||
if (!args) return { handled: true, feedback: 'Usage: /rename <new title>' };
|
||||
if (!sessionId) return { handled: true, feedback: 'No active session to rename.' };
|
||||
|
||||
const title = args.slice(0, 200);
|
||||
try {
|
||||
await client.put(`/sessions/${sessionId}`, { title });
|
||||
queryClient.setQueryData<SessionEntry[]>(
|
||||
['SESSIONS'],
|
||||
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
||||
);
|
||||
return { handled: true, feedback: `Session renamed to "${title}"` };
|
||||
} catch {
|
||||
return { handled: true, feedback: 'Failed to rename session.' };
|
||||
}
|
||||
};
|
||||
|
||||
const archiveSession = async (sessionId: string) => {
|
||||
await client.post(`/sessions/${sessionId}/archive`);
|
||||
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||
};
|
||||
|
||||
const deleteSession = async (sessionId: string) => {
|
||||
await client.delete(`/sessions/${sessionId}`);
|
||||
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||
};
|
||||
|
||||
return { sessions, getMessages, saveMessages, renameSession, archiveSession, deleteSession };
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserSettings } from './types/user-settings';
|
||||
import { DEFAULT_SETTINGS } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['USER_SETTINGS'];
|
||||
|
||||
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
|
||||
ai: {
|
||||
enabledModels: saved.ai?.enabledModels?.length ? saved.ai.enabledModels : DEFAULT_SETTINGS.ai.enabledModels,
|
||||
enabledProviders: saved.ai?.enabledProviders ?? DEFAULT_SETTINGS.ai.enabledProviders,
|
||||
},
|
||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||
});
|
||||
|
||||
export const useSettings = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings = DEFAULT_SETTINGS } = useQuery<UserSettings>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const saved = await client.get<Partial<UserSettings>>('/user/settings');
|
||||
return mergeWithDefaults(saved);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (newSettings: UserSettings) => {
|
||||
queryClient.setQueryData(QUERY_KEY, newSettings);
|
||||
await client.put<UserSettings>('/user/settings', newSettings);
|
||||
},
|
||||
[client, queryClient],
|
||||
);
|
||||
|
||||
return { settings, saveSettings };
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useSessions } from './useSessions';
|
||||
|
||||
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
||||
|
||||
type UseSlashCommandsParams = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
||||
const { renameSession } = useSessions();
|
||||
|
||||
const execute = async (input: string): Promise<SlashCommandResult> => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) return { handled: false };
|
||||
|
||||
const spaceIndex = trimmed.indexOf(' ');
|
||||
const command = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
|
||||
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
|
||||
|
||||
switch (command) {
|
||||
case 'rename':
|
||||
return renameSession(sessionId, args);
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
};
|
||||
|
||||
return { execute };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '@/components/ui/ThemeProvider';
|
||||
import { useSettings } from './useSettings';
|
||||
import { DEFAULT_SETTINGS } from './types/user-settings';
|
||||
|
||||
export const useThemeSync = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const { setTheme } = useTheme();
|
||||
const migrated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
|
||||
// One-time migration: if settings are default and localStorage has a theme, save it
|
||||
if (!migrated.current) {
|
||||
migrated.current = true;
|
||||
const lsTheme = localStorage.getItem('officer-theme');
|
||||
if (
|
||||
lsTheme &&
|
||||
settings.appearance.theme === DEFAULT_SETTINGS.appearance.theme &&
|
||||
lsTheme !== settings.appearance.theme
|
||||
) {
|
||||
const updated = { ...settings, appearance: { ...settings.appearance, theme: lsTheme } };
|
||||
saveSettings(updated);
|
||||
setTheme(lsTheme);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(settings.appearance.theme);
|
||||
}, [settings?.appearance.theme]);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['USER_STATE'];
|
||||
|
||||
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {} } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
// Immediate fire-and-forget PATCH
|
||||
clientRef.current.patch('/user/state', { [key]: newValue }).catch(() => {});
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
@import "tailwindcss";
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Duck Landing Page Colors */
|
||||
--color-duck-yellow: #F4C430;
|
||||
--color-duck-orange: #EA580C;
|
||||
--color-duck-teal: #0891B2;
|
||||
--color-duck-forest: #166534;
|
||||
--color-duck-dark: #14532D;
|
||||
--color-duck-beige: #E7D4B5;
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-brand: hsl(var(--brand));
|
||||
--color-brand-foreground: hsl(var(--brand-foreground));
|
||||
--color-brand-muted: hsl(var(--brand-muted));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-success: hsl(var(--success));
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--color-sidebar: hsl(var(--sidebar-background));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||
|
||||
--color-cta: hsl(var(--cta));
|
||||
--color-cta-foreground: hsl(var(--cta-foreground));
|
||||
|
||||
--color-ctahover: hsl(var(--ctahover));
|
||||
|
||||
--color-naturegreen: hsl(var(--naturegreen));
|
||||
--color-naturegreen-foreground: hsl(var(--naturegreen-foreground));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out forwards;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out forwards;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||
All colors MUST be HSL.
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--cta: 211 74.77% 45.58%;
|
||||
--cta-foreground: 0 0% 100%;
|
||||
|
||||
--naturegreen: 145 79% 38%;
|
||||
--naturegreen-foreground: 0 0% 100%;
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 211 74.77% 45.58%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Chart gradients */
|
||||
--chart-primary: 211 82% 64%;
|
||||
--chart-secondary: 211 82% 84%;
|
||||
|
||||
/* Status colors */
|
||||
--status-success: 142 76% 36%;
|
||||
--status-warning: 43 96% 56%;
|
||||
--status-in-progress: 211 82% 64%;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* Hover Effect */
|
||||
--ctahover: 211 91.53% 34.82%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 211 82% 64%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
|
||||
/* Chart gradients */
|
||||
--chart-primary: 211 82% 64%;
|
||||
--chart-secondary: 211 82% 84%;
|
||||
|
||||
/* Status colors */
|
||||
--status-success: 142 76% 36%;
|
||||
--status-warning: 43 96% 56%;
|
||||
--status-in-progress: 211 82% 64%;
|
||||
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
[data-sonner-toaster] {
|
||||
z-index: 700 !important;
|
||||
}
|
||||
|
||||
/* File Browser — Context menu hover behavior */
|
||||
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) .file-item-ellipsis {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.file-grid [data-file-item][data-state="open"] .file-item-ellipsis {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* File Viewer — Markdown Prose */
|
||||
.file-viewer-md {
|
||||
color: #14532d;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.file-viewer-md h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
margin: 1.5em 0 0.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 2px solid rgba(20, 83, 45, 0.12);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.file-viewer-md h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
margin: 1.4em 0 0.4em;
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid rgba(20, 83, 45, 0.08);
|
||||
}
|
||||
|
||||
.file-viewer-md h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin: 1.2em 0 0.4em;
|
||||
}
|
||||
|
||||
.file-viewer-md h4, .file-viewer-md h5, .file-viewer-md h6 {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.3em;
|
||||
}
|
||||
|
||||
.file-viewer-md p {
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.file-viewer-md a {
|
||||
color: #0891b2;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.file-viewer-md a:hover {
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.file-viewer-md strong {
|
||||
font-weight: 600;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.file-viewer-md blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid #0891b2;
|
||||
background: rgba(8, 145, 178, 0.05);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.file-viewer-md ul, .file-viewer-md ol {
|
||||
margin: 0.75em 0;
|
||||
padding-left: 1.75em;
|
||||
}
|
||||
|
||||
.file-viewer-md li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.file-viewer-md li::marker {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.file-viewer-md hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: rgba(20, 83, 45, 0.12);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.file-viewer-md table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.file-viewer-md th {
|
||||
background: rgba(8, 145, 178, 0.08);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid rgba(20, 83, 45, 0.12);
|
||||
}
|
||||
|
||||
.file-viewer-md td {
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid rgba(20, 83, 45, 0.08);
|
||||
}
|
||||
|
||||
.file-viewer-md tr:nth-child(even) {
|
||||
background: rgba(20, 83, 45, 0.02);
|
||||
}
|
||||
|
||||
.file-viewer-md img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.file-viewer-md input[type="checkbox"] {
|
||||
accent-color: #0891b2;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* Skills — Markdown Prose */
|
||||
.skill-md {
|
||||
color: #14532d;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.skill-md h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
margin: 1.5em 0 0.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 2px solid rgba(20, 83, 45, 0.12);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.skill-md h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
margin: 1.4em 0 0.4em;
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid rgba(20, 83, 45, 0.08);
|
||||
}
|
||||
|
||||
.skill-md h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin: 1.2em 0 0.4em;
|
||||
}
|
||||
|
||||
.skill-md h4, .skill-md h5, .skill-md h6 {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.3em;
|
||||
}
|
||||
|
||||
.skill-md p {
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.skill-md a {
|
||||
color: #0891b2;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.skill-md a:hover {
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.skill-md strong {
|
||||
font-weight: 600;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.skill-md blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid #0891b2;
|
||||
background: rgba(8, 145, 178, 0.05);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.skill-md ul, .skill-md ol {
|
||||
margin: 0.75em 0;
|
||||
padding-left: 1.75em;
|
||||
}
|
||||
|
||||
.skill-md li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.skill-md li::marker {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.skill-md code {
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(8, 145, 178, 0.1);
|
||||
color: #0891b2;
|
||||
font-size: 0.85em;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.skill-md pre {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
border-radius: 0.5rem;
|
||||
background: #0d1117;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.skill-md pre code {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: #e6edf3;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.skill-md hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: rgba(20, 83, 45, 0.12);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.skill-md table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.skill-md th {
|
||||
background: rgba(8, 145, 178, 0.08);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid rgba(20, 83, 45, 0.12);
|
||||
}
|
||||
|
||||
.skill-md td {
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid rgba(20, 83, 45, 0.08);
|
||||
}
|
||||
|
||||
.skill-md tr:nth-child(even) {
|
||||
background: rgba(20, 83, 45, 0.02);
|
||||
}
|
||||
|
||||
.skill-md img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.skill-md input[type="checkbox"] {
|
||||
accent-color: #0891b2;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "./globals.css";
|
||||
@@ -0,0 +1,100 @@
|
||||
# Database Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
Three PostgreSQL databases managed with Drizzle ORM:
|
||||
|
||||
| Database | Purpose | Package |
|
||||
|----------|---------|---------|
|
||||
| `officer_db` | Main app data | `officerdb` |
|
||||
| `ephemeral_db` | Cache & temporary data | `ephemeraldb` |
|
||||
|
||||
## Type System
|
||||
|
||||
### File Structure
|
||||
|
||||
Each database package has:
|
||||
```
|
||||
db_name/
|
||||
├── src/
|
||||
│ ├── index.ts # DB connection, exports schema + drizzle helpers
|
||||
│ ├── types.ts # All type exports (Select, Insert, extended)
|
||||
│ └── schema/
|
||||
│ ├── index.ts # Re-exports all schema files
|
||||
│ └── *.ts # Table definitions
|
||||
└── package.json # Exports: "." and "./types"
|
||||
```
|
||||
|
||||
### Type Naming Convention
|
||||
|
||||
```ts
|
||||
// Pattern 1: Simple table (no relations needed in API)
|
||||
export type Screenshot = typeof Schema.Screenshots.$inferSelect;
|
||||
export type ScreenshotInsert = typeof Schema.Screenshots.$inferInsert;
|
||||
|
||||
// Pattern 2: Table with relations (for hydrated API responses)
|
||||
export type UserSelect = typeof Schema.Users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.Users.$inferInsert;
|
||||
export type User = UserSelect & {
|
||||
company: Company;
|
||||
passkeys: Passkey[];
|
||||
// computed fields
|
||||
passkeyCount: number;
|
||||
};
|
||||
```
|
||||
|
||||
### Type Organization
|
||||
|
||||
Organize types by domain with comments:
|
||||
|
||||
```ts
|
||||
// officerdb/types.ts
|
||||
|
||||
// Auth
|
||||
export type PasskeySelect = ...
|
||||
export type Passkey = PasskeySelect & { user: User };
|
||||
|
||||
// Companies & Websites
|
||||
export type CompanySelect = ...
|
||||
export type Company = CompanySelect & { ... };
|
||||
|
||||
// Experiments
|
||||
export type ExperimentSelect = ...
|
||||
export type Experiment = ExperimentSelect & { ... };
|
||||
```
|
||||
|
||||
## Importing Types
|
||||
|
||||
```ts
|
||||
// ✅ Good - import from types subpath
|
||||
import type { User } from 'officerdb/types';
|
||||
|
||||
// ✅ Good - import schema/connection from main
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
|
||||
// ❌ Bad - don't define manual types in schema files
|
||||
// ❌ Bad - don't import types from schema directly
|
||||
```
|
||||
|
||||
## Null Safety
|
||||
|
||||
Drizzle-inferred types correctly reflect nullable columns. Add guards when needed:
|
||||
|
||||
```ts
|
||||
export async function handleOauthAccount(account: GanOauth) {
|
||||
// Guard for nullable fields
|
||||
if (!account.refreshToken || !account.companyId) {
|
||||
return;
|
||||
}
|
||||
// Now TypeScript knows these are non-null
|
||||
const token = await refreshAccessToken(account.refreshToken);
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Best Practices
|
||||
|
||||
- Use `bigserial` with `mode: 'number'` for IDs
|
||||
- Use `bigint` with `mode: 'number'` for foreign keys
|
||||
- Always add indexes for frequently queried columns
|
||||
- Use `varchar` with explicit length limits
|
||||
- Timestamps: `timestamp('...', { withTimezone: true })`
|
||||
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '../../../.env' });
|
||||
|
||||
const { POSTGRES_URL } = process.env;
|
||||
|
||||
export default {
|
||||
schema: './src/schema/index.ts',
|
||||
out: './migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: POSTGRES_URL,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TYPE "public"."user_roles" AS ENUM('Member', 'Admin', 'Owner', 'Super Admin');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_status" AS ENUM('Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted');--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"email" varchar(256) NOT NULL,
|
||||
"password" varchar(256),
|
||||
"role" "user_roles" DEFAULT 'Member',
|
||||
"status" "user_status" DEFAULT 'Unverified',
|
||||
"name" varchar(128),
|
||||
"avatar" varchar(512000),
|
||||
"password_changed_at" bigint,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "passkeys" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"email" varchar(256) NOT NULL,
|
||||
"origin" varchar(256),
|
||||
"credential_id" text,
|
||||
"public_key" text,
|
||||
"counter" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "passkey_challenges" (
|
||||
"email" varchar(255) NOT NULL,
|
||||
"origin" varchar(512) NOT NULL,
|
||||
"challenge" varchar(512) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "passkey_challenges_email_origin_pk" PRIMARY KEY("email","origin")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "token_blacklist" (
|
||||
"jti" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"expires_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_passkey_challenges_created_at" ON "passkey_challenges" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_token_blacklist_expires_at" ON "token_blacklist" USING btree ("expires_at");
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"id": "b9130f42-0743-4c4b-aaf1-dce52e708e22",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "user_roles",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'Member'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "user_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'Unverified'"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "varchar(512000)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password_changed_at": {
|
||||
"name": "password_changed_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.passkeys": {
|
||||
"name": "passkeys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"credential_id": {
|
||||
"name": "credential_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"counter": {
|
||||
"name": "counter",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.passkey_challenges": {
|
||||
"name": "passkey_challenges",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "varchar(512)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"challenge": {
|
||||
"name": "challenge",
|
||||
"type": "varchar(512)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_passkey_challenges_created_at": {
|
||||
"name": "idx_passkey_challenges_created_at",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"passkey_challenges_email_origin_pk": {
|
||||
"name": "passkey_challenges_email_origin_pk",
|
||||
"columns": [
|
||||
"email",
|
||||
"origin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.token_blacklist": {
|
||||
"name": "token_blacklist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"jti": {
|
||||
"name": "jti",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_token_blacklist_expires_at": {
|
||||
"name": "idx_token_blacklist_expires_at",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.user_roles": {
|
||||
"name": "user_roles",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"Member",
|
||||
"Admin",
|
||||
"Owner",
|
||||
"Super Admin"
|
||||
]
|
||||
},
|
||||
"public.user_status": {
|
||||
"name": "user_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"Unverified",
|
||||
"Active",
|
||||
"Prospect",
|
||||
"Invited",
|
||||
"Blocked",
|
||||
"Banned",
|
||||
"Deleted"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1770915839349,
|
||||
"tag": "0000_broken_gauntlet",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "officerdb",
|
||||
"version": "0.0.1",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"generate": "bun x drizzle-kit generate",
|
||||
"push": "bun x drizzle-kit push && bun run sps",
|
||||
"studio": "bun x drizzle-kit studio",
|
||||
"sps": "./run_migrations_sp.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"definitions": "workspace:*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.8"
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run all stored procedure migrations in order
|
||||
# This script applies SQL files from the stored-procedures directory to the statistics database
|
||||
# It parses the POSTGRES_URL from the root .env file
|
||||
|
||||
set -e
|
||||
|
||||
# Find the root .env file
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/../../../.env"
|
||||
MIGRATIONS_DIR="$SCRIPT_DIR/src/stored-procedures"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Error: .env file not found at $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse POSTGRES_URL from .env and strip quotes and carriage returns
|
||||
POSTGRES_URL=$(grep "^POSTGRES_URL=" "$ENV_FILE" | cut -d'=' -f2- | sed 's/^"//;s/"$//' | tr -d '\r\n')
|
||||
|
||||
if [ -z "$POSTGRES_URL" ]; then
|
||||
echo "Error: POSTGRES_URL not found in .env file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse PostgreSQL connection string
|
||||
# Format: postgres://user:password@host:port/database
|
||||
DB_USER=$(printf '%s' "$POSTGRES_URL" | sed -E 's|postgres://([^:]+):.*|\1|')
|
||||
DB_PASSWORD=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')
|
||||
DB_HOST=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*@([^:]+):.*|\1|')
|
||||
DB_PORT=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*@[^:]+:([0-9]+)/.*|\1|')
|
||||
DB_NAME=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*:[0-9]+/([^?]+).*|\1|')
|
||||
|
||||
echo "Running stored procedure migrations from: $MIGRATIONS_DIR"
|
||||
echo "Database: postgres://$DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"
|
||||
|
||||
# Get all .sql files sorted by name
|
||||
MIGRATIONS=$(find "$MIGRATIONS_DIR" -name "*.sql" -type f | sort)
|
||||
|
||||
if [ -z "$MIGRATIONS" ]; then
|
||||
echo "No migrations found in $MIGRATIONS_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for migration_file in $MIGRATIONS; do
|
||||
migration_name=$(basename "$migration_file")
|
||||
echo "Applying migration: $migration_name"
|
||||
|
||||
# Execute the migration file with password from environment
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -p "$DB_PORT" -f "$migration_file"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Successfully applied: $migration_name"
|
||||
else
|
||||
echo "✗ Failed to apply: $migration_name"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All stored procedure migrations completed successfully!"
|
||||
@@ -0,0 +1,15 @@
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '../../../../.env' });
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as Schema from './schema';
|
||||
export * from './schema';
|
||||
export * from 'drizzle-orm';
|
||||
|
||||
const { POSTGRES_URL } = process.env;
|
||||
console.log('POSTGRES_URL', POSTGRES_URL);
|
||||
const pgClient = postgres(POSTGRES_URL!);
|
||||
|
||||
const officerdb = drizzle(pgClient, { schema: Schema });
|
||||
|
||||
export { officerdb, pgClient };
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './users';
|
||||
export * from './passkeys';
|
||||
export * from './passkey-challenges';
|
||||
export * from './token-blacklist';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { pgTable, varchar, timestamp, index, primaryKey } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const PasskeyChallenges = pgTable(
|
||||
'passkey_challenges',
|
||||
{
|
||||
email: varchar('email', { length: 255 }).notNull(),
|
||||
origin: varchar('origin', { length: 512 }).notNull(),
|
||||
challenge: varchar('challenge', { length: 512 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.email, table.origin] }),
|
||||
index('idx_passkey_challenges_created_at').on(table.createdAt),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { pgTable, varchar, text, integer } from 'drizzle-orm/pg-core';
|
||||
import { bigserial } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { Users } from './users';
|
||||
|
||||
export const Passkeys = pgTable('passkeys', {
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
email: varchar('email', { length: 256 }).notNull(),
|
||||
origin: varchar('origin', { length: 256 }),
|
||||
credentialId: text('credential_id'),
|
||||
publicKey: text('public_key'),
|
||||
counter: integer('counter').notNull().default(0),
|
||||
});
|
||||
|
||||
export const PasskeysRelations = relations(Passkeys, ({ one }) => ({
|
||||
user: one(Users, {
|
||||
fields: [Passkeys.email],
|
||||
references: [Users.email],
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,10 @@
|
||||
import { pgTable, varchar, bigint, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const TokenBlacklist = pgTable(
|
||||
'token_blacklist',
|
||||
{
|
||||
jti: varchar('jti', { length: 64 }).primaryKey(),
|
||||
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(table) => [index('idx_token_blacklist_expires_at').on(table.expiresAt)],
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { pgTable, pgEnum, varchar } from 'drizzle-orm/pg-core';
|
||||
import { bigint, bigserial } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { Passkeys } from './passkeys';
|
||||
import { USER_STATUSES, USER_ROLES } from 'definitions';
|
||||
|
||||
export const userStatusEnum = pgEnum('user_status', USER_STATUSES);
|
||||
export const userRolesEnum = pgEnum('user_roles', USER_ROLES);
|
||||
|
||||
export const Users = pgTable('users', {
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
email: varchar('email', { length: 256 }).unique().notNull(),
|
||||
password: varchar('password', { length: 256 }),
|
||||
role: userRolesEnum('role').default(USER_ROLES[0]),
|
||||
status: userStatusEnum('status').default(USER_STATUSES[0]),
|
||||
name: varchar('name', { length: 128 }),
|
||||
avatar: varchar('avatar', { length: 512000 }),
|
||||
passwordChangedAt: bigint('password_changed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const UsersRelations = relations(Users, ({ many }) => ({
|
||||
passkeys: many(Passkeys),
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class
|
||||
WHERE relname = 'passkey_challenges'
|
||||
AND relpersistence = 'p'
|
||||
) THEN
|
||||
ALTER TABLE passkey_challenges SET UNLOGGED;
|
||||
RAISE NOTICE 'passkey_challenges set to UNLOGGED';
|
||||
ELSE
|
||||
RAISE NOTICE 'passkey_challenges already UNLOGGED or does not exist';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class
|
||||
WHERE relname = 'token_blacklist'
|
||||
AND relpersistence = 'p'
|
||||
) THEN
|
||||
ALTER TABLE token_blacklist SET UNLOGGED;
|
||||
RAISE NOTICE 'token_blacklist set to UNLOGGED';
|
||||
ELSE
|
||||
RAISE NOTICE 'token_blacklist already UNLOGGED or does not exist';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as Schema from './schema';
|
||||
|
||||
// Auth
|
||||
export type PasskeySelect = typeof Schema.Passkeys.$inferSelect;
|
||||
export type PasskeyInsert = typeof Schema.Passkeys.$inferInsert;
|
||||
export type Passkey = PasskeySelect & {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type UserSelect = typeof Schema.Users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.Users.$inferInsert;
|
||||
export type User = UserSelect & {
|
||||
passkeys: Passkey[];
|
||||
};
|
||||
|
||||
// Security
|
||||
export type PasskeyChallenge = typeof Schema.PasskeyChallenges.$inferSelect;
|
||||
export type PasskeyChallengeInsert = typeof Schema.PasskeyChallenges.$inferInsert;
|
||||
|
||||
export type TokenBlacklist = typeof Schema.TokenBlacklist.$inferSelect;
|
||||
export type TokenBlacklistInsert = typeof Schema.TokenBlacklist.$inferInsert;
|
||||
@@ -0,0 +1,87 @@
|
||||
import './servers/bootstrap';
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { serve } from 'bun';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { honoServer, loadPlugins } from './servers/hono';
|
||||
import { verify } from './servers/jwt';
|
||||
import { officerdb, TokenBlacklist } from 'officerdb';
|
||||
import { claudeWebsocket } from './servers/api/claude/websocket';
|
||||
import { opencodeWebsocket } from './servers/api/opencode/websocket';
|
||||
import { terminalWebsocket } from 'plugins/Terminal/server';
|
||||
import officerWeb from './apps/officer-web/index.html';
|
||||
|
||||
const { PORT = '5000' } = process.env;
|
||||
await loadPlugins();
|
||||
|
||||
type WSData = { userId: number; email: string; provider: 'claude' | 'opencode' | 'terminal' };
|
||||
|
||||
const handlers: Record<string, typeof claudeWebsocket> = {
|
||||
claude: claudeWebsocket,
|
||||
opencode: opencodeWebsocket,
|
||||
terminal: terminalWebsocket,
|
||||
};
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: 'claude' | 'opencode' | 'terminal') {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
try {
|
||||
const user = await verify(token);
|
||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
if (user.jti) {
|
||||
const blacklisted = await officerdb.query.TokenBlacklist.findFirst({
|
||||
where: eq(TokenBlacklist.jti, user.jti),
|
||||
});
|
||||
if (blacklisted) return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const ok = server.upgrade(req, { data: { userId: user.id, email: user.email, provider } });
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||
} catch {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const server = serve({
|
||||
port: Number(PORT),
|
||||
idleTimeout: 60,
|
||||
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
|
||||
routes: {
|
||||
'/static/*': async (req) => {
|
||||
const path = new URL(req.url).pathname.slice('/static'.length);
|
||||
const file = Bun.file(`public${path}`);
|
||||
if (await file.exists()) return new Response(file);
|
||||
return new Response(null, { status: 404 });
|
||||
},
|
||||
'/api/harness/claudecode/ws': (req, server) => upgradeWs(req, server, 'claude'),
|
||||
'/api/harness/opencode/ws': (req, server) => upgradeWs(req, server, 'opencode'),
|
||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||
'/': officerWeb,
|
||||
'/*': officerWeb,
|
||||
'/api': honoServer.fetch,
|
||||
'/api/*': honoServer.fetch,
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
||||
handlers[provider]!.open(ws as any);
|
||||
},
|
||||
message(ws, raw) {
|
||||
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
||||
handlers[provider]!.message(ws as any, raw);
|
||||
},
|
||||
close(ws) {
|
||||
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
||||
handlers[provider]!.close(ws as any);
|
||||
},
|
||||
drain() {},
|
||||
},
|
||||
|
||||
development: process.env.NODE_ENV !== 'production' && {
|
||||
hmr: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`🚀 Server running at ${server.url}`);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
|
||||
export const bodyParser: () => MiddlewareHandler = () => async (ctx, next) => {
|
||||
if (!['POST', 'PUT', 'PATCH'].includes(ctx.req.method.toUpperCase())) {
|
||||
ctx.set('body', {});
|
||||
return next();
|
||||
}
|
||||
|
||||
const contentType = ctx.req.header('Content-Type') || '';
|
||||
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
const text = await ctx.req.text();
|
||||
const body = text ? JSON.parse(text) : {};
|
||||
ctx.set('body', body);
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const body = await ctx.req.parseBody();
|
||||
ctx.set('body', body);
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
const body = await ctx.req.parseBody({ all: true });
|
||||
ctx.set('body', body);
|
||||
} else {
|
||||
// No recognized content type, set empty body
|
||||
ctx.set('body', {});
|
||||
}
|
||||
} catch (ex) {
|
||||
// throw errors.BAD_REQUEST('Invalid request body');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './body-parser';
|
||||
export * from './user-middleware';
|
||||
export * from './origin-middleware';
|
||||
export * from './origin-validation';
|
||||
export * from './rate-limiter';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
export const originMiddleware: MiddlewareHandler = function (ctx, next) {
|
||||
let origin = ctx.req.header('origin');
|
||||
|
||||
if (!origin) {
|
||||
const referer = ctx.req.header('referer');
|
||||
if (referer) {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
origin = url.origin;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.set('origin', origin);
|
||||
return next();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user