Workspaces in Workspaces all around
This commit is contained in:
@@ -36,7 +36,30 @@ trigger:
|
|||||||
inputs:
|
inputs:
|
||||||
- name: input_name
|
- name: input_name
|
||||||
description: What this input is.
|
description: What this input is.
|
||||||
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
- name: count
|
||||||
|
description: How many items to process.
|
||||||
|
type: number
|
||||||
|
default: 10
|
||||||
|
required: false
|
||||||
|
- name: verbose
|
||||||
|
description: Enable verbose output.
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
required: false
|
||||||
|
- name: format
|
||||||
|
description: Output format.
|
||||||
|
type: select
|
||||||
|
default: json
|
||||||
|
required: false
|
||||||
|
options:
|
||||||
|
- value: json
|
||||||
|
label: JSON
|
||||||
|
- value: csv
|
||||||
|
label: CSV
|
||||||
|
- value: md
|
||||||
|
label: Markdown
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,7 +79,12 @@ inputs:
|
|||||||
| `inputs` | object[] | no | Inputs the task expects. |
|
| `inputs` | object[] | no | Inputs the task expects. |
|
||||||
| `inputs[].name` | string | yes | Name of the input parameter. |
|
| `inputs[].name` | string | yes | Name of the input parameter. |
|
||||||
| `inputs[].description` | string | yes | Description of the input. |
|
| `inputs[].description` | string | yes | Description of the input. |
|
||||||
|
| `inputs[].type` | string | no | Input type. Determines how a task runner renders the input. One of: `string`, `number`, `boolean`, `select`. Defaults to `string`. |
|
||||||
|
| `inputs[].default` | any | no | Default value for the input. |
|
||||||
| `inputs[].required` | boolean | no | Whether the input is required. |
|
| `inputs[].required` | boolean | no | Whether the input is required. |
|
||||||
|
| `inputs[].options` | object[] | no | Available choices when `type` is `select`. Each option has a `value` and a `label`. |
|
||||||
|
| `inputs[].options[].value` | string | yes | The value passed to the task. |
|
||||||
|
| `inputs[].options[].label` | string | yes | Human-readable label displayed in the UI. |
|
||||||
|
|
||||||
### Body
|
### Body
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export function App() {
|
|||||||
<Route path="/settings/server" element={<Dashboard.ServerSettings />} />
|
<Route path="/settings/server" element={<Dashboard.ServerSettings />} />
|
||||||
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
|
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
|
||||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||||
<Route path="/chat" element={<Dashboard.SessionList />} />
|
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/chat/new" element={<Dashboard.NewChat />} />
|
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
||||||
<Route path="/chat/:sessionId" element={<Dashboard.ClaudeChat />} />
|
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/chat/opencode/new" element={<Dashboard.OpenCodeChat />} />
|
<Route path="/chat/opencode/new" element={<Dashboard.SessionListPage provider="opencode" isNew />} />
|
||||||
<Route path="/chat/opencode/:sessionId" element={<Dashboard.OpenCodeChat />} />
|
<Route path="/chat/opencode/:sessionId" element={<Dashboard.SessionListPage provider="opencode" />} />
|
||||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
<Route path="/plans" element={<Dashboard.Plans />} />
|
||||||
<Route path="/files" element={<Dashboard.FilesPage />} />
|
<Route path="/files" element={<Dashboard.FilesPage />} />
|
||||||
<Route path="/terminal" element={<Dashboard.Terminal />} />
|
<Route path="/terminal" element={<Dashboard.Terminal />} />
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ForgotPassword() {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">Email</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function ResetPassword() {
|
|||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">Password</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Min 12 characters"
|
placeholder="Min 12 characters"
|
||||||
@@ -50,7 +50,7 @@ export function ResetPassword() {
|
|||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
<span className="text-duck-dark/70">Confirm Password</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function Login() {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">Email</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
@@ -59,7 +59,7 @@ export function Login() {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">Password</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Background } from './Background';
|
import { Background } from './Background';
|
||||||
|
|
||||||
type AuthenticationLayoutProps = {
|
type AuthenticationLayoutProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||||
@@ -13,9 +15,6 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const VerifyScreen = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">Email</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
disabled
|
disabled
|
||||||
@@ -48,7 +48,7 @@ export const VerifyScreen = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Name</span>
|
<span className="text-duck-dark/70">Name</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
@@ -59,7 +59,7 @@ export const VerifyScreen = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Username</span>
|
<span className="text-duck-dark/70">Username</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
placeholder="your-username"
|
placeholder="your-username"
|
||||||
@@ -71,7 +71,7 @@ export const VerifyScreen = () => {
|
|||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">Password</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Min 12 characters"
|
placeholder="Min 12 characters"
|
||||||
@@ -82,7 +82,7 @@ export const VerifyScreen = () => {
|
|||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
<span className="text-duck-dark/70">Confirm Password</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { X } from 'lucide-react';
|
import { toast } from 'sonner';
|
||||||
|
import { Trash2, X } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { CapabilityChat } from '../CapabilityPage';
|
import { CapabilityChat } from '../CapabilityPage';
|
||||||
@@ -10,6 +12,7 @@ export const AutomationEditChat = () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||||
|
const [chatKey, setChatKey] = useState(0);
|
||||||
|
|
||||||
const { data: detail } = useQuery<CapabilityDetail>({
|
const { data: detail } = useQuery<CapabilityDetail>({
|
||||||
queryKey: [selection?.queryKey, selection?.dirName],
|
queryKey: [selection?.queryKey, selection?.dirName],
|
||||||
@@ -22,30 +25,48 @@ export const AutomationEditChat = () => {
|
|||||||
setSelection({ ...selection, editing: false });
|
setSelection({ ...selection, editing: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteChat = async () => {
|
||||||
|
if (!selection) return;
|
||||||
|
try {
|
||||||
|
await client.delete(`${selection.endpoint}/${selection.dirName}/chat`);
|
||||||
|
await qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] });
|
||||||
|
setChatKey((k) => k + 1);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete chat');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!selection || !detail?.filePath) {
|
if (!selection || !detail?.filePath) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<p className="text-sm text-duck-dark/40">Select a resource and click edit to chat</p>
|
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">Select a resource and click edit to chat</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name ?? 'Chat'}</span>
|
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">{detail.name ?? 'Chat'}</span>
|
||||||
<button onClick={closeChat} className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors">
|
<button
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
onClick={deleteChat}
|
||||||
|
className="p-1 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||||
|
title="Delete chat history"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
|
||||||
|
</button>
|
||||||
|
<button onClick={closeChat} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
|
||||||
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<CapabilityChat
|
<CapabilityChat
|
||||||
kind={selection.kind.toLowerCase()}
|
kind={selection.kind.toLowerCase()}
|
||||||
key={detail.filePath}
|
key={`${detail.filePath}-${chatKey}`}
|
||||||
endpoint={selection.endpoint}
|
endpoint={selection.endpoint}
|
||||||
dirName={selection.dirName}
|
dirName={selection.dirName}
|
||||||
filePath={detail.filePath}
|
filePath={detail.filePath}
|
||||||
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
||||||
chatSessionId={detail.chatSessionId}
|
chatSessionId={chatKey > 0 ? null : detail.chatSessionId}
|
||||||
isNew={selection.isNew}
|
isNew={selection.isNew}
|
||||||
description={selection.description}
|
description={selection.description}
|
||||||
onResponseEnd={() => qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] })}
|
onResponseEnd={() => qc.invalidateQueries({ queryKey: [selection.queryKey, selection.dirName] })}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ArrowLeft, Pencil, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Pencil, Play, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -13,6 +13,53 @@ import { Card } from '@/components/Card';
|
|||||||
import { FrontmatterBlock } from '../CapabilityPage';
|
import { FrontmatterBlock } from '../CapabilityPage';
|
||||||
import type { CapabilityDetail } from '../CapabilityPage';
|
import type { CapabilityDetail } from '../CapabilityPage';
|
||||||
import type { AutomationSelection } from './AutomationRightPanel';
|
import type { AutomationSelection } from './AutomationRightPanel';
|
||||||
|
import { TaskRunnerModal } from '../Files/Screen/TaskRunnerModal';
|
||||||
|
|
||||||
|
type SelectOption = { value: string; label: string };
|
||||||
|
|
||||||
|
type TaskInput = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'select';
|
||||||
|
required: boolean;
|
||||||
|
default: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseInputs(rawYaml: string): TaskInput[] {
|
||||||
|
// Match the inputs block: everything indented after "inputs:" until next top-level key or end
|
||||||
|
const inputsMatch = rawYaml.match(/^inputs:\s*\n((?:[ \t]+.*\n?)*)/m);
|
||||||
|
if (!inputsMatch) return [];
|
||||||
|
const block = inputsMatch[1]!;
|
||||||
|
// Split on list items that have a "name:" field (e.g. " - name: country")
|
||||||
|
const items = block.split(/(?=[ \t]+-\s*name\s*:)/);
|
||||||
|
return items
|
||||||
|
.filter((item) => /name\s*:/.test(item))
|
||||||
|
.map((item) => {
|
||||||
|
const name = item.match(/name\s*:\s*(.+)/)?.[1]?.trim() ?? '';
|
||||||
|
const description = item.match(/description\s*:\s*(.+)/)?.[1]?.trim() ?? '';
|
||||||
|
const rawType = item.match(/type\s*:\s*(.+)/)?.[1]?.trim() ?? 'string';
|
||||||
|
const type = (['string', 'number', 'boolean', 'select'] as const).includes(rawType as 'string')
|
||||||
|
? (rawType as TaskInput['type'])
|
||||||
|
: 'string';
|
||||||
|
const required = item.match(/required\s*:\s*(.+)/)?.[1]?.trim() === 'true';
|
||||||
|
const defaultFromField = item.match(/default\s*:\s*(.+)/)?.[1]?.trim();
|
||||||
|
const defaultFromDesc = description.match(/[Dd]efaults?\s+to\s+(\S+?)\.?\s*$/)?.[1];
|
||||||
|
|
||||||
|
const options: SelectOption[] = [];
|
||||||
|
const optionsMatch = item.match(/options\s*:\s*\n((?:[ \t]+.*\n?)*)/);
|
||||||
|
if (optionsMatch) {
|
||||||
|
const optionEntries = optionsMatch[1]!.split(/(?=[ \t]*- \s*value\s*:)/);
|
||||||
|
for (const entry of optionEntries) {
|
||||||
|
const value = entry.match(/value\s*:\s*(.+)/)?.[1]?.trim() ?? '';
|
||||||
|
const label = entry.match(/label\s*:\s*(.+)/)?.[1]?.trim() ?? value;
|
||||||
|
if (value) options.push({ value, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, description, type, required, default: defaultFromField ?? defaultFromDesc ?? '', options };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type CapabilityDetailViewProps = {
|
type CapabilityDetailViewProps = {
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -28,12 +75,45 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
|
const [showInputForm, setShowInputForm] = useState(false);
|
||||||
|
const [runPrompt, setRunPrompt] = useState<string | null>(null);
|
||||||
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const { data: detail } = useQuery<CapabilityDetail>({
|
const { data: detail } = useQuery<CapabilityDetail>({
|
||||||
queryKey: [queryKey, dirName],
|
queryKey: [queryKey, dirName],
|
||||||
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${dirName}`),
|
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${dirName}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taskInputs = useMemo(() => (detail?.rawFrontmatter ? parseInputs(detail.rawFrontmatter) : []), [detail?.rawFrontmatter]);
|
||||||
|
|
||||||
|
const handleRunClick = () => {
|
||||||
|
if (taskInputs.length > 0) {
|
||||||
|
const defaults: Record<string, string> = {};
|
||||||
|
for (const input of taskInputs) {
|
||||||
|
if (input.type === 'boolean') {
|
||||||
|
defaults[input.name] = input.default === 'true' ? 'true' : 'false';
|
||||||
|
} else if (input.type === 'select') {
|
||||||
|
defaults[input.name] = input.default || (input.options[0]?.value ?? '');
|
||||||
|
} else {
|
||||||
|
defaults[input.name] = input.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInputValues(defaults);
|
||||||
|
setShowInputForm(true);
|
||||||
|
} else {
|
||||||
|
setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputSubmit = () => {
|
||||||
|
const parts = Object.entries(inputValues)
|
||||||
|
.filter(([, v]) => v !== '')
|
||||||
|
.map(([k, v]) => `- ${k}: ${v}`);
|
||||||
|
const suffix = parts.length > 0 ? `\n\nInputs:\n${parts.join('\n')}` : '';
|
||||||
|
setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them${suffix}`);
|
||||||
|
setShowInputForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleEditing = () => {
|
const toggleEditing = () => {
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
setSelection({ ...selection, editing: !editing });
|
setSelection({ ...selection, editing: !editing });
|
||||||
@@ -53,24 +133,33 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="h-full overflow-hidden flex flex-col min-h-0">
|
<Card className="h-full overflow-hidden flex flex-col min-h-0">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
||||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail?.name ?? `Loading...`}</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{detail?.name ?? `Loading...`}</span>
|
||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
|
{kind === 'Task' && (
|
||||||
|
<button
|
||||||
|
onClick={handleRunClick}
|
||||||
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
|
title="Run task"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggleEditing}
|
onClick={toggleEditing}
|
||||||
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
className={`p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/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'}`} />
|
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50 dark:text-foreground/50'}`} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(true)}
|
onClick={() => setDeleteConfirm(true)}
|
||||||
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
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" />
|
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -84,7 +173,7 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</article>
|
</article>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
|
<p className="text-sm text-duck-dark/40 dark:text-foreground/40 text-center mt-12">Empty file</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -107,6 +196,89 @@ export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editin
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showInputForm} onOpenChange={setShowInputForm}>
|
||||||
|
<DialogContent className="sm:max-w-lg z-[700]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Run {detail?.name}</DialogTitle>
|
||||||
|
<DialogDescription>Configure inputs before running.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleInputSubmit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 mt-1"
|
||||||
|
>
|
||||||
|
{taskInputs.map((input) => (
|
||||||
|
<div key={input.name} className="flex flex-col gap-1">
|
||||||
|
{input.type === 'boolean' ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-foreground/70 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inputValues[input.name] === 'true'}
|
||||||
|
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.checked ? 'true' : 'false' }))}
|
||||||
|
className="h-4 w-4 rounded border-foreground/20 accent-duck-teal"
|
||||||
|
/>
|
||||||
|
{input.name}
|
||||||
|
{input.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="text-sm font-medium text-foreground/70">
|
||||||
|
{input.name}
|
||||||
|
{input.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{input.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
value={inputValues[input.name] ?? ''}
|
||||||
|
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
|
||||||
|
required={input.required}
|
||||||
|
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
|
>
|
||||||
|
{input.options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={input.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={inputValues[input.name] ?? ''}
|
||||||
|
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
|
||||||
|
placeholder={input.default || undefined}
|
||||||
|
required={input.required}
|
||||||
|
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{input.description && (
|
||||||
|
<span className="text-xs text-foreground/40">{input.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowInputForm(false)} className="cursor-pointer">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer">
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{runPrompt !== null && detail && (
|
||||||
|
<TaskRunnerModal
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRunPrompt(null);
|
||||||
|
}}
|
||||||
|
task={{ dirName, name: detail.name, description: detail.description ?? '', scope: detail.scope ?? 'user', triggers: [], filePath: detail.filePath }}
|
||||||
|
promptOverride={runPrompt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,23 +35,23 @@ export const CapabilityList = ({ kind, endpoint, queryKey }: CapabilityListProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">{kind}s</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{kind}s</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '', creating: true })}
|
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '', creating: true })}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 text-duck-dark/50" />
|
<Plus className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-3">
|
||||||
<div className="relative">
|
<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" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30 dark:text-foreground/30" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
className="w-full rounded border border-duck-dark/15 bg-white/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
className="w-full rounded border border-duck-dark/15 dark:border-foreground/15 bg-background/80 pl-7 pr-2 py-1.5 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,18 +63,18 @@ export const CapabilityList = ({ kind, endpoint, queryKey }: CapabilityListProps
|
|||||||
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: c.dirName })}
|
onClick={() => setSelection({ kind, endpoint, queryKey, dirName: c.dirName })}
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${
|
||||||
selection?.queryKey === queryKey && selection.dirName === c.dirName
|
selection?.queryKey === queryKey && selection.dirName === c.dirName
|
||||||
? 'bg-duck-teal/10 text-duck-dark'
|
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||||
: 'text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark'
|
: 'text-duck-dark/60 dark:text-foreground/60 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{c.name}</span>
|
<span className="font-medium">{c.name}</span>
|
||||||
{c.description && (
|
{c.description && (
|
||||||
<p className="text-xs text-duck-dark/40 mt-0.5 line-clamp-1">{c.description}</p>
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 mt-0.5 line-clamp-1">{c.description}</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40 px-3 py-4 text-center">
|
||||||
{items.length === 0 ? `No ${kind.toLowerCase()}s yet` : 'No matches'}
|
{items.length === 0 ? `No ${kind.toLowerCase()}s yet` : 'No matches'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewCron = ({ selection }: NewCronProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Cron</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Cron</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewCron = ({ selection }: NewCronProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Cron name..."
|
placeholder="Cron name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this cron job schedules..."
|
placeholder="Describe what this cron job schedules..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewPipeline = ({ selection }: NewPipelineProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Pipeline</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Pipeline</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewPipeline = ({ selection }: NewPipelineProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Pipeline name..."
|
placeholder="Pipeline name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this pipeline orchestrates..."
|
placeholder="Describe what this pipeline orchestrates..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewProcess = ({ selection }: NewProcessProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Process</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Process</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewProcess = ({ selection }: NewProcessProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Process name..."
|
placeholder="Process name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this process manages..."
|
placeholder="Describe what this process manages..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewService = ({ selection }: NewServiceProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Service</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Service</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewService = ({ selection }: NewServiceProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Service name..."
|
placeholder="Service name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this service provides..."
|
placeholder="Describe what this service provides..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewSkill = ({ selection }: NewSkillProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Skill</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Skill</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewSkill = ({ selection }: NewSkillProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Skill name..."
|
placeholder="Skill name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this skill does..."
|
placeholder="Describe what this skill does..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewTask = ({ selection }: NewTaskProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Task</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Task</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewTask = ({ selection }: NewTaskProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Task name..."
|
placeholder="Task name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this task automates..."
|
placeholder="Describe what this task automates..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -43,18 +43,18 @@ export const NewWorkflow = ({ selection }: NewWorkflowProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">New Workflow</span>
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">New Workflow</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelection(null)}
|
onClick={() => setSelection(null)}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Name</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
@@ -66,17 +66,17 @@ export const NewWorkflow = ({ selection }: NewWorkflowProps) => {
|
|||||||
}}
|
}}
|
||||||
placeholder="Workflow name..."
|
placeholder="Workflow name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-duck-dark/70">Description</label>
|
<label className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="Describe what this workflow automates..."
|
placeholder="Describe what this workflow automates..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="rounded 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-1 focus:ring-duck-teal/30 resize-none"
|
className="rounded border border-duck-dark/20 dark:border-foreground/20 bg-background px-3 py-2 text-sm text-duck-dark dark:text-foreground placeholder:text-duck-dark/30 dark:placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useVisibleClaudeModels } from '@/state/useModels';
|
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||||
|
import { useSettings } from '@/state/useSettings';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import type { ChatMessage } from 'apps/Chat';
|
import type { ChatMessage } from 'apps/Chat';
|
||||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||||
|
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||||
type CapabilitySummary = {
|
type CapabilitySummary = {
|
||||||
dirName: string;
|
dirName: string;
|
||||||
@@ -57,7 +59,11 @@ type CapabilityChatProps = {
|
|||||||
onResponseEnd?: () => void;
|
onResponseEnd?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CapabilityChat = ({
|
type CapabilityChatInnerProps = CapabilityChatProps & {
|
||||||
|
onProviderChange: (p: 'claude' | 'opencode') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CapabilityChatClaude = ({
|
||||||
kind,
|
kind,
|
||||||
endpoint,
|
endpoint,
|
||||||
dirName,
|
dirName,
|
||||||
@@ -67,7 +73,8 @@ export const CapabilityChat = ({
|
|||||||
isNew,
|
isNew,
|
||||||
description,
|
description,
|
||||||
onResponseEnd,
|
onResponseEnd,
|
||||||
}: CapabilityChatProps) => {
|
onProviderChange,
|
||||||
|
}: CapabilityChatInnerProps) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const claudeModels = useVisibleClaudeModels();
|
const claudeModels = useVisibleClaudeModels();
|
||||||
const seedFile = `${kind.toUpperCase()}.md`;
|
const seedFile = `${kind.toUpperCase()}.md`;
|
||||||
@@ -117,6 +124,7 @@ export const CapabilityChat = ({
|
|||||||
chat={claude}
|
chat={claude}
|
||||||
provider="claude"
|
provider="claude"
|
||||||
availableModels={claudeModels}
|
availableModels={claudeModels}
|
||||||
|
onProviderChange={onProviderChange}
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
promptPrefix={promptFrontmatter}
|
promptPrefix={promptFrontmatter}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@@ -124,6 +132,59 @@ export const CapabilityChat = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CapabilityChatOpenCode = ({
|
||||||
|
kind,
|
||||||
|
filePath,
|
||||||
|
resourceDir,
|
||||||
|
isNew,
|
||||||
|
description,
|
||||||
|
onResponseEnd,
|
||||||
|
onProviderChange,
|
||||||
|
}: CapabilityChatInnerProps) => {
|
||||||
|
const openCodeModels = useVisibleOpenCodeModels();
|
||||||
|
const seedFile = `${kind.toUpperCase()}.md`;
|
||||||
|
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||||
|
const defaultInput = isNew
|
||||||
|
? description ?? `Help me create the content for this new ${kind} file`
|
||||||
|
: `Help me understand and improve this ${kind} file`;
|
||||||
|
|
||||||
|
const opencode = useOpenCode(undefined, undefined, { replaceUrl: false });
|
||||||
|
|
||||||
|
const onResponseEndRef = useRef(onResponseEnd);
|
||||||
|
onResponseEndRef.current = onResponseEnd;
|
||||||
|
|
||||||
|
const wasGenerating = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasGenerating.current && !opencode.isGenerating) {
|
||||||
|
onResponseEndRef.current?.();
|
||||||
|
}
|
||||||
|
wasGenerating.current = opencode.isGenerating;
|
||||||
|
}, [opencode.isGenerating]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableChat
|
||||||
|
chat={opencode}
|
||||||
|
provider="opencode"
|
||||||
|
availableModels={openCodeModels}
|
||||||
|
onProviderChange={onProviderChange}
|
||||||
|
defaultInput={defaultInput}
|
||||||
|
promptPrefix={promptFrontmatter}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CapabilityChat = (props: CapabilityChatProps) => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const [provider, setProvider] = useState<'claude' | 'opencode'>(settings.chat.defaultProvider);
|
||||||
|
|
||||||
|
return provider === 'claude' ? (
|
||||||
|
<CapabilityChatClaude key="claude" {...props} onProviderChange={setProvider} />
|
||||||
|
) : (
|
||||||
|
<CapabilityChatOpenCode key="opencode" {...props} onProviderChange={setProvider} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -182,7 +243,7 @@ export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/60 flex items-center justify-between">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
|
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
<button
|
<button
|
||||||
@@ -213,7 +274,7 @@ export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, o
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={`${kind} name...`}
|
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"
|
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background 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
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
@@ -241,7 +302,7 @@ export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, o
|
|||||||
value={internalSearch}
|
value={internalSearch}
|
||||||
onChange={(ev) => setInternalSearch(ev.target.value)}
|
onChange={(ev) => setInternalSearch(ev.target.value)}
|
||||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
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"
|
className="w-full rounded border border-duck-dark/15 bg-background/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>
|
</div>
|
||||||
@@ -353,7 +414,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
|||||||
{/* Right panel — detail + chat */}
|
{/* Right panel — detail + chat */}
|
||||||
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
<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' : ''}`}>
|
<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">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDetail(false)}
|
onClick={() => setShowDetail(false)}
|
||||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||||
@@ -400,7 +461,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
|||||||
|
|
||||||
{editing && detail?.filePath && selected && (
|
{editing && detail?.filePath && selected && (
|
||||||
<Card className="flex-1 overflow-hidden flex flex-col min-h-0">
|
<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">
|
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => setEditing(false)}
|
||||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export const InputArea = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-t border-duck-dark/10 bg-white/60 p-2 md:p-3">
|
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
|
||||||
{commandFeedback && (
|
{commandFeedback && (
|
||||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||||
)}
|
)}
|
||||||
@@ -296,7 +296,7 @@ export const InputArea = ({
|
|||||||
}}
|
}}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
rows={1}
|
rows={1}
|
||||||
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-white/80 px-2 py-1.5 md:px-3 md: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"
|
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md: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 ? (
|
{isGenerating ? (
|
||||||
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
|
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
|
||||||
@@ -343,7 +343,7 @@ export const InputArea = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com"
|
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"
|
className="flex-1 rounded-lg border border-duck-dark/20 bg-background 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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ export const Settings = ({
|
|||||||
{provider === 'claude' ? 'Claude' : 'OpenCode'}
|
{provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-background/60 p-1">
|
||||||
{(['claude', 'opencode'] as const).map((value) => (
|
{(['claude', 'opencode'] as const).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => onProviderChange?.(value)}
|
onClick={() => onProviderChange?.(value)}
|
||||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
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'
|
provider === value ? 'bg-background text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
|
||||||
} ${!onProviderChange ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
} ${!onProviderChange ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
>
|
>
|
||||||
{value === 'claude' ? 'Claude' : 'OpenCode'}
|
{value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||||
|
|||||||
@@ -1,79 +1 @@
|
|||||||
import { useState } from 'react';
|
export {};
|
||||||
import { useParams } from 'react-router';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import type { SessionEntry } from 'apps/Chat';
|
|
||||||
import { useClaude } from './useClaude';
|
|
||||||
import { useOpenCode } from './useOpenCode';
|
|
||||||
import { ChatPanel } from './ChatPanel';
|
|
||||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
import { Trash2, Archive } from 'lucide-react';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
|
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||||
|
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||||
|
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||||
|
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||||
|
|
||||||
|
export type SelectedSession = {
|
||||||
|
id: string;
|
||||||
|
provider: 'claude' | 'opencode';
|
||||||
|
model?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const CHANNEL = 'chat:selected-session';
|
||||||
|
|
||||||
|
type ChatLocationState = {
|
||||||
|
initialMessage?: string;
|
||||||
|
prefillInput?: string;
|
||||||
|
model?: string;
|
||||||
|
cwd?: { root?: string; path: string };
|
||||||
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type DetailBarProps = {
|
||||||
|
provider: 'claude' | 'opencode';
|
||||||
|
sessionTitle: string | undefined;
|
||||||
|
isConnected: boolean;
|
||||||
|
isGenerating: boolean;
|
||||||
|
onArchive: (() => void) | undefined;
|
||||||
|
onDelete: (() => void) | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailBar = ({
|
||||||
|
provider,
|
||||||
|
sessionTitle,
|
||||||
|
isConnected,
|
||||||
|
isGenerating,
|
||||||
|
onArchive,
|
||||||
|
onDelete,
|
||||||
|
}: DetailBarProps) => (
|
||||||
|
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{provider === 'claude' && onArchive && (
|
||||||
|
<button
|
||||||
|
onClick={onArchive}
|
||||||
|
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1 text-duck-dark/40 dark:text-foreground/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 dark:text-foreground/70 truncate px-3">
|
||||||
|
{sessionTitle ?? 'New chat'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type InnerProps = {
|
||||||
|
sessionId: string;
|
||||||
|
model?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClaudeInner = ({ sessionId, model }: InnerProps) => {
|
||||||
|
const chat = useClaude(sessionId, model, { replaceUrl: false });
|
||||||
|
const models = useVisibleClaudeModels();
|
||||||
|
const { sessions, archiveSession, deleteSession } = useChatSessions();
|
||||||
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<DetailBar
|
||||||
|
provider="claude"
|
||||||
|
sessionTitle={sessionTitle}
|
||||||
|
isConnected={chat.isConnected}
|
||||||
|
isGenerating={chat.isGenerating}
|
||||||
|
onArchive={async () => {
|
||||||
|
await archiveSession('claude', sessionId);
|
||||||
|
setSelected(null);
|
||||||
|
window.history.replaceState(null, '', '/chat');
|
||||||
|
}}
|
||||||
|
onDelete={async () => {
|
||||||
|
await deleteSession('claude', sessionId);
|
||||||
|
setSelected(null);
|
||||||
|
window.history.replaceState(null, '', '/chat');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EmbeddableChat chat={chat} provider="claude" availableModels={models} className="flex-1 min-h-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
|
||||||
|
const chat = useOpenCode(sessionId, model, { replaceUrl: false });
|
||||||
|
const models = useVisibleOpenCodeModels();
|
||||||
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<DetailBar
|
||||||
|
provider="opencode"
|
||||||
|
sessionTitle={sessionTitle}
|
||||||
|
isConnected={chat.isConnected}
|
||||||
|
isGenerating={chat.isGenerating}
|
||||||
|
onArchive={undefined}
|
||||||
|
onDelete={async () => {
|
||||||
|
await deleteSession('opencode', sessionId);
|
||||||
|
setSelected(null);
|
||||||
|
window.history.replaceState(null, '', '/chat');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EmbeddableChat chat={chat} provider="opencode" availableModels={models} className="flex-1 min-h-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const locationState = location.state as ChatLocationState;
|
||||||
|
const initialSentRef = useRef(false);
|
||||||
|
const chat = useClaude();
|
||||||
|
const models = useVisibleClaudeModels();
|
||||||
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chat.sessionId) {
|
||||||
|
setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model });
|
||||||
|
}
|
||||||
|
}, [chat.sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationState || initialSentRef.current || !chat.isConnected) return;
|
||||||
|
if (locationState.prefillInput) {
|
||||||
|
initialSentRef.current = true;
|
||||||
|
window.history.replaceState({}, '', location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!locationState.initialMessage) return;
|
||||||
|
initialSentRef.current = true;
|
||||||
|
if (locationState.model) chat.setSelectedModel(locationState.model);
|
||||||
|
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images, locationState.cwd);
|
||||||
|
window.history.replaceState({}, '', location.pathname);
|
||||||
|
}, [chat.isConnected, location.state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<DetailBar
|
||||||
|
provider="claude"
|
||||||
|
sessionTitle={undefined}
|
||||||
|
isConnected={chat.isConnected}
|
||||||
|
isGenerating={chat.isGenerating}
|
||||||
|
onArchive={undefined}
|
||||||
|
onDelete={undefined}
|
||||||
|
/>
|
||||||
|
<EmbeddableChat
|
||||||
|
chat={chat}
|
||||||
|
provider="claude"
|
||||||
|
availableModels={models}
|
||||||
|
onProviderChange={onProviderChange}
|
||||||
|
defaultInput={locationState?.prefillInput ?? ''}
|
||||||
|
cwd={locationState?.cwd}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const locationState = location.state as ChatLocationState;
|
||||||
|
const initialSentRef = useRef(false);
|
||||||
|
const chat = useOpenCode();
|
||||||
|
const models = useVisibleOpenCodeModels();
|
||||||
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chat.sessionId) {
|
||||||
|
setSelected({ id: chat.sessionId, provider: 'opencode', model: chat.model });
|
||||||
|
}
|
||||||
|
}, [chat.sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationState || initialSentRef.current || !chat.isConnected) return;
|
||||||
|
if (locationState.prefillInput) {
|
||||||
|
initialSentRef.current = true;
|
||||||
|
window.history.replaceState({}, '', location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!locationState.initialMessage) return;
|
||||||
|
initialSentRef.current = true;
|
||||||
|
if (locationState.model) chat.setSelectedModel(locationState.model);
|
||||||
|
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
|
||||||
|
window.history.replaceState({}, '', location.pathname);
|
||||||
|
}, [chat.isConnected, location.state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<DetailBar
|
||||||
|
provider="opencode"
|
||||||
|
sessionTitle={undefined}
|
||||||
|
isConnected={chat.isConnected}
|
||||||
|
isGenerating={chat.isGenerating}
|
||||||
|
onArchive={undefined}
|
||||||
|
onDelete={undefined}
|
||||||
|
/>
|
||||||
|
<EmbeddableChat
|
||||||
|
chat={chat}
|
||||||
|
provider="opencode"
|
||||||
|
availableModels={models}
|
||||||
|
onProviderChange={onProviderChange}
|
||||||
|
defaultInput={locationState?.prefillInput ?? ''}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type NewChatPanelProps = {
|
||||||
|
initialProvider?: 'claude' | 'opencode';
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => {
|
||||||
|
const [selected, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
|
const provider = selected?.provider ?? initialProvider;
|
||||||
|
|
||||||
|
const handleProviderChange = (p: 'claude' | 'opencode') => {
|
||||||
|
setSelected({ id: 'new', provider: p });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Once a session is created, the inner component updates selected via the channel
|
||||||
|
if (selected && selected.id !== 'new') {
|
||||||
|
return selected.provider === 'claude' ? (
|
||||||
|
<ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />
|
||||||
|
) : (
|
||||||
|
<OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider === 'claude' ? (
|
||||||
|
<NewClaudeInner key="new-claude" onProviderChange={handleProviderChange} />
|
||||||
|
) : (
|
||||||
|
<NewOpenCodeInner key="new-opencode" onProviderChange={handleProviderChange} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatDetailPanel = () => {
|
||||||
|
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||||
|
Select a session to view
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.id === 'new') {
|
||||||
|
return <NewChatPanel key="new" initialProvider={selected.provider} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected.provider === 'claude' ? (
|
||||||
|
<ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />
|
||||||
|
) : (
|
||||||
|
<OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,66 +1,111 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Card } from '@/components/Card';
|
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
|
import type { SelectedSession } from './ChatDetailPanel';
|
||||||
|
|
||||||
type Filter = 'all' | 'claude' | 'opencode';
|
type Filter = 'all' | 'claude' | 'opencode';
|
||||||
|
|
||||||
export const SessionList = () => {
|
export const SessionList = () => {
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
const { sessions, deleteSession } = useChatSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
|
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
|
const scrolledRef = useRef(false);
|
||||||
|
const selectedRef = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
if (node && !scrolledRef.current) {
|
||||||
|
scrolledRef.current = true;
|
||||||
|
node.scrollIntoView({ block: 'center' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrolledRef.current = false;
|
||||||
|
}, [selected?.id, selected?.provider]);
|
||||||
|
|
||||||
const filtered = filter === 'all' ? sessions : sessions.filter((s) => s.provider === filter);
|
const filtered = filter === 'all' ? sessions : sessions.filter((s) => s.provider === filter);
|
||||||
|
|
||||||
|
const handleSelect = (session: (typeof sessions)[number]) => {
|
||||||
|
setSelected({ id: session.id, provider: session.provider, model: session.model ?? null });
|
||||||
|
const path = session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`;
|
||||||
|
window.history.replaceState(null, '', path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (provider: 'claude' | 'opencode', id: string) => {
|
||||||
|
if (selected?.id === id && selected?.provider === provider) {
|
||||||
|
setSelected(null);
|
||||||
|
window.history.replaceState(null, '', '/chat');
|
||||||
|
}
|
||||||
|
await deleteSession(provider, id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full items-center p-4 md:p-6">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<Card className="w-full max-w-2xl flex flex-col gap-4 h-full p-4 md:p-6 overflow-hidden">
|
{/* Header */}
|
||||||
{/* Header */}
|
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||||
<div className="flex items-center justify-between">
|
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||||
<h2 className="text-lg font-bold text-duck-dark/80">Sessions</h2>
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer gap-2">
|
{/* Radio filter */}
|
||||||
<Link to="/chat/new">
|
<div className="flex items-center gap-0.5 rounded-lg bg-duck-dark/5 dark:bg-foreground/5 p-0.5">
|
||||||
<Plus className="h-4 w-4" />
|
{(['all', 'claude', 'opencode'] as const).map((value) => (
|
||||||
New Chat
|
<button
|
||||||
</Link>
|
key={value}
|
||||||
</Button>
|
onClick={() => setFilter(value)}
|
||||||
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
filter === value
|
||||||
|
? 'bg-background dark:bg-foreground/10 text-duck-dark dark:text-foreground shadow-sm'
|
||||||
|
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark/80 dark:hover:text-foreground/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelected({ id: 'new', provider: 'claude' });
|
||||||
|
window.history.replaceState(null, '', '/chat/new');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Radio filter */}
|
{/* Session list */}
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
|
||||||
{(['all', 'claude', 'opencode'] as const).map((value) => (
|
{filtered.length === 0 && (
|
||||||
<button
|
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||||
key={value}
|
No sessions yet. Start a new chat!
|
||||||
onClick={() => setFilter(value)}
|
</div>
|
||||||
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 */}
|
{filtered.map((session) => {
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
|
||||||
{filtered.length === 0 && (
|
return (
|
||||||
<div className="text-center py-16 text-duck-dark/30 text-sm">No sessions yet. Start a new chat!</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filtered.map((session) => (
|
|
||||||
<div
|
<div
|
||||||
key={`${session.provider}-${session.id}`}
|
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"
|
ref={isSelected ? selectedRef : undefined}
|
||||||
|
className={`group flex items-center gap-3 rounded-lg border transition-colors cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
|
||||||
|
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Link
|
<button
|
||||||
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
|
onClick={() => handleSelect(session)}
|
||||||
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0"
|
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-duck-dark/80 truncate">{session.title}</div>
|
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
|
||||||
<div className="text-xs text-duck-dark/40">
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -74,20 +119,22 @@ export const SessionList = () => {
|
|||||||
>
|
>
|
||||||
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
|
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 font-mono text-duck-dark/25">{session.id.slice(0, 8)}</span>
|
<span className="ml-2 font-mono text-duck-dark/25 dark:text-foreground/25">
|
||||||
|
{session.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteSession(session.provider, session.id)}
|
onClick={() => handleDelete(session.provider, session.id)}
|
||||||
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"
|
className="shrink-0 p-2 mr-2 text-duck-dark/20 dark:text-foreground/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,57 @@
|
|||||||
|
import { useMemo, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
|
import { SessionList } from './Screen';
|
||||||
|
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
|
||||||
|
|
||||||
export { ChatHistory as ChatHistoryApp } from './Widget';
|
export { ChatHistory as ChatHistoryApp } from './Widget';
|
||||||
export { SessionList } from './Screen';
|
export { SessionList };
|
||||||
|
|
||||||
|
const layout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'chat-history-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'chat-list', appType: null }, size: 35 },
|
||||||
|
{ node: { type: 'panel', id: 'chat-detail', appType: null }, size: 65 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionListPageProps = {
|
||||||
|
provider?: 'claude' | 'opencode';
|
||||||
|
isNew?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionListPage = ({ provider = 'claude', isNew }: SessionListPageProps) => {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
const { sessions } = useChatSessions();
|
||||||
|
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) {
|
||||||
|
setSelected({ id: 'new', provider });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sessionId) return;
|
||||||
|
const session = sessions.find((s) => s.id === sessionId && s.provider === provider);
|
||||||
|
setSelected({ id: sessionId, provider, model: session?.model ?? null });
|
||||||
|
}, [sessionId, provider, isNew]);
|
||||||
|
|
||||||
|
const panelComponents: PanelComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
'chat-list': SessionList,
|
||||||
|
'chat-detail': ChatDetailPanel,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type FileGridProps = {
|
|||||||
onDelete: (entry: DirEntry) => void;
|
onDelete: (entry: DirEntry) => void;
|
||||||
onRename: (entry: DirEntry, newName: string) => void;
|
onRename: (entry: DirEntry, newName: string) => void;
|
||||||
onChat: (entry: DirEntry) => void;
|
onChat: (entry: DirEntry) => void;
|
||||||
|
onDownload: (entry: DirEntry) => void;
|
||||||
onSelect: (names: Set<string>) => void;
|
onSelect: (names: Set<string>) => void;
|
||||||
onCut: () => void;
|
onCut: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
@@ -63,6 +64,7 @@ export const FileGrid = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRename,
|
onRename,
|
||||||
onChat,
|
onChat,
|
||||||
|
onDownload,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
@@ -143,6 +145,7 @@ export const FileGrid = ({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
onChat={onChat}
|
onChat={onChat}
|
||||||
|
onDownload={onDownload}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onCut={onCut}
|
onCut={onCut}
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play } from 'lucide-react';
|
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -35,6 +35,7 @@ export type FileItemProps = {
|
|||||||
onDelete: (entry: DirEntry) => void;
|
onDelete: (entry: DirEntry) => void;
|
||||||
onRename: (entry: DirEntry, newName: string) => void;
|
onRename: (entry: DirEntry, newName: string) => void;
|
||||||
onChat: (entry: DirEntry) => void;
|
onChat: (entry: DirEntry) => void;
|
||||||
|
onDownload: (entry: DirEntry) => void;
|
||||||
onSelect: (entry: DirEntry, ev: React.MouseEvent) => void;
|
onSelect: (entry: DirEntry, ev: React.MouseEvent) => void;
|
||||||
onCut: () => void;
|
onCut: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
@@ -64,6 +65,7 @@ type MenuItemsProps = {
|
|||||||
onDelete: (e: DirEntry) => void;
|
onDelete: (e: DirEntry) => void;
|
||||||
onStartRename: () => void;
|
onStartRename: () => void;
|
||||||
onChat: (e: DirEntry) => void;
|
onChat: (e: DirEntry) => void;
|
||||||
|
onDownload: (e: DirEntry) => void;
|
||||||
onCut: () => void;
|
onCut: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
matchingTasks: TaskSummary[];
|
matchingTasks: TaskSummary[];
|
||||||
@@ -76,6 +78,7 @@ const DropdownMenuItems = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onStartRename,
|
onStartRename,
|
||||||
onChat,
|
onChat,
|
||||||
|
onDownload,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
@@ -86,6 +89,10 @@ const DropdownMenuItems = ({
|
|||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
Chat...
|
Chat...
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
{matchingTasks.length > 0 && (
|
{matchingTasks.length > 0 && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger className="cursor-pointer">
|
<DropdownMenuSubTrigger className="cursor-pointer">
|
||||||
@@ -130,6 +137,7 @@ const ContextMenuItems = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onStartRename,
|
onStartRename,
|
||||||
onChat,
|
onChat,
|
||||||
|
onDownload,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
@@ -140,6 +148,10 @@ const ContextMenuItems = ({
|
|||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
Chat...
|
Chat...
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</ContextMenuItem>
|
||||||
{matchingTasks.length > 0 && (
|
{matchingTasks.length > 0 && (
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger className="cursor-pointer">
|
<ContextMenuSubTrigger className="cursor-pointer">
|
||||||
@@ -237,7 +249,7 @@ const InlineRenameInput = ({
|
|||||||
if (ev.key === 'Escape') onCancel();
|
if (ev.key === 'Escape') onCancel();
|
||||||
}}
|
}}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
className="text-sm font-medium text-duck-dark bg-white border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
|
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -257,7 +269,7 @@ const Checkbox = ({
|
|||||||
onClick(ev);
|
onClick(ev);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
|
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
|
||||||
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-white/80 hover:border-duck-teal/50'
|
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
|
||||||
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||||
>
|
>
|
||||||
{checked && <Check className="h-3 w-3" />}
|
{checked && <Check className="h-3 w-3" />}
|
||||||
@@ -280,6 +292,7 @@ export const FileItem = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRename,
|
onRename,
|
||||||
onChat,
|
onChat,
|
||||||
|
onDownload,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
@@ -358,6 +371,7 @@ export const FileItem = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onStartRename: () => setRenaming(true),
|
onStartRename: () => setRenaming(true),
|
||||||
onChat,
|
onChat,
|
||||||
|
onDownload,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ const MarkdownRenderer = ({
|
|||||||
|
|
||||||
// ── Text renderer ──
|
// ── Text renderer ──
|
||||||
const TextRenderer = ({ content }: { content: string }) => (
|
const TextRenderer = ({ content }: { content: string }) => (
|
||||||
<pre className="whitespace-pre-wrap font-mono text-sm text-duck-dark leading-relaxed p-4">{content}</pre>
|
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Code renderer with syntax highlighting ──
|
// ── Code renderer with syntax highlighting ──
|
||||||
@@ -851,7 +851,7 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
{/* Image area */}
|
{/* Image area */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
|
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(hsl(var(--muted))_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
|
||||||
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
|
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
|
||||||
}`}
|
}`}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
@@ -880,7 +880,7 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-white/80">
|
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
|
||||||
<button
|
<button
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
disabled={zoom <= minZoom}
|
disabled={zoom <= minZoom}
|
||||||
@@ -1050,22 +1050,12 @@ export const FileViewer = ({ open, onOpenChange, filePath, fileName, root }: Fil
|
|||||||
className={`fixed left-[50%] top-[50%] z-[600] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ${
|
className={`fixed left-[50%] top-[50%] z-[600] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ${
|
||||||
expanded ? 'w-[95vw] h-[95vh]' : 'w-[90vw] max-w-4xl h-[85vh]'
|
expanded ? 'w-[95vw] h-[95vh]' : 'w-[90vw] max-w-4xl h-[85vh]'
|
||||||
}`}
|
}`}
|
||||||
style={
|
style={fileType === 'video' ? { backgroundColor: '#000' } : cardStyle()}
|
||||||
fileType === 'video'
|
|
||||||
? { backgroundColor: '#000' }
|
|
||||||
: cardStyle({
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.97)',
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 flex items-center gap-3 px-5 py-3 border-b ${
|
className={`shrink-0 flex items-center gap-3 px-5 py-3 border-b ${
|
||||||
fileType === 'video' ? 'border-white/10 bg-black/80' : 'border-duck-dark/10 bg-white/60'
|
fileType === 'video' ? 'border-white/10 bg-black/80' : 'border-duck-dark/10 bg-background/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{headerIcon}
|
{headerIcon}
|
||||||
|
|||||||
@@ -104,17 +104,21 @@ type TaskRunnerModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
task: TaskSummary;
|
task: TaskSummary;
|
||||||
entryName: string;
|
entryName?: string;
|
||||||
entryType: 'file' | 'directory';
|
entryType?: 'file' | 'directory';
|
||||||
cwd: { root?: string; path: string };
|
cwd?: { root?: string; path: string };
|
||||||
|
promptOverride?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd }: TaskRunnerModalProps) => {
|
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const taskSettings = settings.tasks;
|
const taskSettings = settings.tasks;
|
||||||
const [provider, setProvider] = useState<'claude' | 'opencode'>(taskSettings.defaultProvider);
|
const [provider, setProvider] = useState<'claude' | 'opencode'>(taskSettings.defaultProvider);
|
||||||
const defaultInput = `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`;
|
const defaultInput = promptOverride
|
||||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName, entryType };
|
?? (entryName && entryType
|
||||||
|
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
|
||||||
|
: `Read the task instructions at ${task.filePath} and execute them`);
|
||||||
|
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -123,16 +127,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
onOpenAutoFocus={(ev) => ev.preventDefault()}
|
onOpenAutoFocus={(ev) => ev.preventDefault()}
|
||||||
className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-3xl h-[80vh] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-3xl h-[80vh] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||||
style={cardStyle({
|
style={cardStyle()}
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.97)',
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-white/60">
|
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-background/60">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
|
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
|
||||||
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
|
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Search,
|
Search,
|
||||||
@@ -43,7 +42,7 @@ export const Files = () => {
|
|||||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
||||||
const [fullscreen, setFullscreen] = useUserState<boolean>('files/fullscreen', false);
|
|
||||||
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
|
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
|
||||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
@@ -240,6 +239,25 @@ export const Files = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (entry: DirEntry) => {
|
||||||
|
const path = entryPath(entry.name);
|
||||||
|
try {
|
||||||
|
await files.download([path]);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to download');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadSelected = async () => {
|
||||||
|
const paths = selectedPaths();
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
try {
|
||||||
|
await files.download(paths);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to download');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
|
const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
|
||||||
setRunningTask({ task, entry });
|
setRunningTask({ task, entry });
|
||||||
};
|
};
|
||||||
@@ -378,6 +396,7 @@ export const Files = () => {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
|
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
|
onRefresh={refresh}
|
||||||
onCreateDir={handleCreateDir}
|
onCreateDir={handleCreateDir}
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
selectionCount={selected.size}
|
selectionCount={selected.size}
|
||||||
@@ -385,6 +404,7 @@ export const Files = () => {
|
|||||||
onCut={handleCut}
|
onCut={handleCut}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onDownloadSelected={handleDownloadSelected}
|
||||||
onDeleteSelected={handleDeleteSelected}
|
onDeleteSelected={handleDeleteSelected}
|
||||||
onClearSelection={() => setSelected(new Set())}
|
onClearSelection={() => setSelected(new Set())}
|
||||||
/>
|
/>
|
||||||
@@ -402,7 +422,7 @@ export const Files = () => {
|
|||||||
value={cloneUrl}
|
value={cloneUrl}
|
||||||
onChange={(ev) => setCloneUrl(ev.target.value)}
|
onChange={(ev) => setCloneUrl(ev.target.value)}
|
||||||
placeholder="https://github.com/user/repo.git"
|
placeholder="https://github.com/user/repo.git"
|
||||||
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||||
disabled={cloning}
|
disabled={cloning}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
@@ -454,7 +474,7 @@ export const Files = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
|
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -486,12 +506,6 @@ export const Files = () => {
|
|||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setFullscreen((f) => !f)}
|
|
||||||
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Home dir selector (Super Admin only) */}
|
{/* Home dir selector (Super Admin only) */}
|
||||||
@@ -603,6 +617,7 @@ export const Files = () => {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRename={handleRename}
|
onRename={handleRename}
|
||||||
onChat={handleChat}
|
onChat={handleChat}
|
||||||
|
onDownload={handleDownload}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
onCut={handleCut}
|
onCut={handleCut}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const FileBrowser = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
onChange={(ev) => setSearchQuery(ev.target.value)}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { Card } from '@/components/Card';
|
import { useMemo } from 'react';
|
||||||
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
import { Files as FilesInner } from './Screen';
|
import { Files as FilesInner } from './Screen';
|
||||||
|
|
||||||
export { FilesInner as Files };
|
export { FilesInner as Files };
|
||||||
|
|
||||||
export const FilesPage = () => (
|
const layout: LayoutNode = {
|
||||||
<div className="flex items-center justify-center h-full p-0 md:p-4">
|
type: 'panel',
|
||||||
<Card className="flex flex-col overflow-hidden w-full h-full md:w-[90vw] md:h-[90%] lg:w-[50vw]">
|
id: 'files-main',
|
||||||
<FilesInner />
|
appType: null,
|
||||||
</Card>
|
};
|
||||||
</div>
|
|
||||||
);
|
export const FilesPage = () => {
|
||||||
|
const panelComponents: PanelComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
'files-main': FilesInner,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export { FileBrowser as FileBrowserApp } from './Widget';
|
export { FileBrowser as FileBrowserApp } from './Widget';
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export const ChatLauncher = () => {
|
|||||||
}}
|
}}
|
||||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||||
provider === value
|
provider === value
|
||||||
? 'bg-white text-duck-dark shadow-sm'
|
? 'bg-background text-duck-dark shadow-sm'
|
||||||
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -317,7 +317,7 @@ export const ChatLauncher = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com"
|
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"
|
className="flex-1 rounded-lg border border-duck-dark/20 bg-background 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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const ICON_GAP = 24;
|
|||||||
const DOCK_PADDING = 12;
|
const DOCK_PADDING = 12;
|
||||||
const MAX_SCALE = 1.5;
|
const MAX_SCALE = 1.5;
|
||||||
const MAX_DISTANCE = 150;
|
const MAX_DISTANCE = 150;
|
||||||
const BOTTOM_THRESHOLD = 100;
|
const SHOW_THRESHOLD = 24;
|
||||||
|
const HIDE_THRESHOLD = 100;
|
||||||
|
|
||||||
const getScale = (mouseX: number | null, iconCenterX: number) => {
|
const getScale = (mouseX: number | null, iconCenterX: number) => {
|
||||||
if (mouseX === null) return 1;
|
if (mouseX === null) return 1;
|
||||||
@@ -39,12 +40,14 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (ev: MouseEvent) => {
|
const handleMouseMove = (ev: MouseEvent) => {
|
||||||
const distFromBottom = window.innerHeight - ev.clientY;
|
const distFromBottom = window.innerHeight - ev.clientY;
|
||||||
if (distFromBottom <= BOTTOM_THRESHOLD) {
|
setVisible((prev) => {
|
||||||
setVisible(true);
|
if (!prev) return distFromBottom <= SHOW_THRESHOLD;
|
||||||
|
return distFromBottom <= HIDE_THRESHOLD;
|
||||||
|
});
|
||||||
|
if (distFromBottom <= HIDE_THRESHOLD) {
|
||||||
const rect = dockRef.current?.getBoundingClientRect();
|
const rect = dockRef.current?.getBoundingClientRect();
|
||||||
if (rect) setMouseX(ev.clientX - rect.left);
|
if (rect) setMouseX(ev.clientX - rect.left);
|
||||||
} else {
|
} else {
|
||||||
setVisible(false);
|
|
||||||
setMouseX(null);
|
setMouseX(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ export function Header() {
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { User, LogOut, Server, Package, Bot } from 'lucide-react';
|
import { User, LogOut, Server, Package, Bot, Sun, Moon } from 'lucide-react';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
|
import { useSettings } from '@/state/useSettings';
|
||||||
|
|
||||||
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
|
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu() {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { colorMode, setColorMode } = useColorMode();
|
||||||
|
const { settings, saveSettings } = useSettings();
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
const toggleColorMode = () => {
|
||||||
|
const next = colorMode === 'dark' ? 'light' : 'dark';
|
||||||
|
setColorMode(next);
|
||||||
|
saveSettings({ ...settings, appearance: { ...settings.appearance, colorMode: next } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -49,6 +59,16 @@ export function UserMenu() {
|
|||||||
{t('header.userMenu.resources')}
|
{t('header.userMenu.resources')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
|
||||||
|
{colorMode === 'dark' ? (
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{colorMode === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/auth/signout">
|
<Link to="/auth/signout">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ export const Plans = () => {
|
|||||||
<div className="flex flex-col h-full p-4">
|
<div className="flex flex-col h-full p-4">
|
||||||
<Card className="flex-1 overflow-hidden">
|
<Card className="flex-1 overflow-hidden">
|
||||||
{/* Header with plan selector */}
|
{/* 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">
|
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
||||||
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
|
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
|
||||||
{plans.length > 1 && (
|
{plans.length > 1 && (
|
||||||
<select
|
<select
|
||||||
value={selectedPlan ?? ''}
|
value={selectedPlan ?? ''}
|
||||||
onChange={(ev) => setSelectedPlan(ev.target.value)}
|
onChange={(ev) => setSelectedPlan(ev.target.value)}
|
||||||
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-white/80"
|
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
|
||||||
>
|
>
|
||||||
{plans.map((p) => (
|
{plans.map((p) => (
|
||||||
<option key={p} value={p}>
|
<option key={p} value={p}>
|
||||||
@@ -49,7 +49,7 @@ export const Plans = () => {
|
|||||||
|
|
||||||
{/* Markdown content */}
|
{/* Markdown content */}
|
||||||
<div className="overflow-y-auto h-full p-6">
|
<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">
|
<div className="prose prose-sm dark:prose-invert 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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Terminal, Eye, Trash2 } from 'lucide-react';
|
import { Terminal, Eye, Trash2, Bot } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -10,7 +10,10 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card } from '@/components/Card';
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
|
import { createSettingsPanelComponents, type SettingsSection } from './SettingsPanel';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
import {
|
import {
|
||||||
@@ -21,38 +24,47 @@ import {
|
|||||||
} from '@/state/useModels';
|
} from '@/state/useModels';
|
||||||
import type { UserSettings } from '@/state/types/user-settings';
|
import type { UserSettings } from '@/state/types/user-settings';
|
||||||
|
|
||||||
|
const GLOBAL_KEY = 'AI_SETTINGS_SELECTED';
|
||||||
|
|
||||||
|
const sections: SettingsSection[] = [
|
||||||
|
{ key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: <ChatDefaultsSection /> },
|
||||||
|
{ key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||||
|
globalKey: GLOBAL_KEY,
|
||||||
|
sidebarIcon: Bot,
|
||||||
|
sidebarLabel: 'AI',
|
||||||
|
sections,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'ai-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'ai-left', appType: null }, size: 20 },
|
||||||
|
{ node: { type: 'panel', id: 'ai-right', appType: null }, size: 80 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const AISettings = () => {
|
export const AISettings = () => {
|
||||||
const [mainTab, setMainTab] = useUserState('ai-settings-tab', 'chat-defaults');
|
const panelComponents: PanelComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
'ai-left': Sidebar,
|
||||||
|
'ai-right': Content,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
<div className="h-full w-full pt-2">
|
||||||
<Card className="w-full max-w-2xl h-fit p-6">
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatDefaultsSection = () => {
|
function ChatDefaultsSection() {
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
const claudeModels = useVisibleClaudeModels();
|
const claudeModels = useVisibleClaudeModels();
|
||||||
const openCodeModels = useVisibleOpenCodeModels();
|
const openCodeModels = useVisibleOpenCodeModels();
|
||||||
@@ -102,7 +114,7 @@ const ChatDefaultsSection = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Model</span>
|
<span className="text-duck-dark/70">Model</span>
|
||||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||||
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
||||||
<SelectValue placeholder="Default" />
|
<SelectValue placeholder="Default" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[600]">
|
<SelectContent className="z-[600]">
|
||||||
@@ -119,7 +131,7 @@ const ChatDefaultsSection = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">System Prompt</span>
|
<span className="text-duck-dark/70">System Prompt</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
|
className="bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
|
||||||
placeholder="Custom instructions for the AI..."
|
placeholder="Custom instructions for the AI..."
|
||||||
value={systemPrompt}
|
value={systemPrompt}
|
||||||
onChange={(ev) => setSystemPrompt(ev.target.value)}
|
onChange={(ev) => setSystemPrompt(ev.target.value)}
|
||||||
@@ -137,7 +149,7 @@ const ChatDefaultsSection = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Default Working Directory</span>
|
<span className="text-duck-dark/70">Default Working Directory</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
value={defaultPwd}
|
value={defaultPwd}
|
||||||
onChange={(ev) => setDefaultPwd(ev.target.value)}
|
onChange={(ev) => setDefaultPwd(ev.target.value)}
|
||||||
placeholder="~"
|
placeholder="~"
|
||||||
@@ -154,9 +166,9 @@ const ChatDefaultsSection = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const ModelVisibilitySection = () => {
|
function ModelVisibilitySection() {
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
const claudeModels = useClaudeModels();
|
const claudeModels = useClaudeModels();
|
||||||
const openCodeModels = useOpenCodeModels();
|
const openCodeModels = useOpenCodeModels();
|
||||||
@@ -234,7 +246,7 @@ const ModelVisibilitySection = () => {
|
|||||||
{addingProvider ? (
|
{addingProvider ? (
|
||||||
<>
|
<>
|
||||||
<Select value={selectedNewProvider} onValueChange={setSelectedNewProvider}>
|
<Select value={selectedNewProvider} onValueChange={setSelectedNewProvider}>
|
||||||
<SelectTrigger className="h-9 flex-1 bg-white/60 border-duck-dark/20 text-duck-dark text-sm">
|
<SelectTrigger className="h-9 flex-1 bg-background/60 border-duck-dark/20 text-duck-dark text-sm">
|
||||||
<SelectValue placeholder="Select provider..." />
|
<SelectValue placeholder="Select provider..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[600]">
|
<SelectContent className="z-[600]">
|
||||||
@@ -282,7 +294,7 @@ const ModelVisibilitySection = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
|
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const ChangePassword = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">{t('settings.profile.changePassword.currentPasswordLabel')}</span>
|
<span className="text-duck-dark/70">{t('settings.profile.changePassword.currentPasswordLabel')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder={t('settings.profile.changePassword.currentPasswordPlaceholder')}
|
placeholder={t('settings.profile.changePassword.currentPasswordPlaceholder')}
|
||||||
@@ -63,7 +63,7 @@ export const ChangePassword = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">{t('settings.profile.changePassword.newPasswordLabel')}</span>
|
<span className="text-duck-dark/70">{t('settings.profile.changePassword.newPasswordLabel')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="newPassword"
|
name="newPassword"
|
||||||
placeholder={t('settings.profile.changePassword.newPasswordPlaceholder')}
|
placeholder={t('settings.profile.changePassword.newPasswordPlaceholder')}
|
||||||
@@ -74,7 +74,7 @@ export const ChangePassword = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">{t('settings.profile.changePassword.confirmPasswordLabel')}</span>
|
<span className="text-duck-dark/70">{t('settings.profile.changePassword.confirmPasswordLabel')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder={t('settings.profile.changePassword.confirmPasswordPlaceholder')}
|
placeholder={t('settings.profile.changePassword.confirmPasswordPlaceholder')}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const Languages = () => {
|
|||||||
<select
|
<select
|
||||||
value={addingLang}
|
value={addingLang}
|
||||||
onChange={(ev) => addSpoken(ev.target.value)}
|
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"
|
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="">{t('settings.profile.languages.addLanguage')}</option>
|
<option value="">{t('settings.profile.languages.addLanguage')}</option>
|
||||||
{availableToAdd.map((l) => (
|
{availableToAdd.map((l) => (
|
||||||
@@ -110,7 +110,7 @@ export const Languages = () => {
|
|||||||
<select
|
<select
|
||||||
value={defaultLang}
|
value={defaultLang}
|
||||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
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"
|
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||||
>
|
>
|
||||||
{spoken.map((code) => (
|
{spoken.map((code) => (
|
||||||
<option key={code} value={code}>
|
<option key={code} value={code}>
|
||||||
@@ -127,7 +127,7 @@ export const Languages = () => {
|
|||||||
<select
|
<select
|
||||||
value={translateTo}
|
value={translateTo}
|
||||||
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
|
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"
|
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||||
>
|
>
|
||||||
{LANGUAGES.map((l) => (
|
{LANGUAGES.map((l) => (
|
||||||
<option key={l.code} value={l.code}>
|
<option key={l.code} value={l.code}>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const TaskDefaults = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Default Model</span>
|
<span className="text-duck-dark/70">Default Model</span>
|
||||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||||
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
||||||
<SelectValue placeholder="Same as chat default" />
|
<SelectValue placeholder="Same as chat default" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[600] max-h-[300px]">
|
<SelectContent className="z-[600] max-h-[300px]">
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const UserData = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">Email</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
value={user?.email ?? ''}
|
value={user?.email ?? ''}
|
||||||
disabled
|
disabled
|
||||||
@@ -105,7 +105,7 @@ export const UserData = () => {
|
|||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Name</span>
|
<span className="text-duck-dark/70">Name</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
|
|||||||
@@ -1,106 +1,52 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Search, User, Lock, Globe, ListChecks } from 'lucide-react';
|
import { User, Lock, Globe, ListChecks } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
import { Card } from '@/components/Card';
|
import { appRegistry } from '../../Workspaces/app-registry';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||||
import { UserData } from './UserData';
|
import { UserData } from './UserData';
|
||||||
import { ChangePassword } from './ChangePassword';
|
import { ChangePassword } from './ChangePassword';
|
||||||
import { Languages } from './Languages';
|
import { Languages } from './Languages';
|
||||||
import { TaskDefaults } from './TaskDefaults';
|
import { TaskDefaults } from './TaskDefaults';
|
||||||
|
|
||||||
export const ProfileSettings = () => {
|
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
|
||||||
const { t } = useTranslation();
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [expanded, setExpanded] = useState<string[]>([]);
|
|
||||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
||||||
|
|
||||||
const sections = useMemo(
|
const sections: SettingsSection[] = [
|
||||||
() => [
|
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
|
||||||
{
|
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
|
||||||
key: 'profile',
|
{ key: 'tasks', icon: ListChecks, title: 'Task Defaults', description: 'Default model for tasks', content: <TaskDefaults /> },
|
||||||
icon: User,
|
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
|
||||||
title: t('settings.profile.sections.profileTitle'),
|
];
|
||||||
description: t('settings.profile.sections.profileDescription'),
|
|
||||||
content: <UserData />,
|
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||||
},
|
globalKey: GLOBAL_KEY,
|
||||||
{
|
sidebarIcon: User,
|
||||||
key: 'change-password',
|
sidebarLabel: 'Profile',
|
||||||
icon: Lock,
|
sections,
|
||||||
title: t('settings.profile.sections.changePasswordTitle'),
|
});
|
||||||
description: t('settings.profile.sections.changePasswordDescription'),
|
|
||||||
content: <ChangePassword />,
|
const layout: LayoutNode = {
|
||||||
},
|
type: 'group',
|
||||||
{
|
id: 'profile-root',
|
||||||
key: 'tasks',
|
direction: 'horizontal',
|
||||||
icon: ListChecks,
|
children: [
|
||||||
title: t('settings.profile.sections.tasksTitle'),
|
{ node: { type: 'panel', id: 'profile-left', appType: null }, size: 20 },
|
||||||
description: t('settings.profile.sections.tasksDescription'),
|
{ node: { type: 'panel', id: 'profile-right', appType: null }, size: 80 },
|
||||||
content: <TaskDefaults />,
|
],
|
||||||
},
|
};
|
||||||
{
|
|
||||||
key: 'languages',
|
export const ProfileSettings = () => {
|
||||||
icon: Globe,
|
const panelComponents: PanelComponents = useMemo(
|
||||||
title: t('settings.profile.sections.languagesTitle'),
|
() => ({
|
||||||
description: t('settings.profile.sections.languagesDescription'),
|
'profile-left': Sidebar,
|
||||||
content: <Languages />,
|
'profile-right': Content,
|
||||||
},
|
}),
|
||||||
],
|
[],
|
||||||
[t],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allKeys = useMemo(() => sections.map((s) => s.key), [sections]);
|
|
||||||
|
|
||||||
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, allKeys, sections]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (search) setExpanded(matchingKeys);
|
|
||||||
}, [search, matchingKeys]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
<div className="h-full w-full pt-2">
|
||||||
<Card className="w-full max-w-2xl h-fit p-6">
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||||
<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={t('settings.profile.searchPlaceholder')}
|
|
||||||
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 className="px-1">{section.content}</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ const CopyCommand = ({ command }: { command: string }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<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>
|
<code className="flex-1 bg-duck-dark/5 dark:bg-foreground/5 rounded px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70">{command}</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/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" />}
|
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -77,9 +77,9 @@ export const Applications = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto p-6">
|
<div className="h-full overflow-y-auto p-6">
|
||||||
<h2 className="text-lg font-bold text-duck-dark mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
|
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
|
||||||
|
|
||||||
{isLoading && <p className="text-sm text-duck-dark/50">Checking applications...</p>}
|
{isLoading && <p className="text-sm text-duck-dark/50 dark:text-foreground/50">Checking applications...</p>}
|
||||||
|
|
||||||
{apps && (
|
{apps && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -88,18 +88,18 @@ export const Applications = () => {
|
|||||||
const canAutoRun = hasAutoAction(app);
|
const canAutoRun = hasAutoAction(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 px-4 py-3">
|
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-duck-dark">{app.name}</span>
|
<span className="text-sm font-semibold text-duck-dark dark:text-foreground">{app.name}</span>
|
||||||
{app.installed && (
|
{app.installed && (
|
||||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
||||||
{app.version}
|
{app.version}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!app.installed && (
|
{!app.installed && (
|
||||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">
|
<span className="text-xs bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/40 dark:text-foreground/40 rounded-full px-2 py-0.5">
|
||||||
Not installed
|
Not installed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -109,7 +109,7 @@ export const Applications = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-duck-dark/50 mt-0.5">{app.description}</p>
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5">{app.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
@@ -147,7 +147,7 @@ export const Applications = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{manualCmd && (
|
{manualCmd && (
|
||||||
<div className="mt-2 text-xs text-duck-dark/50">
|
<div className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
{app.installed ? 'Update' : 'Install'} manually:
|
{app.installed ? 'Update' : 'Install'} manually:
|
||||||
<CopyCommand command={manualCmd} />
|
<CopyCommand command={manualCmd} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-6
@@ -36,9 +36,8 @@ export const ResourceSidebar = () => {
|
|||||||
<button
|
<button
|
||||||
key={r.id}
|
key={r.id}
|
||||||
onClick={() => setSelectedId(r.id)}
|
onClick={() => setSelectedId(r.id)}
|
||||||
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
|
||||||
isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{r.port ? (
|
{r.port ? (
|
||||||
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
|
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
|
||||||
@@ -50,9 +49,8 @@ export const ResourceSidebar = () => {
|
|||||||
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
<Circle
|
<Circle
|
||||||
className={`h-2 w-2 shrink-0 mt-1.5 ${
|
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
|
||||||
r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
|
}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,96 +1,48 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Search, Terminal, Shield } from 'lucide-react';
|
import { Terminal, Shield, Server } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
import { Card } from '@/components/Card';
|
import { appRegistry } from '../../Workspaces/app-registry';
|
||||||
|
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||||
import { AIHarnessesSection } from './AIHarnessesSection';
|
import { AIHarnessesSection } from './AIHarnessesSection';
|
||||||
// import { PluginsSection } from './PluginsSection';
|
|
||||||
import { TerminalSection } from './TerminalSection';
|
import { TerminalSection } from './TerminalSection';
|
||||||
|
|
||||||
const sections = [
|
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
|
||||||
{
|
|
||||||
key: 'ai-harnesses',
|
const sections: SettingsSection[] = [
|
||||||
icon: Terminal,
|
{ key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: <AIHarnessesSection /> },
|
||||||
title: 'AI Harnesses',
|
{ key: 'terminal', icon: Shield, title: 'Terminal', description: 'Sandbox and access controls', content: <TerminalSection /> },
|
||||||
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);
|
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||||
|
globalKey: GLOBAL_KEY,
|
||||||
|
sidebarIcon: Server,
|
||||||
|
sidebarLabel: 'Server',
|
||||||
|
sections,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'server-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'server-left', appType: null }, size: 20 },
|
||||||
|
{ node: { type: 'panel', id: 'server-right', appType: null }, size: 80 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const ServerSettings = () => {
|
export const ServerSettings = () => {
|
||||||
const [search, setSearch] = useState('');
|
const panelComponents: PanelComponents = useMemo(
|
||||||
const [expanded, setExpanded] = useState<string[]>(allKeys);
|
() => ({
|
||||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
'server-left': Sidebar,
|
||||||
|
'server-right': Content,
|
||||||
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 (
|
return (
|
||||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
<div className="h-full w-full pt-2">
|
||||||
<Card className="w-full max-w-2xl h-fit p-6">
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
export type SettingsSection = {
|
||||||
|
key: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsSidebarProps = {
|
||||||
|
globalKey: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
sections: SettingsSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections }: SettingsSidebarProps) => {
|
||||||
|
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const query = search.toLowerCase();
|
||||||
|
const filtered = sections.filter(
|
||||||
|
(s) => s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
|
<div className="p-3 pb-2">
|
||||||
|
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||||
|
{filtered.map((s) => {
|
||||||
|
const isActive = selectedKey === s.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setSelectedKey(s.key)}
|
||||||
|
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||||
|
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<s.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">{s.title}</div>
|
||||||
|
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{s.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsContentProps = {
|
||||||
|
globalKey: string;
|
||||||
|
sections: SettingsSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) => {
|
||||||
|
const [selectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
|
||||||
|
const section = sections.find((s) => s.key === selectedKey);
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-6">
|
||||||
|
<div className="flex items-center gap-2.5 mb-1">
|
||||||
|
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
|
||||||
|
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
|
||||||
|
<div className="max-w-xl">{section.content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateSettingsPanelParams = {
|
||||||
|
globalKey: string;
|
||||||
|
sidebarIcon: LucideIcon;
|
||||||
|
sidebarLabel: string;
|
||||||
|
sections: SettingsSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections }: CreateSettingsPanelParams) => {
|
||||||
|
const Sidebar: ComponentType = () => (
|
||||||
|
<SettingsSidebar globalKey={globalKey} icon={sidebarIcon} label={sidebarLabel} sections={sections} />
|
||||||
|
);
|
||||||
|
const Content: ComponentType = () => <SettingsContent globalKey={globalKey} sections={sections} />;
|
||||||
|
return { Sidebar, Content };
|
||||||
|
};
|
||||||
@@ -28,7 +28,7 @@ const formatDate = (iso: string) => {
|
|||||||
|
|
||||||
const ProviderBadge = ({ provider }: { provider: string }) => (
|
const ProviderBadge = ({ provider }: { provider: string }) => (
|
||||||
<span
|
<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'}`}
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' : 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'}`}
|
||||||
>
|
>
|
||||||
{provider}
|
{provider}
|
||||||
</span>
|
</span>
|
||||||
@@ -89,7 +89,7 @@ export const TaskLogs = () => {
|
|||||||
placeholder="Search logs..."
|
placeholder="Search logs..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
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"
|
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +161,7 @@ export const TaskLogs = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedLog.isError && (
|
{selectedLog.isError && (
|
||||||
<span className="text-xs text-red-600 bg-red-50 px-2 py-0.5 rounded-full">Error</span>
|
<span className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 px-2 py-0.5 rounded-full">Error</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
|||||||
@@ -1,10 +1,51 @@
|
|||||||
import { Link } from 'react-router';
|
import { LayoutGrid, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { LayoutGrid, Plus } from 'lucide-react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||||
|
import {
|
||||||
|
SELECTED_WORKSPACE_KEY,
|
||||||
|
CREATING_WORKSPACE_KEY,
|
||||||
|
EDITING_WORKSPACE_KEY,
|
||||||
|
NEW_WS_NAME_KEY,
|
||||||
|
NEW_WS_DESC_KEY,
|
||||||
|
NEW_WS_TEMPLATE_KEY,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
export const WorkspaceListApp = () => {
|
export const WorkspaceListApp = () => {
|
||||||
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||||
|
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||||
|
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||||
|
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||||
|
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||||
|
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||||
|
|
||||||
|
const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setSelected(null);
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(ws.id);
|
||||||
|
setName(ws.name);
|
||||||
|
setDescription(ws.description ?? '');
|
||||||
|
setTemplateIdx(ws.templateIdx ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id));
|
||||||
|
if (selected === ws.id) setSelected(null);
|
||||||
|
|
||||||
|
// Clean up persisted layout
|
||||||
|
const layoutKey = `ws-layout-${ws.id}`;
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||||
|
const { [layoutKey]: _, ...rest } = currentState;
|
||||||
|
queryClient.setQueryData(['USER_STATE'], rest);
|
||||||
|
client.patch('/user/state', { [layoutKey]: null }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
@@ -16,25 +57,52 @@ export const WorkspaceListApp = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 px-3 pt-3">
|
<div className="flex flex-col gap-0.5 px-3 pt-3">
|
||||||
{workspaces.map((ws) => (
|
{workspaces.map((ws) => (
|
||||||
<Link
|
<div
|
||||||
key={ws.id}
|
key={ws.id}
|
||||||
to={`/workspaces/${ws.id}`}
|
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||||
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark transition-all"
|
selected === ws.id
|
||||||
|
? 'bg-duck-teal/10 text-duck-teal'
|
||||||
|
: 'text-duck-dark/60 hover:bg-duck-dark/5'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(ws.id);
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4 shrink-0" />
|
<LayoutGrid className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1 text-left truncate">{ws.name}</span>
|
<span className="flex-1 text-left truncate">{ws.name}</span>
|
||||||
</Link>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(ev) => handleEdit(ev, ws)}
|
||||||
|
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(ev) => handleDelete(ev, ws)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{workspaces.length === 0 && (
|
{workspaces.length === 0 && (
|
||||||
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
|
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
|
||||||
)}
|
)}
|
||||||
<Link
|
<button
|
||||||
to="/workspaces"
|
type="button"
|
||||||
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all"
|
onClick={() => {
|
||||||
|
setSelected(null);
|
||||||
|
setEditing(null);
|
||||||
|
setCreating(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 shrink-0" />
|
<Plus className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1 text-left">New workspace</span>
|
<span className="flex-1 text-left">New workspace</span>
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,94 +1,466 @@
|
|||||||
import { useState } from 'react';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||||
import { Plus, Trash2, LayoutGrid, ArrowRight } from 'lucide-react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
import { Widget } from 'widgets/Widget';
|
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { appRegistry } from './app-registry';
|
||||||
|
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
|
||||||
|
|
||||||
export const WorkspaceListScreen = () => {
|
const defaultLayout: LayoutNode = {
|
||||||
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
type: 'group',
|
||||||
const [newName, setNewName] = useState('');
|
id: 'ws-home-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'ws-home-list', appType: 'workspace-list' }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: 'ws-home-right', appType: 'workspace-preview' }, size: 50 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const createWorkspace = () => {
|
// --- Layout Templates ---
|
||||||
const name = newName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const ws: WorkspaceDefinition = {
|
|
||||||
id: `ws-${Date.now()}`,
|
|
||||||
name,
|
|
||||||
cwd: '~',
|
|
||||||
};
|
|
||||||
setWorkspaces((prev) => [...prev, ws]);
|
|
||||||
setNewName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteWorkspace = (id: string) => {
|
let tplCounter = 0;
|
||||||
setWorkspaces((prev) => prev.filter((ws) => ws.id !== id));
|
const tplUid = () => `tpl-${++tplCounter}`;
|
||||||
};
|
|
||||||
|
|
||||||
|
type LayoutTemplate = {
|
||||||
|
name: string;
|
||||||
|
layout: () => LayoutNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const templates: LayoutTemplate[] = [
|
||||||
|
{
|
||||||
|
name: 'Single',
|
||||||
|
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '2 Columns',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Main + Side',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sidebar',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '2x2 Grid',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cols + Bottom',
|
||||||
|
layout: () => ({
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: tplUid(),
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 70,
|
||||||
|
},
|
||||||
|
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Template Thumbnails ---
|
||||||
|
|
||||||
|
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return <div className="h-full w-full bg-duck-teal/15 border border-duck-teal/20 rounded-sm" />;
|
||||||
|
}
|
||||||
|
const isH = node.direction === 'horizontal';
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-start justify-center p-8">
|
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
|
||||||
<Widget title="Workspaces" className="w-full max-w-lg">
|
{node.children.map((child) => (
|
||||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
|
||||||
<div className="flex gap-2">
|
<ThumbnailNode node={child.node} />
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newName}
|
|
||||||
onChange={(ev) => setNewName(ev.target.value)}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
createWorkspace();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="New workspace name..."
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={createWorkspace}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{workspaces.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-duck-dark/40">
|
|
||||||
<LayoutGrid className="h-8 w-8" />
|
|
||||||
<p className="text-sm">No workspaces yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{workspaces.map((ws) => (
|
|
||||||
<li
|
|
||||||
key={ws.id}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-duck-dark/5 group"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
|
||||||
<Link to={`/workspaces/${ws.id}`} className="flex-1 min-w-0">
|
|
||||||
<span className="text-sm font-medium text-duck-dark truncate block">{ws.name}</span>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => deleteWorkspace(ws.id)}
|
|
||||||
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>
|
|
||||||
<Link
|
|
||||||
to={`/workspaces/${ws.id}`}
|
|
||||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Widget>
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemplateCell = ({ index }: { index: number }) => {
|
||||||
|
const tpl = templates[index]!;
|
||||||
|
const [selected, setSelected] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||||
|
const node = tpl.layout();
|
||||||
|
const isSelected = selected === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(index)}
|
||||||
|
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
|
||||||
|
isSelected ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
||||||
|
<ThumbnailNode node={node} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>{tpl.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Panel: Template Picker (3x2 workspace) ---
|
||||||
|
|
||||||
|
const tplPanelLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'tpl-root',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'tpl-row-0',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'tpl-0', appType: 'tpl-0' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'tpl-1', appType: 'tpl-1' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'tpl-2', appType: 'tpl-2' }, size: 33.34 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'tpl-row-1',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'tpl-3', appType: 'tpl-3' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'tpl-4', appType: 'tpl-4' }, size: 33.33 },
|
||||||
|
{ node: { type: 'panel', id: 'tpl-5', appType: 'tpl-5' }, size: 33.34 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tplRegistry = Object.fromEntries(
|
||||||
|
templates.map((tpl, i) => [`tpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const TemplatePanel = () => (
|
||||||
|
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Panel: Name ---
|
||||||
|
|
||||||
|
const NamePanel = () => {
|
||||||
|
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||||
|
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-full flex-col gap-4 p-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||||
|
<Type className="h-3 w-3" />
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
|
placeholder="My Workspace"
|
||||||
|
autoFocus
|
||||||
|
className="rounded-lg border border-duck-dark/20 bg-background 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 min-h-0 flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
|
placeholder="What is this workspace for?"
|
||||||
|
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Panel: Create ---
|
||||||
|
|
||||||
|
const CreatePanel = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const client = useClient();
|
||||||
|
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||||
|
const [filePath] = useUserState<string>('files/currentPath', '/');
|
||||||
|
const [, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
|
||||||
|
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
|
||||||
|
|
||||||
|
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||||
|
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||||
|
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||||
|
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||||
|
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||||
|
|
||||||
|
const isEditing = !!editingId;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const desc = description.trim();
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
setWorkspaces((prev) =>
|
||||||
|
prev.map((ws) =>
|
||||||
|
ws.id === editingId
|
||||||
|
? { ...ws, name: trimmed, description: desc || undefined, templateIdx }
|
||||||
|
: ws,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
|
||||||
|
const layoutKey = `ws-layout-${editingId}`;
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||||
|
queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||||
|
client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
const ws: WorkspaceDefinition = {
|
||||||
|
id: `ws-${Date.now()}`,
|
||||||
|
name: trimmed,
|
||||||
|
cwd,
|
||||||
|
description: desc || undefined,
|
||||||
|
templateIdx,
|
||||||
|
};
|
||||||
|
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
|
||||||
|
const layoutKey = `ws-layout-${ws.id}`;
|
||||||
|
|
||||||
|
setWorkspaces((prev) => [...prev, ws]);
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||||
|
queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||||
|
client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {});
|
||||||
|
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
navigate(`/workspaces/${ws.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
|
||||||
|
<Rocket className="h-8 w-8 text-duck-teal/40" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
|
||||||
|
<p className="text-xs font-mono text-duck-dark/40 mt-1">{cwd}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{isEditing ? 'Update Workspace' : 'Create Workspace'}
|
||||||
|
</Button>
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/workspaces/${editingId}`)}
|
||||||
|
variant="outline"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
|
Open Workspace
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- New Workspace Layout ---
|
||||||
|
|
||||||
|
const newWsLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'new-ws-root',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'new-ws-top',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
|
||||||
|
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser' }, size: 70 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'new-ws-bottom',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'new-ws-template', appType: 'new-ws-template' }, size: 70 },
|
||||||
|
{ node: { type: 'panel', id: 'new-ws-create', appType: 'new-ws-create' }, size: 30 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const newWsRegistry = {
|
||||||
|
...appRegistry,
|
||||||
|
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
|
||||||
|
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||||
|
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewWorkspaceForm = () => (
|
||||||
|
<WorkspaceLayout layout={newWsLayout} onLayoutChange={() => {}} registry={newWsRegistry} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const WorkspacePreviewEmpty = () => {
|
||||||
|
const [creating, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||||
|
const [editingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||||
|
|
||||||
|
if (creating || editingId) return <NewWorkspaceForm />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-duck-dark/30">
|
||||||
|
<LayoutGrid className="h-10 w-10" />
|
||||||
|
<p className="text-sm">Select a workspace or create a new one</p>
|
||||||
|
<Button onClick={() => setCreating(true)} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
New Workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||||
|
const [layout, setLayout] = useUserState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||||
|
|
||||||
|
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspacePreview = () => {
|
||||||
|
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||||
|
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
|
||||||
|
|
||||||
|
if (!workspace) return <WorkspacePreviewEmpty />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<WorkspacePreviewInner key={workspace.id} workspace={workspace} />
|
||||||
|
<Link
|
||||||
|
to={`/workspaces/${workspace.id}`}
|
||||||
|
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-16 w-16 text-duck-teal/0 group-hover:text-duck-teal/60 transition-colors" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = { ...appRegistry, 'workspace-preview': { name: 'Workspace Preview', icon: LayoutGrid, component: WorkspacePreview } };
|
||||||
|
|
||||||
|
export const WorkspaceListScreen = () => {
|
||||||
|
const [layout, setLayout, isLoaded] = useUserState<LayoutNode>('ws-layout-workspaces', defaultLayout);
|
||||||
|
|
||||||
|
if (!isLoaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={registry} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const SELECTED_WORKSPACE_KEY = 'SELECTED_WORKSPACE';
|
||||||
|
export const CREATING_WORKSPACE_KEY = 'CREATING_WORKSPACE';
|
||||||
|
export const EDITING_WORKSPACE_KEY = 'EDITING_WORKSPACE';
|
||||||
|
export const NEW_WS_NAME_KEY = 'NEW_WS_NAME';
|
||||||
|
export const NEW_WS_DESC_KEY = 'NEW_WS_DESC';
|
||||||
|
export const NEW_WS_TEMPLATE_KEY = 'NEW_WS_TEMPLATE';
|
||||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ThemeProvider } from '@/components/ui/ThemeProvider';
|
import { ColorModeProvider } from '@/components/ui/ThemeProvider';
|
||||||
import { I18nBridge } from '@/lib/I18nBridge';
|
import { I18nBridge } from '@/lib/I18nBridge';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { Toaster as Sonner } from 'sonner';
|
import { Toaster as Sonner } from 'sonner';
|
||||||
@@ -34,7 +34,7 @@ const elem = document.getElementById('root')!;
|
|||||||
const app = (
|
const app = (
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="officer-theme">
|
<ColorModeProvider>
|
||||||
<I18nBridge>
|
<I18nBridge>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -42,7 +42,7 @@ const app = (
|
|||||||
<App />
|
<App />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</I18nBridge>
|
</I18nBridge>
|
||||||
</ThemeProvider>
|
</ColorModeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type UserSettings = {
|
|||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
};
|
};
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: string;
|
colorMode: 'light' | 'dark';
|
||||||
colorTheme: string;
|
colorTheme: string;
|
||||||
};
|
};
|
||||||
languages: {
|
languages: {
|
||||||
@@ -44,7 +44,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: 'light',
|
colorMode: 'light',
|
||||||
colorTheme: 'DuckPond',
|
colorTheme: 'DuckPond',
|
||||||
},
|
},
|
||||||
languages: {
|
languages: {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { usePlans } from './usePlans';
|
import { usePlans } from './usePlans';
|
||||||
import { useSettings } from './useSettings';
|
import { useSettings } from './useSettings';
|
||||||
import { useThemeSync } from './useThemeSync';
|
import { useColorModeSync } from './useThemeSync';
|
||||||
|
|
||||||
export const useInitialData = () => {
|
export const useInitialData = () => {
|
||||||
const { sessions } = useChatSessions();
|
const { sessions } = useChatSessions();
|
||||||
const { plans } = usePlans();
|
const { plans } = usePlans();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
useThemeSync();
|
useColorModeSync();
|
||||||
|
|
||||||
return { sessions, plans, settings };
|
return { sessions, plans, settings };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTheme } from '@/components/ui/ThemeProvider';
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
import { useSettings } from './useSettings';
|
import { useSettings } from './useSettings';
|
||||||
|
|
||||||
export const useThemeSync = () => {
|
export const useColorModeSync = () => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { setTheme } = useTheme();
|
const { setColorMode } = useColorMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
setTheme('light');
|
setColorMode(settings.appearance.colorMode);
|
||||||
}, [settings?.appearance?.colorTheme]);
|
}, [settings?.appearance?.colorMode]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import type { UserState } from './types/user-settings';
|
|||||||
|
|
||||||
const QUERY_KEY = ['USER_STATE'];
|
const QUERY_KEY = ['USER_STATE'];
|
||||||
|
|
||||||
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
const { data: state = {} } = useQuery<UserState>({
|
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||||
queryKey: QUERY_KEY,
|
queryKey: QUERY_KEY,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: () => client.get<UserState>('/user/state'),
|
queryFn: () => client.get<UserState>('/user/state'),
|
||||||
@@ -36,5 +36,5 @@ export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | (
|
|||||||
[key, defaultValue, queryClient],
|
[key, defaultValue, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
return [value, setValue];
|
return [value, setValue, isSuccess];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
/* Duck brand — lightened for dark-mode contrast */
|
||||||
|
--duck-teal: #22d3ee;
|
||||||
|
--duck-dark: #f1f5f9;
|
||||||
|
--duck-forest: #4ade80;
|
||||||
|
--duck-beige: #292524;
|
||||||
|
|
||||||
/* Page background */
|
/* Page background */
|
||||||
--pixel-grid-color: rgba(200, 230, 210, 0.08);
|
--pixel-grid-color: rgba(200, 230, 210, 0.08);
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
/* File Viewer — Markdown Prose */
|
/* File Viewer — Markdown Prose */
|
||||||
.file-viewer-md {
|
.file-viewer-md {
|
||||||
color: #14532d;
|
color: hsl(var(--foreground));
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md h1 {
|
.file-viewer-md h1 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 1.5em 0 0.5em;
|
margin: 1.5em 0 0.5em;
|
||||||
padding-bottom: 0.3em;
|
padding-bottom: 0.3em;
|
||||||
border-bottom: 2px solid rgba(20, 83, 45, 0.12);
|
border-bottom: 2px solid hsl(var(--border));
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md h2 {
|
.file-viewer-md h2 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1.4em 0 0.4em;
|
margin: 1.4em 0 0.4em;
|
||||||
padding-bottom: 0.25em;
|
padding-bottom: 0.25em;
|
||||||
border-bottom: 1px solid rgba(20, 83, 45, 0.08);
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md h3 {
|
.file-viewer-md h3 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1.2em 0 0.4em;
|
margin: 1.2em 0 0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md h4, .file-viewer-md h5, .file-viewer-md h6 {
|
.file-viewer-md h4, .file-viewer-md h5, .file-viewer-md h6 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1em 0 0.3em;
|
margin: 1em 0 0.3em;
|
||||||
@@ -39,27 +43,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md a {
|
.file-viewer-md a {
|
||||||
color: #0891b2;
|
color: var(--duck-teal);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md a:hover {
|
.file-viewer-md a:hover {
|
||||||
color: #0e7490;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md strong {
|
.file-viewer-md strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #14532d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md blockquote {
|
.file-viewer-md blockquote {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
border-left: 3px solid #0891b2;
|
border-left: 3px solid var(--duck-teal);
|
||||||
background: rgba(8, 145, 178, 0.05);
|
background: color-mix(in srgb, var(--duck-teal) 5%, transparent);
|
||||||
border-radius: 0 0.5rem 0.5rem 0;
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
color: #166534;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md ul, .file-viewer-md ol {
|
.file-viewer-md ul, .file-viewer-md ol {
|
||||||
@@ -72,13 +75,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md li::marker {
|
.file-viewer-md li::marker {
|
||||||
color: #0891b2;
|
color: var(--duck-teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md hr {
|
.file-viewer-md hr {
|
||||||
border: none;
|
border: none;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(20, 83, 45, 0.12);
|
background: hsl(var(--border));
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,20 +93,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md th {
|
.file-viewer-md th {
|
||||||
background: rgba(8, 145, 178, 0.08);
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: color-mix(in srgb, var(--duck-teal) 8%, transparent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.5em 0.75em;
|
padding: 0.5em 0.75em;
|
||||||
border: 1px solid rgba(20, 83, 45, 0.12);
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md td {
|
.file-viewer-md td {
|
||||||
padding: 0.5em 0.75em;
|
padding: 0.5em 0.75em;
|
||||||
border: 1px solid rgba(20, 83, 45, 0.08);
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md tr:nth-child(even) {
|
.file-viewer-md tr:nth-child(even) {
|
||||||
background: rgba(20, 83, 45, 0.02);
|
background: hsl(var(--muted) / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md img {
|
.file-viewer-md img {
|
||||||
@@ -113,41 +117,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-viewer-md input[type="checkbox"] {
|
.file-viewer-md input[type="checkbox"] {
|
||||||
accent-color: #0891b2;
|
accent-color: var(--duck-teal);
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skills — Markdown Prose */
|
/* Skills — Markdown Prose */
|
||||||
.skill-md {
|
.skill-md {
|
||||||
color: #14532d;
|
color: hsl(var(--foreground));
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md h1 {
|
.skill-md h1 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 1.5em 0 0.5em;
|
margin: 1.5em 0 0.5em;
|
||||||
padding-bottom: 0.3em;
|
padding-bottom: 0.3em;
|
||||||
border-bottom: 2px solid rgba(20, 83, 45, 0.12);
|
border-bottom: 2px solid hsl(var(--border));
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md h2 {
|
.skill-md h2 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1.4em 0 0.4em;
|
margin: 1.4em 0 0.4em;
|
||||||
padding-bottom: 0.25em;
|
padding-bottom: 0.25em;
|
||||||
border-bottom: 1px solid rgba(20, 83, 45, 0.08);
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md h3 {
|
.skill-md h3 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1.2em 0 0.4em;
|
margin: 1.2em 0 0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md h4, .skill-md h5, .skill-md h6 {
|
.skill-md h4, .skill-md h5, .skill-md h6 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1em 0 0.3em;
|
margin: 1em 0 0.3em;
|
||||||
@@ -158,27 +166,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-md a {
|
.skill-md a {
|
||||||
color: #0891b2;
|
color: var(--duck-teal);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md a:hover {
|
.skill-md a:hover {
|
||||||
color: #0e7490;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md strong {
|
.skill-md strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #14532d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md blockquote {
|
.skill-md blockquote {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
border-left: 3px solid #0891b2;
|
border-left: 3px solid var(--duck-teal);
|
||||||
background: rgba(8, 145, 178, 0.05);
|
background: color-mix(in srgb, var(--duck-teal) 5%, transparent);
|
||||||
border-radius: 0 0.5rem 0.5rem 0;
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
color: #166534;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md ul, .skill-md ol {
|
.skill-md ul, .skill-md ol {
|
||||||
@@ -191,14 +198,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-md li::marker {
|
.skill-md li::marker {
|
||||||
color: #0891b2;
|
color: var(--duck-teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md code {
|
.skill-md code {
|
||||||
padding: 0.15em 0.4em;
|
padding: 0.15em 0.4em;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
background: rgba(8, 145, 178, 0.1);
|
background: color-mix(in srgb, var(--duck-teal) 10%, transparent);
|
||||||
color: #0891b2;
|
color: var(--duck-teal);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
@@ -224,7 +231,7 @@
|
|||||||
.skill-md hr {
|
.skill-md hr {
|
||||||
border: none;
|
border: none;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(20, 83, 45, 0.12);
|
background: hsl(var(--border));
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,20 +243,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-md th {
|
.skill-md th {
|
||||||
background: rgba(8, 145, 178, 0.08);
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: color-mix(in srgb, var(--duck-teal) 8%, transparent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.5em 0.75em;
|
padding: 0.5em 0.75em;
|
||||||
border: 1px solid rgba(20, 83, 45, 0.12);
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md td {
|
.skill-md td {
|
||||||
padding: 0.5em 0.75em;
|
padding: 0.5em 0.75em;
|
||||||
border: 1px solid rgba(20, 83, 45, 0.08);
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md tr:nth-child(even) {
|
.skill-md tr:nth-child(even) {
|
||||||
background: rgba(20, 83, 45, 0.02);
|
background: hsl(var(--muted) / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-md img {
|
.skill-md img {
|
||||||
@@ -259,6 +267,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-md input[type="checkbox"] {
|
.skill-md input[type="checkbox"] {
|
||||||
accent-color: #0891b2;
|
accent-color: var(--duck-teal);
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ async function handleChat({
|
|||||||
const workingDir = state.cwd ?? homeDir;
|
const workingDir = state.cwd ?? homeDir;
|
||||||
|
|
||||||
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
||||||
if (skillsAppend) send(ws, { type: 'system:prompt', text: skillsAppend });
|
const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend;
|
||||||
|
send(ws, { type: 'system:prompt', text: contextAppend });
|
||||||
|
|
||||||
// Build prompt: use AsyncIterable<SDKUserMessage> with image content blocks when images are present
|
// Build prompt: use AsyncIterable<SDKUserMessage> with image content blocks when images are present
|
||||||
let promptInput: string | AsyncIterable<SDKUserMessage> = prompt;
|
let promptInput: string | AsyncIterable<SDKUserMessage> = prompt;
|
||||||
@@ -170,7 +171,7 @@ async function handleChat({
|
|||||||
systemPrompt: {
|
systemPrompt: {
|
||||||
type: 'preset',
|
type: 'preset',
|
||||||
preset: 'claude_code',
|
preset: 'claude_code',
|
||||||
...(skillsAppend ? { append: skillsAppend } : {}),
|
append: contextAppend,
|
||||||
},
|
},
|
||||||
additionalDirectories: [],
|
additionalDirectories: [],
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
|
|||||||
@@ -413,6 +413,71 @@ router.post('/git-clone', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Download file or directory (directories are zipped on-the-fly)
|
||||||
|
router.get('/download', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||||
|
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
|
||||||
|
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
const s = await stat(absPath);
|
||||||
|
const name = absPath.split('/').pop()!;
|
||||||
|
|
||||||
|
if (s.isDirectory()) {
|
||||||
|
const proc = Bun.spawn(['zip', '-r', '-', name], {
|
||||||
|
cwd: dirname(absPath),
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(proc.stdout as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${name}.zip"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Bun.file(absPath);
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type || 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${name}"`,
|
||||||
|
'Content-Length': String(s.size),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download multiple items as a single zip
|
||||||
|
router.post('/download', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||||
|
const { paths } = ctx.get('body') as { paths: string[] };
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required');
|
||||||
|
|
||||||
|
const items: string[] = [];
|
||||||
|
for (const p of paths) {
|
||||||
|
const relPath = p.replace(/^\/+/, '');
|
||||||
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
await stat(absPath); // throws if not found
|
||||||
|
items.push(relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = Bun.spawn(['zip', '-r', '-', ...items], {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(proc.stdout as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': 'attachment; filename="download.zip"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Delete file or directory
|
// Delete file or directory
|
||||||
router.delete('/rm', async (ctx) => {
|
router.delete('/rm', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import { mkdir, rename } from 'node:fs/promises';
|
import { mkdir, rename } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
||||||
import { getTmpAttachmentsDir, getAttachmentsDir, getOpencodeSessionDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
|
import { getTmpAttachmentsDir, getAttachmentsDir, getOpencodeSessionDir, getHomeDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
|
||||||
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
||||||
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
||||||
|
|
||||||
@@ -400,9 +400,11 @@ async function handleChat({ ws, prompt, sessionId, model, attachmentIds, images,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const homeDir = getHomeDir(ws.data.email);
|
||||||
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
||||||
if (skillsAppend) send(ws, { type: 'system:prompt', text: skillsAppend });
|
const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend;
|
||||||
const fullPrompt = skillsAppend ? `<system>${skillsAppend}</system>\n\n${prompt}` : prompt;
|
send(ws, { type: 'system:prompt', text: contextAppend });
|
||||||
|
const fullPrompt = `<system>${contextAppend}</system>\n\n${prompt}`;
|
||||||
|
|
||||||
const parts: Record<string, unknown>[] = [];
|
const parts: Record<string, unknown>[] = [];
|
||||||
if (images?.length) {
|
if (images?.length) {
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ processesRouter.put('/:name/chat', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
processesRouter.delete('/:name/chat', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const name = ctx.req.param('name');
|
||||||
|
|
||||||
|
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
|
||||||
|
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
|
||||||
|
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
|
||||||
|
|
||||||
|
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
|
||||||
|
if (!resolved) return ctx.text('Not found', 404);
|
||||||
|
|
||||||
|
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||||
|
await rm(chatDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
processesRouter.post('/', async (ctx) => {
|
processesRouter.post('/', async (ctx) => {
|
||||||
const { name } = await ctx.req.json<{ name: string }>();
|
const { name } = await ctx.req.json<{ name: string }>();
|
||||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ skillsRouter.put('/:name/chat', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
skillsRouter.delete('/:name/chat', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const name = ctx.req.param('name');
|
||||||
|
|
||||||
|
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||||
|
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||||
|
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
|
||||||
|
|
||||||
|
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
|
||||||
|
if (!resolved) return ctx.text('Not found', 404);
|
||||||
|
|
||||||
|
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||||
|
await rm(chatDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
skillsRouter.post('/', async (ctx) => {
|
skillsRouter.post('/', async (ctx) => {
|
||||||
const { name } = await ctx.req.json<{ name: string }>();
|
const { name } = await ctx.req.json<{ name: string }>();
|
||||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||||
|
|||||||
@@ -171,6 +171,23 @@ tasksRouter.put('/:name/chat', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tasksRouter.delete('/:name/chat', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const name = ctx.req.param('name');
|
||||||
|
|
||||||
|
const nativeTasks = await readTaskDirs(getNativeTasksDir());
|
||||||
|
const globalTasks = await readTaskDirs(getGlobalTasksDir());
|
||||||
|
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
|
||||||
|
|
||||||
|
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
|
||||||
|
if (!resolved) return ctx.text('Not found', 404);
|
||||||
|
|
||||||
|
const chatDir = join(dirname(resolved.filePath), 'chat');
|
||||||
|
await rm(chatDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
tasksRouter.post('/', async (ctx) => {
|
tasksRouter.post('/', async (ctx) => {
|
||||||
const { name } = await ctx.req.json<{ name: string }>();
|
const { name } = await ctx.req.json<{ name: string }>();
|
||||||
if (!name?.trim()) return ctx.text('Name is required', 400);
|
if (!name?.trim()) return ctx.text('Name is required', 400);
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
|
|||||||
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
|
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<details className="max-w-[85%] rounded-2xl bg-duck-dark/5 border border-duck-dark/10 px-4 py-2 text-sm">
|
<details className="max-w-[85%] rounded-2xl bg-duck-dark/5 border border-duck-dark/10 px-4 py-2 text-sm">
|
||||||
<summary className="text-xs text-duck-dark/40 hover:text-duck-dark/60 cursor-pointer select-none">
|
<summary className="text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer select-none">
|
||||||
{label}
|
{label}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-1.5 text-xs text-duck-dark/40 whitespace-pre-wrap border-l-2 border-duck-dark/10 pl-2 max-h-48 overflow-y-auto">
|
<div className="mt-1.5 text-xs text-duck-dark/60 whitespace-pre-wrap border-l-2 border-duck-dark/15 pl-2 max-h-48 overflow-y-auto">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -53,7 +53,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
if (!message.text) return null;
|
if (!message.text) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<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">
|
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-background/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm dark:prose-invert 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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
{message.text}
|
{message.text}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
@@ -81,7 +81,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
case 'error':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<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">
|
<div className="max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
|
||||||
{message.text}
|
{message.text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +97,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
|||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<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">
|
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-background/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm dark:prose-invert 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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
{text}
|
{text}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
|||||||
return (
|
return (
|
||||||
<div className="my-1 space-y-3">
|
<div className="my-1 space-y-3">
|
||||||
{questions.map((q, qi) => (
|
{questions.map((q, qi) => (
|
||||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-white/90 overflow-hidden">
|
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-background/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">
|
<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" />
|
<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>
|
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
|
|||||||
<>
|
<>
|
||||||
<pre
|
<pre
|
||||||
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
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'
|
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' : 'text-duck-dark/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const SessionBar = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
}: SessionBarProps) => (
|
}: SessionBarProps) => (
|
||||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
|
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useState, useCallback } from 'react';
|
import { useRef, useState, useCallback } from 'react';
|
||||||
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
|
import { RefreshCw, FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check, Download } from 'lucide-react';
|
||||||
|
|
||||||
type ToolbarProps = {
|
type ToolbarProps = {
|
||||||
|
onRefresh: () => void;
|
||||||
onCreateDir: (name: string) => void;
|
onCreateDir: (name: string) => void;
|
||||||
onUpload: (files: FileList) => void;
|
onUpload: (files: FileList) => void;
|
||||||
selectionCount: number;
|
selectionCount: number;
|
||||||
@@ -9,11 +10,13 @@ type ToolbarProps = {
|
|||||||
onCut: () => void;
|
onCut: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onPaste: () => void;
|
onPaste: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
onDeleteSelected: () => void;
|
onDeleteSelected: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Toolbar = ({
|
export const Toolbar = ({
|
||||||
|
onRefresh,
|
||||||
onCreateDir,
|
onCreateDir,
|
||||||
onUpload,
|
onUpload,
|
||||||
selectionCount,
|
selectionCount,
|
||||||
@@ -21,6 +24,7 @@ export const Toolbar = ({
|
|||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
onPaste,
|
onPaste,
|
||||||
|
onDownloadSelected,
|
||||||
onDeleteSelected,
|
onDeleteSelected,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
}: ToolbarProps) => {
|
}: ToolbarProps) => {
|
||||||
@@ -69,9 +73,20 @@ export const Toolbar = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshBtn = (
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="Refresh"
|
||||||
|
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
if (selectionCount > 0) {
|
if (selectionCount > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{refreshBtn}
|
||||||
<span className="text-sm font-medium text-duck-dark/70 mr-1">
|
<span className="text-sm font-medium text-duck-dark/70 mr-1">
|
||||||
{selectionCount}
|
{selectionCount}
|
||||||
<span className="hidden md:inline"> selected</span>
|
<span className="hidden md:inline"> selected</span>
|
||||||
@@ -99,6 +114,13 @@ export const Toolbar = ({
|
|||||||
<ClipboardPaste className="h-4 w-4" />
|
<ClipboardPaste className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onDownloadSelected}
|
||||||
|
title="Download"
|
||||||
|
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteSelected}
|
onClick={onDeleteSelected}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
@@ -120,6 +142,7 @@ export const Toolbar = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{refreshBtn}
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={(ev) => {
|
onSubmit={(ev) => {
|
||||||
@@ -133,7 +156,7 @@ export const Toolbar = ({
|
|||||||
value={folderName}
|
value={folderName}
|
||||||
onChange={(ev) => setFolderName(ev.target.value)}
|
onChange={(ev) => setFolderName(ev.target.value)}
|
||||||
placeholder="Folder name"
|
placeholder="Folder name"
|
||||||
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
setShowInput(false);
|
setShowInput(false);
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ type ListDirResponse = {
|
|||||||
reset?: boolean;
|
reset?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerBlobDownload = (blob: Blob, filename: string) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
|
||||||
export const useFiles = (root: string = 'home') => {
|
export const useFiles = (root: string = 'home') => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
||||||
@@ -58,6 +68,26 @@ export const useFiles = (root: string = 'home') => {
|
|||||||
|
|
||||||
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
|
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
|
||||||
|
|
||||||
|
download: async (paths: string[]) => {
|
||||||
|
if (paths.length === 1) {
|
||||||
|
const blob = await client.getBlob(withRoot(`/file-browser/download?path=${encodeURIComponent(paths[0]!)}`));
|
||||||
|
const name = paths[0]!.split('/').pop()!;
|
||||||
|
const isDir = !name.includes('.') || blob.type === 'application/zip';
|
||||||
|
triggerBlobDownload(blob, isDir && !name.endsWith('.zip') ? `${name}.zip` : name);
|
||||||
|
} else {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const url = `${config.API_URL}/${withRoot('/file-browser/download').replace(/^\//, '')}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paths }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Download failed');
|
||||||
|
const blob = await res.blob();
|
||||||
|
triggerBlobDownload(blob, 'download.zip');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
|
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const ColorPicker = (props: ColorPickerProps) => {
|
|||||||
const { value = '#000000', name, label } = props;
|
const { value = '#000000', name, label } = props;
|
||||||
return (
|
return (
|
||||||
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
|
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
|
||||||
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-white px-2 text-xs">{label}</span>}
|
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="h-12 overflow-hidden rounded-full p-0">
|
<div className="h-12 overflow-hidden rounded-full p-0">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buttonClass =
|
const buttonClass =
|
||||||
'h-8 w-8 p-0 hover:bg-white text-muted-foreground disabled:opacity-100 disabled:pointer-events-none';
|
'h-8 w-8 p-0 hover:bg-background text-muted-foreground disabled:opacity-100 disabled:pointer-events-none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted/50 flex w-full items-center justify-between border-t p-4">
|
<div className="bg-muted/50 flex w-full items-center justify-between border-t p-4">
|
||||||
@@ -44,7 +44,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
|||||||
justify-between
|
justify-between
|
||||||
bg-transparent
|
bg-transparent
|
||||||
text-muted-foreground
|
text-muted-foreground
|
||||||
hover:bg-white
|
hover:bg-background
|
||||||
data-[state=open]:bg-muted
|
data-[state=open]:bg-muted
|
||||||
"
|
"
|
||||||
aria-label="Rows per page"
|
aria-label="Rows per page"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function MarkdownEditor({
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
|
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
|
||||||
{/* Email wrapper simulation */}
|
{/* Email wrapper simulation */}
|
||||||
<div className="mx-auto max-w-2xl rounded-lg bg-white dark:bg-slate-800 p-8 shadow-lg">
|
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
|
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
|||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-white/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-white/90 transition-colors cursor-pointer"
|
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
|
||||||
onClick={() => onSelect(key)}
|
onClick={() => onSelect(key)}
|
||||||
>
|
>
|
||||||
<entry.icon className="h-5 w-5" />
|
<entry.icon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type WorkspaceDefinition = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
description?: string;
|
||||||
|
templateIdx?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppRegistryEntry = {
|
export type AppRegistryEntry = {
|
||||||
|
|||||||
@@ -1,45 +1,40 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type ColorMode = 'light' | 'dark';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
theme: 'light',
|
colorMode: 'light' as ColorMode,
|
||||||
setTheme: (theme: string) => {
|
setColorMode: (_mode: ColorMode) => {},
|
||||||
theme;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThemeProviderProps = { children: React.ReactNode; defaultTheme?: string; storageKey?: string };
|
type ColorModeProviderProps = { children: React.ReactNode; defaultMode?: ColorMode; storageKey?: string };
|
||||||
|
|
||||||
const ThemeProviderContext = createContext(initialState);
|
const ColorModeContext = createContext(initialState);
|
||||||
|
|
||||||
export function ThemeProvider(props: ThemeProviderProps) {
|
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||||
const { children, defaultTheme = 'light', storageKey = 'theme' } = props;
|
const { children, defaultMode = 'light', storageKey = 'officer-color-mode' } = props;
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem(storageKey) || defaultTheme);
|
const [colorMode, setColorModeState] = useState<ColorMode>(
|
||||||
|
() => (localStorage.getItem(storageKey) as ColorMode) || defaultMode,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement;
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
root.classList.remove('light', 'dark');
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(colorMode);
|
||||||
|
}, [colorMode]);
|
||||||
|
|
||||||
root.classList.add(theme);
|
const setColorMode = (mode: ColorMode) => {
|
||||||
}, [theme]);
|
localStorage.setItem(storageKey, mode);
|
||||||
|
setColorModeState(mode);
|
||||||
const value = {
|
|
||||||
theme,
|
|
||||||
setTheme: (newTheme: string) => {
|
|
||||||
localStorage.setItem(storageKey, newTheme);
|
|
||||||
setTheme(newTheme);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>;
|
return <ColorModeContext.Provider value={{ colorMode, setColorMode }}>{children}</ColorModeContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useColorMode = () => {
|
||||||
const context = useContext(ThemeProviderContext);
|
const context = useContext(ColorModeContext);
|
||||||
|
|
||||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
if (context === undefined) throw new Error('useColorMode must be used within a ColorModeProvider');
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { cn } from 'helpers/cn';
|
import { cn } from 'helpers/cn';
|
||||||
import { useTheme } from './ThemeProvider';
|
import { useColorMode } from './ThemeProvider';
|
||||||
|
|
||||||
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
|
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
|
||||||
|
|
||||||
export const ThemeToggle = (props: ThemeToggleProps) => {
|
export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||||
const { colorDark, colorLight } = props;
|
const { colorDark, colorLight } = props;
|
||||||
const { theme, setTheme } = useTheme();
|
const { colorMode, setColorMode } = useColorMode();
|
||||||
|
|
||||||
const changeTheme = () => {
|
const toggle = () => {
|
||||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cursor-pointer" onClick={changeTheme}>
|
<div className="cursor-pointer" onClick={toggle}>
|
||||||
<Sun
|
<Sun
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[1.2rem] w-[1.2rem] origin-center rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0',
|
'h-[1.2rem] w-[1.2rem] origin-center rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0',
|
||||||
@@ -26,7 +26,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
|
|||||||
colorDark && 'text-black',
|
colorDark && 'text-black',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle color mode</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTheme } from '@/components/ui/ThemeProvider';
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
import { cn } from 'helpers/cn';
|
import { cn } from 'helpers/cn';
|
||||||
|
|
||||||
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { colorMode, setColorMode } = useColorMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
onClick={() => setColorMode(colorMode === 'light' ? 'dark' : 'light')}
|
||||||
className={cn('h-9 w-9', className)}
|
className={cn('h-9 w-9', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle color mode</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { Plus, Trash2, Flame } from 'lucide-react';
|
||||||
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
|
type Goal = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyGoalsState = {
|
||||||
|
goals: Goal[];
|
||||||
|
lastDate: string;
|
||||||
|
streak: number;
|
||||||
|
lastCompletedDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_STATE_KEY = ['USER_STATE'];
|
||||||
|
const GOALS_STATE_KEY = 'daily-goals';
|
||||||
|
|
||||||
|
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||||
|
const yesterdayStr = () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
function useDailyGoalsState() {
|
||||||
|
const client = useClient();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
||||||
|
queryKey: USER_STATE_KEY,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => client.get('/user/state'),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = userState[GOALS_STATE_KEY] as DailyGoalsState | undefined;
|
||||||
|
const today = todayStr();
|
||||||
|
|
||||||
|
const state: DailyGoalsState = (() => {
|
||||||
|
if (!stored) return { goals: [], lastDate: today, streak: 0, lastCompletedDate: null };
|
||||||
|
if (stored.lastDate === today) return stored;
|
||||||
|
|
||||||
|
// New day — check if yesterday had all goals completed for streak
|
||||||
|
const allDoneYesterday = stored.goals.length > 0 && stored.goals.every((g) => g.done);
|
||||||
|
const wasYesterday = stored.lastDate === yesterdayStr();
|
||||||
|
const streak = allDoneYesterday && wasYesterday ? stored.streak + 1 : allDoneYesterday ? 1 : 0;
|
||||||
|
const lastCompletedDate = allDoneYesterday ? stored.lastDate : stored.lastCompletedDate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
goals: stored.goals.map((g) => ({ ...g, done: false })),
|
||||||
|
lastDate: today,
|
||||||
|
streak,
|
||||||
|
lastCompletedDate,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const setState = useCallback(
|
||||||
|
(update: DailyGoalsState | ((prev: DailyGoalsState) => DailyGoalsState)) => {
|
||||||
|
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||||
|
const current = (currentUserState[GOALS_STATE_KEY] as DailyGoalsState | undefined) ?? state;
|
||||||
|
const next = typeof update === 'function' ? update(current) : update;
|
||||||
|
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [GOALS_STATE_KEY]: next });
|
||||||
|
clientRef.current.patch('/user/state', { [GOALS_STATE_KEY]: next }).catch(() => {});
|
||||||
|
},
|
||||||
|
[queryClient, state],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setState] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextGoalId = Date.now();
|
||||||
|
|
||||||
|
export const DailyGoals = () => {
|
||||||
|
const [state, setState] = useDailyGoalsState();
|
||||||
|
const [newGoal, setNewGoal] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addGoal = () => {
|
||||||
|
const text = newGoal.trim();
|
||||||
|
if (!text) return;
|
||||||
|
nextGoalId++;
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
goals: [...s.goals, { id: String(nextGoalId), text, done: false }],
|
||||||
|
}));
|
||||||
|
setNewGoal('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGoal = (id: string) => {
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
goals: s.goals.map((g) => (g.id === id ? { ...g, done: !g.done } : g)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGoal = (id: string) => {
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
goals: s.goals.filter((g) => g.id !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { goals, streak } = state;
|
||||||
|
const doneCount = goals.filter((g) => g.done).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title="Daily Goals">
|
||||||
|
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||||
|
{streak > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-warning">
|
||||||
|
<Flame size={12} />
|
||||||
|
<span>{streak} day streak</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{goals.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{goals.map((goal) => (
|
||||||
|
<div key={goal.id} className="group flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border cursor-pointer transition-colors ${
|
||||||
|
goal.done ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleGoal(goal.id)}
|
||||||
|
>
|
||||||
|
{goal.done && (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<path d="M2 5l2.5 2.5L8 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className={`flex-1 text-sm ${goal.done ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
{goal.text}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 rounded cursor-pointer text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => removeGoal(goal.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{goals.length > 0 && (
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{doneCount}/{goals.length} completed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a goal..."
|
||||||
|
value={newGoal}
|
||||||
|
onChange={(ev) => setNewGoal(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
addGoal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={addGoal}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { Pause, Play, RotateCcw, Settings, Check, Volume2, Square } from 'lucide-react';
|
||||||
|
import { allSounds, categories } from 'sounds';
|
||||||
|
import type { SoundAsset } from 'sounds';
|
||||||
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
|
type PomodoroSettings = {
|
||||||
|
focusMinutes: number;
|
||||||
|
breakMinutes: number;
|
||||||
|
longBreakMinutes: number;
|
||||||
|
sessionsBeforeLong: number;
|
||||||
|
focusEndSound: string | null;
|
||||||
|
breakEndSound: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PomodoroState = {
|
||||||
|
settings: PomodoroSettings;
|
||||||
|
completedToday: number;
|
||||||
|
lastDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: PomodoroSettings = {
|
||||||
|
focusMinutes: 25,
|
||||||
|
breakMinutes: 5,
|
||||||
|
longBreakMinutes: 15,
|
||||||
|
sessionsBeforeLong: 4,
|
||||||
|
focusEndSound: 'confirmation-002',
|
||||||
|
breakEndSound: 'notification-pop',
|
||||||
|
};
|
||||||
|
|
||||||
|
const playSound = (name: string | null) => {
|
||||||
|
if (!name) return;
|
||||||
|
const sound = allSounds.find((s) => s.name === name);
|
||||||
|
if (sound) new Audio(sound.dataUri).play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_STATE_KEY = ['USER_STATE'];
|
||||||
|
const POMODORO_STATE_KEY = 'pomodoro-state';
|
||||||
|
|
||||||
|
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function usePomodoroState() {
|
||||||
|
const client = useClient();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
||||||
|
queryKey: USER_STATE_KEY,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => client.get('/user/state'),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = userState[POMODORO_STATE_KEY] as PomodoroState | undefined;
|
||||||
|
const state: PomodoroState = stored
|
||||||
|
? { ...stored, completedToday: stored.lastDate === todayStr() ? stored.completedToday : 0, lastDate: todayStr() }
|
||||||
|
: { settings: DEFAULT_SETTINGS, completedToday: 0, lastDate: todayStr() };
|
||||||
|
|
||||||
|
const setState = useCallback(
|
||||||
|
(update: PomodoroState | ((prev: PomodoroState) => PomodoroState)) => {
|
||||||
|
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||||
|
const current = (currentUserState[POMODORO_STATE_KEY] as PomodoroState | undefined) ?? state;
|
||||||
|
const next = typeof update === 'function' ? update(current) : update;
|
||||||
|
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [POMODORO_STATE_KEY]: next });
|
||||||
|
clientRef.current.patch('/user/state', { [POMODORO_STATE_KEY]: next }).catch(() => {});
|
||||||
|
},
|
||||||
|
[queryClient, state],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setState] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'focus' | 'break' | 'long-break';
|
||||||
|
|
||||||
|
const phaseLabel: Record<Phase, string> = {
|
||||||
|
'focus': 'Focus',
|
||||||
|
'break': 'Break',
|
||||||
|
'long-break': 'Long Break',
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseColor: Record<Phase, string> = {
|
||||||
|
'focus': 'text-destructive',
|
||||||
|
'break': 'text-success',
|
||||||
|
'long-break': 'text-accent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSoundName = (name: string) =>
|
||||||
|
name
|
||||||
|
.split('-')
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
type SoundPickerProps = {
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (name: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
|
const [previewName, setPreviewName] = useState<string | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const source = activeCategory === 'all' ? allSounds : (categories.find((c) => c.id === activeCategory)?.sounds ?? []);
|
||||||
|
if (!search) return source;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return source.filter((s) => s.name.toLowerCase().includes(q));
|
||||||
|
}, [search, activeCategory]);
|
||||||
|
|
||||||
|
const preview = (sound: SoundAsset) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
const audio = new Audio(sound.dataUri);
|
||||||
|
audio.addEventListener('ended', () => setPreviewName(null));
|
||||||
|
audio.play();
|
||||||
|
audioRef.current = audio;
|
||||||
|
setPreviewName(sound.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPreview = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
setPreviewName(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Volume2 size={10} />
|
||||||
|
{value ? formatSoundName(value) : 'None'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-md border border-border p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => { stopPreview(); setOpen(false); }}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search sounds..."
|
||||||
|
value={search}
|
||||||
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
|
className="w-full rounded border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{[{ id: 'all', label: 'All' }, ...categories.map((c) => ({ id: c.id, label: c.label }))].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveCategory(tab.id)}
|
||||||
|
className={`px-1.5 py-0.5 rounded text-[10px] cursor-pointer transition-colors border ${
|
||||||
|
activeCategory === tab.id
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:border-foreground/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-32 overflow-y-auto flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs cursor-pointer transition-colors ${
|
||||||
|
value === null ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
onClick={() => { stopPreview(); onChange(null); }}
|
||||||
|
>
|
||||||
|
None (silent)
|
||||||
|
</button>
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<div key={s.name} className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${value === s.name ? 'bg-primary/10 text-primary' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => previewName === s.name ? stopPreview() : preview(s)}
|
||||||
|
>
|
||||||
|
{previewName === s.name ? <Square size={10} /> : <Play size={10} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 text-left cursor-pointer hover:text-foreground transition-colors truncate"
|
||||||
|
onClick={() => { onChange(s.name); }}
|
||||||
|
>
|
||||||
|
{formatSoundName(s.name)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsPanel = ({ settings, onSave }: { settings: PomodoroSettings; onSave: (s: PomodoroSettings) => void }) => {
|
||||||
|
const [draft, setDraft] = useState(settings);
|
||||||
|
|
||||||
|
const numericFields: { key: 'focusMinutes' | 'breakMinutes' | 'longBreakMinutes' | 'sessionsBeforeLong'; label: string }[] = [
|
||||||
|
{ key: 'focusMinutes', label: 'Focus (min)' },
|
||||||
|
{ key: 'breakMinutes', label: 'Break (min)' },
|
||||||
|
{ key: 'longBreakMinutes', label: 'Long break (min)' },
|
||||||
|
{ key: 'sessionsBeforeLong', label: 'Sessions before long' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||||
|
{numericFields.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
value={draft[key]}
|
||||||
|
onChange={(ev) => setDraft((d) => ({ ...d, [key]: Math.max(1, Number(ev.target.value)) }))}
|
||||||
|
className="w-16 rounded border border-border bg-background px-2 py-1 text-right text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<SoundPicker
|
||||||
|
label="Focus end sound"
|
||||||
|
value={draft.focusEndSound}
|
||||||
|
onChange={(name) => setDraft((d) => ({ ...d, focusEndSound: name }))}
|
||||||
|
/>
|
||||||
|
<SoundPicker
|
||||||
|
label="Break end sound"
|
||||||
|
value={draft.breakEndSound}
|
||||||
|
onChange={(name) => setDraft((d) => ({ ...d, breakEndSound: name }))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-1 flex items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={() => onSave(draft)}
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Pomodoro = () => {
|
||||||
|
const [pomState, setPomState] = usePomodoroState();
|
||||||
|
const { settings, completedToday } = pomState;
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>('focus');
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [sessionCount, setSessionCount] = useState(0);
|
||||||
|
|
||||||
|
const phaseDuration = (p: Phase) => {
|
||||||
|
if (p === 'focus') return settings.focusMinutes * 60;
|
||||||
|
if (p === 'break') return settings.breakMinutes * 60;
|
||||||
|
return settings.longBreakMinutes * 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [remaining, setRemaining] = useState(phaseDuration('focus'));
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
clearTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setRemaining((r) => {
|
||||||
|
if (r <= 1) {
|
||||||
|
clearTimer();
|
||||||
|
setRunning(false);
|
||||||
|
if (phase === 'focus') {
|
||||||
|
playSound(settings.focusEndSound);
|
||||||
|
const newCount = sessionCount + 1;
|
||||||
|
setSessionCount(newCount);
|
||||||
|
setPomState((s) => ({ ...s, completedToday: s.completedToday + 1, lastDate: todayStr() }));
|
||||||
|
const isLongBreak = newCount % settings.sessionsBeforeLong === 0;
|
||||||
|
const nextPhase: Phase = isLongBreak ? 'long-break' : 'break';
|
||||||
|
setPhase(nextPhase);
|
||||||
|
setRemaining(phaseDuration(nextPhase));
|
||||||
|
} else {
|
||||||
|
playSound(settings.breakEndSound);
|
||||||
|
setPhase('focus');
|
||||||
|
setRemaining(phaseDuration('focus'));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return r - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return clearTimer;
|
||||||
|
}, [running, phase, sessionCount, settings]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
clearTimer();
|
||||||
|
setRunning(false);
|
||||||
|
setPhase('focus');
|
||||||
|
setRemaining(phaseDuration('focus'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showSettings) {
|
||||||
|
return (
|
||||||
|
<Widget title="Pomodoro">
|
||||||
|
<SettingsPanel
|
||||||
|
settings={settings}
|
||||||
|
onSave={(s) => {
|
||||||
|
setPomState((prev) => ({ ...prev, settings: s }));
|
||||||
|
setShowSettings(false);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = 1 - remaining / phaseDuration(phase);
|
||||||
|
const circumference = 2 * Math.PI * 40;
|
||||||
|
const dashOffset = circumference * (1 - progress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title="Pomodoro">
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 pb-4">
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<svg width="100" height="100" className="-rotate-90">
|
||||||
|
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" />
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={phaseColor[phase]}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-xl font-bold tabular-nums">{formatTime(remaining)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`text-xs font-medium ${phaseColor[phase]}`}>{phaseLabel[phase]}</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={() => setRunning((r) => !r)}
|
||||||
|
>
|
||||||
|
{running ? <Pause size={14} /> : <Play size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span>{completedToday} session{completedToday !== 1 ? 's' : ''} today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { Widget } from '../Widget';
|
||||||
|
|
||||||
|
const USER_STATE_KEY = ['USER_STATE'];
|
||||||
|
const NOTES_STATE_KEY = 'quick-notes';
|
||||||
|
|
||||||
|
function useQuickNotes() {
|
||||||
|
const client = useClient();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
||||||
|
queryKey: USER_STATE_KEY,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => client.get('/user/state'),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = (userState[NOTES_STATE_KEY] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
const setContent = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||||
|
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [NOTES_STATE_KEY]: text });
|
||||||
|
clientRef.current.patch('/user/state', { [NOTES_STATE_KEY]: text }).catch(() => {});
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [content, setContent] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickNotes = () => {
|
||||||
|
const [content, setContent] = useQuickNotes();
|
||||||
|
const [local, setLocal] = useState(content);
|
||||||
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
// Sync from server on first meaningful load
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current) return;
|
||||||
|
if (content) {
|
||||||
|
setLocal(content);
|
||||||
|
initialized.current = true;
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const handleChange = (text: string) => {
|
||||||
|
setLocal(text);
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = setTimeout(() => setContent(text), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title="Quick Notes">
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<textarea
|
||||||
|
value={local}
|
||||||
|
onChange={(ev) => handleChange(ev.target.value)}
|
||||||
|
placeholder="Jot something down..."
|
||||||
|
className="h-32 w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { MapPin, Settings } from 'lucide-react';
|
||||||
|
import { Widget } from '../Widget';
|
||||||
|
import { getWeatherInfo } from './weather-codes';
|
||||||
|
|
||||||
|
type WeatherLocation = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeocodingResult = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
admin1?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CurrentWeather = {
|
||||||
|
temperature_2m: number;
|
||||||
|
apparent_temperature: number;
|
||||||
|
weather_code: number;
|
||||||
|
wind_speed_10m: number;
|
||||||
|
relative_humidity_2m: number;
|
||||||
|
is_day: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyWeather = {
|
||||||
|
time: string[];
|
||||||
|
temperature_2m_max: number[];
|
||||||
|
temperature_2m_min: number[];
|
||||||
|
weather_code: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ForecastResponse = {
|
||||||
|
current: CurrentWeather;
|
||||||
|
daily: DailyWeather;
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_STATE_KEY = ['USER_STATE'];
|
||||||
|
const LOCATION_STATE_KEY = 'weather-location';
|
||||||
|
|
||||||
|
function useWeatherLocation() {
|
||||||
|
const client = useClient();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clientRef = useRef(client);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const { data: state = {} } = useQuery<Record<string, unknown>>({
|
||||||
|
queryKey: USER_STATE_KEY,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => client.get('/user/state'),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = (state[LOCATION_STATE_KEY] as WeatherLocation | undefined) ?? null;
|
||||||
|
|
||||||
|
const setLocation = useCallback(
|
||||||
|
(loc: WeatherLocation | null) => {
|
||||||
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||||
|
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [LOCATION_STATE_KEY]: loc });
|
||||||
|
clientRef.current.patch('/user/state', { [LOCATION_STATE_KEY]: loc }).catch(() => {});
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [location, setLocation] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationSetup = ({ onSelect }: { onSelect: (loc: WeatherLocation) => void }) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [geoLoading, setGeoLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data: results = [] } = useQuery<GeocodingResult[]>({
|
||||||
|
queryKey: ['geocoding', query],
|
||||||
|
enabled: query.length >= 2,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.results ?? [];
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const useGeolocation = () => {
|
||||||
|
if (!navigator.geolocation) return;
|
||||||
|
setGeoLoading(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (pos) => {
|
||||||
|
const { latitude, longitude } = pos.coords;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://geocoding-api.open-meteo.com/v1/search?name=${latitude.toFixed(2)},${longitude.toFixed(2)}&count=1`,
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const name = data.results?.[0]?.name ?? `${latitude.toFixed(2)}, ${longitude.toFixed(2)}`;
|
||||||
|
onSelect({ latitude, longitude, name });
|
||||||
|
} catch {
|
||||||
|
onSelect({ latitude, longitude, name: `${latitude.toFixed(2)}, ${longitude.toFixed(2)}` });
|
||||||
|
} finally {
|
||||||
|
setGeoLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => setGeoLoading(false),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm hover:bg-accent transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
onClick={useGeolocation}
|
||||||
|
disabled={geoLoading}
|
||||||
|
>
|
||||||
|
<MapPin size={14} />
|
||||||
|
{geoLoading ? 'Locating...' : 'Use my location'}
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search city..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 z-10 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
|
||||||
|
{results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
onClick={() => onSelect({ latitude: r.latitude, longitude: r.longitude, name: r.name })}
|
||||||
|
>
|
||||||
|
<MapPin size={12} className="shrink-0 text-muted-foreground" />
|
||||||
|
<span>
|
||||||
|
{r.name}
|
||||||
|
{r.admin1 ? `, ${r.admin1}` : ''}, {r.country}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayName = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const WeatherDisplay = ({ location, onReset }: { location: WeatherLocation; onReset: () => void }) => {
|
||||||
|
const { data, isLoading } = useQuery<ForecastResponse>({
|
||||||
|
queryKey: ['weather', location.latitude, location.longitude],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: String(location.latitude),
|
||||||
|
longitude: String(location.longitude),
|
||||||
|
current: 'temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,is_day',
|
||||||
|
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
|
||||||
|
timezone: 'auto',
|
||||||
|
forecast_days: '5',
|
||||||
|
});
|
||||||
|
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
staleTime: 15 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||||
|
<div className="h-16 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 flex-1 animate-pulse rounded bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current, daily } = data;
|
||||||
|
const info = getWeatherInfo(current.weather_code, current.is_day === 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-3xl">{info.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{Math.round(current.temperature_2m)}°C</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{info.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>Feels {Math.round(current.apparent_temperature)}°C</span>
|
||||||
|
<span>💧 {current.relative_humidity_2m}%</span>
|
||||||
|
<span>💨 {Math.round(current.wind_speed_10m)} km/h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<MapPin size={10} />
|
||||||
|
<span>{location.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{daily.time.map((date, i) => {
|
||||||
|
const dayInfo = getWeatherInfo(daily.weather_code[i]!, true);
|
||||||
|
return (
|
||||||
|
<div key={date} className="flex flex-1 flex-col items-center gap-0.5 rounded-md bg-muted/50 py-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">{dayName(date)}</span>
|
||||||
|
<span className="text-sm">{dayInfo.icon}</span>
|
||||||
|
<span className="text-[10px] tabular-nums">
|
||||||
|
{Math.round(daily.temperature_2m_max[i]!)}° / {Math.round(daily.temperature_2m_min[i]!)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Weather = () => {
|
||||||
|
const [location, setLocation] = useWeatherLocation();
|
||||||
|
const [showSetup, setShowSetup] = useState(false);
|
||||||
|
|
||||||
|
if (!location || showSetup) {
|
||||||
|
return (
|
||||||
|
<Widget title="Weather">
|
||||||
|
<LocationSetup
|
||||||
|
onSelect={(loc) => {
|
||||||
|
setLocation(loc);
|
||||||
|
setShowSetup(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title="Weather">
|
||||||
|
<WeatherDisplay
|
||||||
|
location={location}
|
||||||
|
onReset={() => setShowSetup(true)}
|
||||||
|
/>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
type WeatherInfo = { icon: string; label: string };
|
||||||
|
|
||||||
|
const codes: Record<number, { day: WeatherInfo; night: WeatherInfo }> = {
|
||||||
|
0: { day: { icon: '☀️', label: 'Clear sky' }, night: { icon: '🌙', label: 'Clear sky' } },
|
||||||
|
1: { day: { icon: '🌤️', label: 'Mainly clear' }, night: { icon: '🌙', label: 'Mainly clear' } },
|
||||||
|
2: { day: { icon: '⛅', label: 'Partly cloudy' }, night: { icon: '☁️', label: 'Partly cloudy' } },
|
||||||
|
3: { day: { icon: '☁️', label: 'Overcast' }, night: { icon: '☁️', label: 'Overcast' } },
|
||||||
|
45: { day: { icon: '🌫️', label: 'Fog' }, night: { icon: '🌫️', label: 'Fog' } },
|
||||||
|
48: { day: { icon: '🌫️', label: 'Rime fog' }, night: { icon: '🌫️', label: 'Rime fog' } },
|
||||||
|
51: { day: { icon: '🌦️', label: 'Light drizzle' }, night: { icon: '🌧️', label: 'Light drizzle' } },
|
||||||
|
53: { day: { icon: '🌦️', label: 'Drizzle' }, night: { icon: '🌧️', label: 'Drizzle' } },
|
||||||
|
55: { day: { icon: '🌧️', label: 'Heavy drizzle' }, night: { icon: '🌧️', label: 'Heavy drizzle' } },
|
||||||
|
56: { day: { icon: '🌧️', label: 'Freezing drizzle' }, night: { icon: '🌧️', label: 'Freezing drizzle' } },
|
||||||
|
57: { day: { icon: '🌧️', label: 'Heavy freezing drizzle' }, night: { icon: '🌧️', label: 'Heavy freezing drizzle' } },
|
||||||
|
61: { day: { icon: '🌦️', label: 'Light rain' }, night: { icon: '🌧️', label: 'Light rain' } },
|
||||||
|
63: { day: { icon: '🌧️', label: 'Rain' }, night: { icon: '🌧️', label: 'Rain' } },
|
||||||
|
65: { day: { icon: '🌧️', label: 'Heavy rain' }, night: { icon: '🌧️', label: 'Heavy rain' } },
|
||||||
|
66: { day: { icon: '🌧️', label: 'Freezing rain' }, night: { icon: '🌧️', label: 'Freezing rain' } },
|
||||||
|
67: { day: { icon: '🌧️', label: 'Heavy freezing rain' }, night: { icon: '🌧️', label: 'Heavy freezing rain' } },
|
||||||
|
71: { day: { icon: '🌨️', label: 'Light snow' }, night: { icon: '🌨️', label: 'Light snow' } },
|
||||||
|
73: { day: { icon: '❄️', label: 'Snow' }, night: { icon: '❄️', label: 'Snow' } },
|
||||||
|
75: { day: { icon: '❄️', label: 'Heavy snow' }, night: { icon: '❄️', label: 'Heavy snow' } },
|
||||||
|
77: { day: { icon: '🌨️', label: 'Snow grains' }, night: { icon: '🌨️', label: 'Snow grains' } },
|
||||||
|
80: { day: { icon: '🌦️', label: 'Light showers' }, night: { icon: '🌧️', label: 'Light showers' } },
|
||||||
|
81: { day: { icon: '🌧️', label: 'Showers' }, night: { icon: '🌧️', label: 'Showers' } },
|
||||||
|
82: { day: { icon: '🌧️', label: 'Heavy showers' }, night: { icon: '🌧️', label: 'Heavy showers' } },
|
||||||
|
85: { day: { icon: '🌨️', label: 'Light snow showers' }, night: { icon: '🌨️', label: 'Light snow showers' } },
|
||||||
|
86: { day: { icon: '❄️', label: 'Heavy snow showers' }, night: { icon: '❄️', label: 'Heavy snow showers' } },
|
||||||
|
95: { day: { icon: '⛈️', label: 'Thunderstorm' }, night: { icon: '⛈️', label: 'Thunderstorm' } },
|
||||||
|
96: { day: { icon: '⛈️', label: 'Thunderstorm with hail' }, night: { icon: '⛈️', label: 'Thunderstorm with hail' } },
|
||||||
|
99: { day: { icon: '⛈️', label: 'Thunderstorm with heavy hail' }, night: { icon: '⛈️', label: 'Thunderstorm with heavy hail' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWeatherInfo = (code: number, isDay: boolean): WeatherInfo => {
|
||||||
|
const entry = codes[code];
|
||||||
|
if (!entry) return { icon: '🌡️', label: 'Unknown' };
|
||||||
|
return isDay ? entry.day : entry.night;
|
||||||
|
};
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./Widget": "./Widget.tsx",
|
"./Widget": "./Widget.tsx",
|
||||||
"./Clock": "./Clock/index.tsx",
|
"./Clock": "./Clock/index.tsx",
|
||||||
|
"./Weather": "./Weather/index.tsx",
|
||||||
|
"./Pomodoro": "./Pomodoro/index.tsx",
|
||||||
|
"./DailyGoals": "./DailyGoals/index.tsx",
|
||||||
|
"./QuickNotes": "./QuickNotes/index.tsx",
|
||||||
"./widget-registry": "./widget-registry.tsx",
|
"./widget-registry": "./widget-registry.tsx",
|
||||||
"./WidgetPanel": "./WidgetPanel/index.tsx",
|
"./WidgetPanel": "./WidgetPanel/index.tsx",
|
||||||
"./Workspaces": "./Workspaces/index.tsx"
|
"./Workspaces": "./Workspaces/index.tsx"
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { Clock as ClockIcon } from 'lucide-react';
|
import { Clock as ClockIcon, CloudSun, Timer, Target, StickyNote } from 'lucide-react';
|
||||||
import type { AppRegistryEntry } from '@/components/Workspace';
|
import type { AppRegistryEntry } from '@/components/Workspace';
|
||||||
import { Clock } from './Clock/index';
|
import { Clock } from './Clock/index';
|
||||||
|
import { Weather } from './Weather/index';
|
||||||
|
import { Pomodoro } from './Pomodoro/index';
|
||||||
|
import { DailyGoals } from './DailyGoals/index';
|
||||||
|
import { QuickNotes } from './QuickNotes/index';
|
||||||
|
|
||||||
export const widgetRegistry: Record<string, AppRegistryEntry> = {
|
export const widgetRegistry: Record<string, AppRegistryEntry> = {
|
||||||
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
|
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
|
||||||
|
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather /> },
|
||||||
|
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro /> },
|
||||||
|
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals /> },
|
||||||
|
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes /> },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user