diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index 8139eba3..a5c635c5 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -1,18 +1,13 @@
-import { useMemo } from 'react';
-import { useAuth } from 'hooks/useAuth';
+import { useDock } from 'officerdev';
import { Background } from './Background';
import { Header } from './Header';
-import { Dock, dockItems } from './Dock';
+import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
type DashboardLayoutProps = {
children?: React.ReactNode;
};
export function DashboardLayout({ children }: DashboardLayoutProps) {
- const { user } = useAuth();
- const visibleItems = useMemo(
- () => dockItems.filter((item) => !item.role || item.role === user?.role),
- [user?.role],
- );
+ const { items: visibleItems } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index f3c037b4..4ca24c27 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -36,7 +36,7 @@ export const Dock = ({ items, className }: DockProps) => {
const dockRef = useRef
(null);
const location = useLocation();
- const isActive = (to: string) => location.pathname.startsWith(to);
+ const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to));
useEffect(() => {
const handleMouseMove = (ev: MouseEvent) => {
@@ -110,12 +110,12 @@ export const Dock = ({ items, className }: DockProps) => {
};
-import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
+import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
-export const dockItems: DockItem[] = [
+export const ALL_DOCK_ITEMS: DockItem[] = [
+ { label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
-
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
@@ -124,3 +124,5 @@ export const dockItems: DockItem[] = [
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
];
+
+export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/workspaces', '/chat'];
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx
new file mode 100644
index 00000000..1b969c06
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx
@@ -0,0 +1,159 @@
+import { useState, useCallback, type DragEvent } from 'react';
+import { X, Plus, RotateCcw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { useDock } from 'officerdev';
+import { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
+
+type DockPillProps = {
+ label: string;
+ path: string;
+ color: string;
+ visible: boolean;
+ onAction: (path: string) => void;
+ onDragStart: (ev: DragEvent, path: string) => void;
+};
+
+const DockPill = ({ label, path, color, visible, onAction, onDragStart }: DockPillProps) => (
+ 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"
+ >
+
+ {label}
+
+
+);
+
+type DropZoneProps = {
+ label: string;
+ children: React.ReactNode;
+ onDrop: (path: string) => void;
+};
+
+const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
+ const [over, setOver] = useState(false);
+
+ const handleDragOver = useCallback((ev: DragEvent) => {
+ ev.preventDefault();
+ setOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback(() => setOver(false), []);
+
+ const handleDrop = useCallback(
+ (ev: DragEvent) => {
+ ev.preventDefault();
+ setOver(false);
+ const path = ev.dataTransfer.getData('text/plain');
+ if (path) onDrop(path);
+ },
+ [onDrop],
+ );
+
+ return (
+
+
{label}
+
+ {children}
+
+
+ );
+};
+
+export const DockSettings = () => {
+ const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
+
+ const visiblePaths = new Set(items.map((i) => i.to));
+ const hiddenItems = allItems.filter((i) => !visiblePaths.has(i.to));
+
+ const onDragStart = useCallback((ev: DragEvent, path: string) => {
+ ev.dataTransfer.setData('text/plain', path);
+ ev.dataTransfer.effectAllowed = 'move';
+ }, []);
+
+ const addItem = useCallback(
+ (path: string) => {
+ if (visiblePaths.has(path)) return;
+ setItems([...items.map((i) => i.to), path]);
+ },
+ [items, visiblePaths, setItems],
+ );
+
+ const removeItem = useCallback(
+ (path: string) => {
+ setItems(items.filter((i) => i.to !== path).map((i) => i.to));
+ },
+ [items, setItems],
+ );
+
+ const onDropVisible = useCallback(
+ (path: string) => {
+ if (visiblePaths.has(path)) return;
+ addItem(path);
+ },
+ [visiblePaths, addItem],
+ );
+
+ const onDropHidden = useCallback(
+ (path: string) => {
+ if (!visiblePaths.has(path)) return;
+ removeItem(path);
+ },
+ [visiblePaths, removeItem],
+ );
+
+ return (
+
+
+ {items.length === 0 && Drag items here to show in dock}
+ {items.map((item) => (
+
+ ))}
+
+
+
+ {hiddenItems.length === 0 && All items visible}
+ {hiddenItems.map((item) => (
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx
index ede5605b..b1968943 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
-import { User, Lock, Globe, Bot } from 'lucide-react';
+import { User, Lock, Globe, Bot, LayoutGrid } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
@@ -8,6 +8,7 @@ import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { AIModels } from './AIModels';
+import { DockSettings } from './DockSettings';
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
@@ -16,6 +17,7 @@ const sections: SettingsSection[] = [
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: },
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: },
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: },
+ { key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: },
];
const { Sidebar, Content } = createSettingsPanelComponents({
diff --git a/src/servers/api/dock/dock.ts b/src/servers/api/dock/dock.ts
new file mode 100644
index 00000000..5250b730
--- /dev/null
+++ b/src/servers/api/dock/dock.ts
@@ -0,0 +1,33 @@
+import { mkdir } from 'node:fs/promises';
+import { dirname } from 'node:path';
+import { createRouter } from '../../create-router';
+import { getUserDockFile } from '@@/data-path';
+
+const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
+
+export const dockRouter = createRouter();
+
+// GET / — return dock.json, or null if it doesn't exist (frontend uses defaults)
+dockRouter.get('/', async (ctx) => {
+ const email = ctx.get('user').email;
+ const filePath = getUserDockFile(email);
+ const file = Bun.file(filePath);
+
+ if (await file.exists()) {
+ const data = await file.json();
+ return ctx.json(data);
+ }
+
+ return ctx.json(null);
+});
+
+// PUT / — full replacement of dock paths array
+dockRouter.put('/', async (ctx) => {
+ const email = ctx.get('user').email;
+ const body = ctx.get('body');
+ const filePath = getUserDockFile(email);
+
+ await ensureDir(filePath);
+ await Bun.write(filePath, JSON.stringify(body, null, 2));
+ return ctx.json(body);
+});
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index b8d6cbc5..493e1688 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -70,3 +70,5 @@ export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'c
export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) =>
join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments');
+
+export const getUserDockFile = (email: string) => join(DATA_PATH, email, 'dock', 'dock.json');
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 970566f7..ea6674c0 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -19,6 +19,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { piRestRouter } from './api/pi/rest';
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
+import { dockRouter } from './api/dock/dock';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser } from './_middlewares';
@@ -59,6 +60,7 @@ protectedRouter.route('/workspaces', workspacesRouter);
protectedRouter.route('/task-logs', taskLogsRouter);
protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/dev-server', devServerRouter);
+protectedRouter.route('/dock', dockRouter);
protectedRouter.route('/', piRestRouter);
honoServer.route('/api', protectedRouter);
diff --git a/src/workspaces/officerdev/src/hooks/index.ts b/src/workspaces/officerdev/src/hooks/index.ts
index 5ccfdca9..06b0e444 100644
--- a/src/workspaces/officerdev/src/hooks/index.ts
+++ b/src/workspaces/officerdev/src/hooks/index.ts
@@ -1,3 +1,4 @@
export * from './useFilesAPI';
export * from './useFileViewerPanels';
export * from './usePiChat';
+export * from './useDock';
diff --git a/src/workspaces/officerdev/src/hooks/useDock.ts b/src/workspaces/officerdev/src/hooks/useDock.ts
new file mode 100644
index 00000000..5de7da2b
--- /dev/null
+++ b/src/workspaces/officerdev/src/hooks/useDock.ts
@@ -0,0 +1,54 @@
+import { useCallback, useMemo } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useClient } from 'hooks/useClient';
+import { useAuth } from 'hooks/useAuth';
+
+const QUERY_KEY = ['DOCK'];
+
+type DockItemLike = {
+ to: string;
+ role?: string;
+};
+
+export function useDock(allDockItems: T[], defaultPaths?: string[]) {
+ const client = useClient();
+ const { user, isAuthenticated } = useAuth();
+ const queryClient = useQueryClient();
+
+ const { data: dockPaths = null } = useQuery({
+ queryKey: QUERY_KEY,
+ enabled: isAuthenticated,
+ queryFn: () => client.get('/dock'),
+ staleTime: Infinity,
+ });
+
+ const fallbackPaths = useMemo(() => defaultPaths ?? allDockItems.map((i) => i.to), [defaultPaths, allDockItems]);
+ const activePaths = dockPaths ?? fallbackPaths;
+
+ const items = useMemo(() => {
+ const byPath = new Map(allDockItems.map((item) => [item.to, item]));
+ return activePaths
+ .map((path) => byPath.get(path))
+ .filter((item): item is T => !!item && (!item.role || item.role === user?.role));
+ }, [activePaths, allDockItems, user?.role]);
+
+ const allItems = useMemo(
+ () => allDockItems.filter((item) => !item.role || item.role === user?.role),
+ [allDockItems, user?.role],
+ );
+
+ const setItems = useCallback(
+ (paths: string[]) => {
+ queryClient.setQueryData(QUERY_KEY, paths);
+ client.put('/dock', paths);
+ },
+ [client, queryClient],
+ );
+
+ const reset = useCallback(() => {
+ queryClient.setQueryData(QUERY_KEY, null);
+ client.put('/dock', null);
+ }, [client, queryClient]);
+
+ return { items, allItems, setItems, reset };
+}