workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,8 +54,8 @@ export function App() {
|
||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/workspaces" element={<Dashboard.WorkspacesScreen />} />
|
||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Bootstrap() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="md:min-w-[480px]">
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="md:py-6 md:px-24 flex flex-col gap-6">
|
||||
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
|
||||
const capabilityItems = [
|
||||
{ id: 'processes', label: 'Processes', icon: Cpu, kind: 'Process', endpoint: '/processes', queryKey: 'processes' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo, kind: 'Task', endpoint: '/tasks', queryKey: 'tasks' },
|
||||
{ id: 'skills', label: 'Skills', icon: Sparkles, kind: 'Skill', endpoint: '/skills', queryKey: 'skills' },
|
||||
{ id: 'tools', label: 'Tools', icon: Wrench, kind: 'Tool', endpoint: '/tools', queryKey: 'tools' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo, kind: 'Task', endpoint: '/tasks', queryKey: 'tasks' },
|
||||
{ id: 'pipelines', label: 'Pipelines', icon: Workflow, kind: 'Pipeline', endpoint: '/pipelines', queryKey: 'pipelines' },
|
||||
{ id: 'processes', label: 'Processes', icon: Cpu, kind: 'Process', endpoint: '/processes', queryKey: 'processes' },
|
||||
{ id: 'workflows', label: 'Workflows', icon: GitBranch, kind: 'Workflow', endpoint: '/workflows', queryKey: 'workflows' },
|
||||
{ id: 'crons', label: 'Crons', icon: Clock, kind: 'Cron', endpoint: '/crons', queryKey: 'crons' },
|
||||
{ id: 'services', label: 'Services', icon: Server, kind: 'Service', endpoint: '/services', queryKey: 'services' },
|
||||
{ id: 'workflows', label: 'Workflows', icon: GitBranch, kind: 'Workflow', endpoint: '/workflows', queryKey: 'workflows' },
|
||||
] as const;
|
||||
|
||||
export const AutomationSidebar = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
|
||||
import { AutomationRightPanel } from './AutomationRightPanel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
@@ -67,7 +67,7 @@ const removeChatPanel = (layout: LayoutNode): LayoutNode => {
|
||||
};
|
||||
|
||||
export const Automation = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/automation', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/automation', defaultLayout);
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const isMobile = useIsMobile();
|
||||
const editing = selection?.editing ?? false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
@@ -13,7 +13,7 @@ const PROMPT_PREFIX = `You are a browser assistant. The user has connected Chrom
|
||||
export const BrowserScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/browser', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/browser', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router';
|
||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
@@ -37,7 +37,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const { sessions } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||
const rawWorkspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { LayoutNode, DashboardDefinition } from 'officerdev';
|
||||
import { WorkspaceView, createDefaultLayout } from 'officerdev';
|
||||
|
||||
export const DashboardScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { value: dashboards, isLoaded } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
||||
const ws = dashboards.find((w) => w.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!ws) return <Navigate to="/dashboards" replace />;
|
||||
|
||||
return <DashboardScreenInner dashboard={ws} />;
|
||||
};
|
||||
|
||||
const DashboardScreenInner = ({ dashboard }: { dashboard: DashboardDefinition }) => {
|
||||
const wsState = useDashboardState<LayoutNode>(`ws-layout-${dashboard.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={wsState} cwd={dashboard.cwd} root={dashboard.root} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, SELECTED_WORKSPACE_KEY } from 'officerdev';
|
||||
import { WorkspaceView, SELECTED_DASHBOARD_KEY } from 'officerdev';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const WorkspacesScreen = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
|
||||
export const DashboardsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/dashboards', defaultLayout);
|
||||
const isMobile = useIsMobile();
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
||||
const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined;
|
||||
|
||||
return (
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'ws-home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ws-home-list', appType: 'dashboard-list' }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'ws-home-right', appType: 'dashboard-preview' }, size: 50 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DashboardsScreen } from './DashboardsScreen';
|
||||
export { DashboardScreen } from './DashboardScreen';
|
||||
@@ -20,6 +20,7 @@ const LIMIT = 50;
|
||||
|
||||
const FOLDERS = [
|
||||
{ key: 'inbox', label: 'Inbox', icon: Inbox },
|
||||
{ key: 'all', label: 'All', icon: Mail },
|
||||
{ key: 'sent', label: 'Sent', icon: Send },
|
||||
{ key: 'spam', label: 'Spam', icon: ShieldAlert },
|
||||
{ key: 'trash', label: 'Trash', icon: Trash2 },
|
||||
@@ -55,6 +56,19 @@ export const EmailList = () => {
|
||||
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
||||
});
|
||||
|
||||
// Auto-switch to "all" if inbox filter returns nothing but emails exist
|
||||
const { data: allCount } = useQuery({
|
||||
queryKey: ['email-messages-all-count'],
|
||||
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'),
|
||||
enabled: folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
||||
setFolder('all');
|
||||
}
|
||||
}, [folder, data?.total, allCount]);
|
||||
|
||||
const handleFolderChange = (newFolder: string) => {
|
||||
setFolder(newFolder);
|
||||
setPage(1);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
@@ -13,7 +13,7 @@ const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite e
|
||||
export const EmailScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/email', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/email', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, useFileViewerPanels } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const FilesScreen = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/files', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/files', defaultLayout);
|
||||
const ephemeral = useFileViewerPanels();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev';
|
||||
import { WorkspaceView, useFileViewerPanels } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
@@ -34,7 +34,7 @@ const components: PanelComponents = {
|
||||
const defaultSort: DefaultFileSort = { field: 'type', direction: 'desc' };
|
||||
|
||||
export const HomeScreen = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/home', defaultLayout);
|
||||
const ephemeral = useFileViewerPanels();
|
||||
const { settings } = useSettings();
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ export const BugReportButton = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={bugReport.handleOpen}
|
||||
className="fixed bottom-20 right-4 z-[90] flex h-10 w-10 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg transition-transform hover:scale-110"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
|
||||
aria-label="Report a bug"
|
||||
>
|
||||
<Bug size={20} />
|
||||
<Bug size={16} />
|
||||
</button>
|
||||
<BugReportDialog
|
||||
open={bugReport.open}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { BugReportButton } from './BugReport/BugReportButton';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
@@ -22,7 +21,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
<BugReportButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -124,7 +124,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/workspaces', '/chat'];
|
||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Menu, Terminal } from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import type { DockItem } from '../Dock';
|
||||
import { useIsTouch } from '../useIsTouch';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { BugReportButton } from '../BugReport/BugReportButton';
|
||||
|
||||
type HeaderProps = {
|
||||
dockItems?: DockItem[];
|
||||
@@ -38,7 +39,17 @@ export function Header({ dockItems }: HeaderProps) {
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<UserMenu />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => (window as any).eruda?.show()}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110"
|
||||
aria-label="Dev console"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
</button>
|
||||
<BugReportButton />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTouch && dockItems && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { LayoutNode, ProjectType } from 'officerdev';
|
||||
import {
|
||||
WorkspaceView,
|
||||
@@ -20,7 +20,7 @@ import { generateSlug } from 'helpers/slug';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const ProjectListScreen = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/projects', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/projects', defaultLayout);
|
||||
const isMobile = useIsMobile();
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [creating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { LayoutNode, ProjectDefinition } from 'officerdev';
|
||||
import { WorkspaceView, createDefaultLayout } from 'officerdev';
|
||||
|
||||
export const ProjectScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { value: projects, isLoaded } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const { value: projects, isLoaded } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
const project = projects.find((p) => p.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
@@ -15,7 +15,7 @@ export const ProjectScreen = () => {
|
||||
};
|
||||
|
||||
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const workspace = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
const workspace = useDashboardState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
||||
+8
-3
@@ -22,6 +22,7 @@ export const GoogleAccount = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
|
||||
const [dismissedError, setDismissedError] = useState(false);
|
||||
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
|
||||
const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running');
|
||||
const lastJob = jobs[0];
|
||||
@@ -143,11 +144,15 @@ export const GoogleAccount = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
{!activeJob && lastJob?.status === 'failed' && !dismissedError && (
|
||||
<button
|
||||
onClick={() => setDismissedError(true)}
|
||||
className="flex items-center gap-2 text-xs text-red-500 hover:opacity-70 transition-opacity cursor-pointer text-left"
|
||||
title="Click to dismiss"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{!activeJob && lastSyncAt && (
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
|
||||
@@ -93,7 +93,7 @@ export const AIModels = () => {
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project workspace</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
|
||||
@@ -11,14 +11,24 @@ type DockPillProps = {
|
||||
visible: boolean;
|
||||
onAction: (path: string) => void;
|
||||
onDragStart: (ev: DragEvent, path: string) => void;
|
||||
onDropOnPill?: (draggedPath: string) => void;
|
||||
dropIndicator?: 'left' | 'right' | null;
|
||||
onDragOverPill?: (ev: DragEvent) => void;
|
||||
onDragLeavePill?: () => void;
|
||||
};
|
||||
|
||||
const DockPill = ({ label, path, color, visible, onAction, onDragStart }: DockPillProps) => (
|
||||
const DockPill = ({
|
||||
label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill,
|
||||
}: DockPillProps) => (
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, path)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
||||
onDragOver={onDragOverPill}
|
||||
onDragLeave={onDragLeavePill}
|
||||
onDrop={onDropOnPill ? (ev) => { ev.preventDefault(); ev.stopPropagation(); const p = ev.dataTransfer.getData('text/plain'); if (p) onDropOnPill(p); } : undefined}
|
||||
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
|
||||
>
|
||||
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
||||
{label}
|
||||
<button
|
||||
@@ -27,6 +37,7 @@ const DockPill = ({ label, path, color, visible, onAction, onDragStart }: DockPi
|
||||
>
|
||||
{visible ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</button>
|
||||
{dropIndicator === 'right' && <span className="absolute -right-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -73,6 +84,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
|
||||
export const DockSettings = () => {
|
||||
const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
|
||||
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
|
||||
|
||||
const visiblePaths = new Set(items.map((i) => i.to));
|
||||
const hiddenItems = allItems.filter((i) => !visiblePaths.has(i.to));
|
||||
@@ -97,8 +109,42 @@ export const DockSettings = () => {
|
||||
[items, setItems],
|
||||
);
|
||||
|
||||
const insertAt = useCallback(
|
||||
(draggedPath: string, targetPath: string, side: 'left' | 'right') => {
|
||||
const paths = items.filter((i) => i.to !== draggedPath).map((i) => i.to);
|
||||
const targetIdx = paths.indexOf(targetPath);
|
||||
if (targetIdx === -1) return;
|
||||
const insertIdx = side === 'left' ? targetIdx : targetIdx + 1;
|
||||
paths.splice(insertIdx, 0, draggedPath);
|
||||
setItems(paths);
|
||||
setDropTarget(null);
|
||||
},
|
||||
[items, setItems],
|
||||
);
|
||||
|
||||
const handlePillDragOver = useCallback((ev: DragEvent, pillPath: string) => {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midX = rect.left + rect.width / 2;
|
||||
const side = ev.clientX < midX ? 'left' : 'right';
|
||||
setDropTarget((prev) => (prev?.path === pillPath && prev?.side === side ? prev : { path: pillPath, side }));
|
||||
}, []);
|
||||
|
||||
const handlePillDragLeave = useCallback(() => setDropTarget(null), []);
|
||||
|
||||
const handlePillDrop = useCallback(
|
||||
(draggedPath: string, targetPath: string) => {
|
||||
if (draggedPath === targetPath) { setDropTarget(null); return; }
|
||||
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
|
||||
insertAt(draggedPath, targetPath, side);
|
||||
},
|
||||
[dropTarget, insertAt],
|
||||
);
|
||||
|
||||
const onDropVisible = useCallback(
|
||||
(path: string) => {
|
||||
setDropTarget(null);
|
||||
if (visiblePaths.has(path)) return;
|
||||
addItem(path);
|
||||
},
|
||||
@@ -107,6 +153,7 @@ export const DockSettings = () => {
|
||||
|
||||
const onDropHidden = useCallback(
|
||||
(path: string) => {
|
||||
setDropTarget(null);
|
||||
if (!visiblePaths.has(path)) return;
|
||||
removeItem(path);
|
||||
},
|
||||
@@ -126,6 +173,10 @@ export const DockSettings = () => {
|
||||
visible
|
||||
onAction={removeItem}
|
||||
onDragStart={onDragStart}
|
||||
dropIndicator={dropTarget?.path === item.to ? dropTarget.side : null}
|
||||
onDragOverPill={(ev) => handlePillDragOver(ev, item.to)}
|
||||
onDragLeavePill={handlePillDragLeave}
|
||||
onDropOnPill={(draggedPath) => handlePillDrop(draggedPath, item.to)}
|
||||
/>
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const TerminalScreen = () => {
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/terminal', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/terminal', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import type { LayoutNode, WorkspaceDefinition } from 'officerdev';
|
||||
import { WorkspaceView, createDefaultLayout } from 'officerdev';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { value: workspaces, isLoaded } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const ws = workspaces.find((w) => w.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!ws) return <Navigate to="/workspaces" replace />;
|
||||
|
||||
return <WorkspaceScreenInner workspace={ws} />;
|
||||
};
|
||||
|
||||
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const wsState = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={wsState} cwd={workspace.cwd} root={workspace.root} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { WorkspacesScreen } from './WorkspacesScreen';
|
||||
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||
@@ -13,7 +13,7 @@ export * from './Tasks';
|
||||
|
||||
export * from './Files';
|
||||
export * from './ChatHistory';
|
||||
export * from './Workspaces';
|
||||
export * from './Dashboards';
|
||||
export * from './Projects';
|
||||
export * from './Terminal';
|
||||
export * from './Email';
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Officer Dev (Alpha)</title>
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Officer Dev (Alpha)" />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta property="og:image" content="https://alpha.officer.dev/og-image-v3.jpg" />
|
||||
<meta property="og:image:secure_url" content="https://static.officer.dev/og-image-v3.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Officer Dev (Alpha)" />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta name="twitter:image" content="https://alpha.officer.dev/og-image-v3.jpg" />
|
||||
|
||||
<!-- Favicons & App Icons -->
|
||||
@@ -32,7 +32,7 @@
|
||||
<meta name="theme-color" content="#1F2620" />
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||
<script>eruda.init();</script>
|
||||
<script>eruda.init(); eruda._entryBtn.hide();</script>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user