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:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent bd362dc586
commit 927267e041
98 changed files with 1176 additions and 802 deletions
+2 -2
View File
@@ -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>
);
};
@@ -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">
@@ -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 -4
View File
@@ -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>