Workspaces
This commit is contained in:
@@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||
import * as Authentication from './Screens/Authentication';
|
||||
import * as Dashboard from './Screens/Dashboard';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
import { useServerSettings } from 'state/useServerSettings';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
|
||||
export function App() {
|
||||
@@ -51,9 +51,7 @@ 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.WorkspaceListScreen />} />
|
||||
<Route path="/workspaces/new" element={<Dashboard.NewWorkspaceRedirect />} />
|
||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/workspaces" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OfficerSvgLogo } from '@/components/Logos';
|
||||
import { useLandingPage } from '@/state/useLandingPage';
|
||||
import { useLandingPage } from 'state/useLandingPage';
|
||||
import { Bootstrap } from './Bootstrap';
|
||||
import { Login } from './Login';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useAuth } from 'hooks/useAuth';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
|
||||
import { AutomationRightPanel } from './AutomationRightPanel';
|
||||
import type { AutomationSelection } from './AutomationRightPanel';
|
||||
import { AutomationEditChat } from './AutomationEditChat';
|
||||
@@ -98,7 +98,7 @@ export const Automation = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} registry={appRegistry} components={panelComponents} />
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { usePiChat, EmbeddableChat } from 'officerdev';
|
||||
|
||||
export type SelectedSession = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
|
||||
type CreateGroupDialogProps = {
|
||||
onClose: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
import type { GroupEntry } from 'officerdev';
|
||||
|
||||
type GroupContextMenuProps = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
import type { SelectedSession } from './ChatDetailPanel';
|
||||
import { CreateGroupDialog } from './CreateGroupDialog';
|
||||
import { GroupContextMenu } from './GroupContextMenu';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import type { SessionEntry } from 'officerdev';
|
||||
|
||||
type SessionContextMenuProps = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
|
||||
export const ChatHistory = () => {
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 { useChatSessions } from 'state/useChatSessions';
|
||||
|
||||
import { SessionList } from './Screen';
|
||||
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
|
||||
|
||||
@@ -50,7 +50,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { SessionBar, EmbeddableChat, type UsePiChatType, type Attachment } from 'officerdev';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useFileViewerPanels } from 'officerdev';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const FilesScreen = () => {
|
||||
@@ -11,7 +10,7 @@ export const FilesScreen = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={workspace} registry={appRegistry} ephemeral={ephemeral} />
|
||||
<WorkspaceView workspace={workspace} ephemeral={ephemeral} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { LayoutNode } from '@/components/Workspace';
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'files-main',
|
||||
appType: 'file-browser',
|
||||
appType: 'officerdev/file-browser',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const HomeScreen = () => {
|
||||
@@ -8,8 +8,7 @@ export const HomeScreen = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={workspace} registry={appRegistry} />
|
||||
<WorkspaceView workspace={workspace} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ export const dockItems: DockItem[] = [
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316', role: 'Super Admin' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { User, LogOut, Settings, Package, Sun, Moon } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
|
||||
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
import { useServerSettings } from 'state/useServerSettings';
|
||||
import { ServerTypeCard } from './ServerTypeCard';
|
||||
import { AIHarnessesCard } from './AIHarnessesCard';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useProjectsState } from '@/state/useProjectsState';
|
||||
import { useProjectsState } from 'state/useProjectsState';
|
||||
import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||
import {
|
||||
AlertDialog,
|
||||
|
||||
@@ -4,12 +4,12 @@ import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from '
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useProjectsState } from '@/state/useProjectsState';
|
||||
import { useProjectsState } from 'state/useProjectsState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateSlug, slugify } from 'helpers/slug';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { useAppRegistry } from 'officerdev';
|
||||
import {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
@@ -482,7 +482,7 @@ const TemplatePreviewInner = ({ templateIdx }: { templateIdx: number }) => {
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, next);
|
||||
};
|
||||
|
||||
return <WorkspaceView workspace={null} layout={layout} onLayoutChange={handleLayoutChange} registry={appRegistry} />;
|
||||
return <WorkspaceView workspace={null} layout={layout} onLayoutChange={handleLayoutChange} />;
|
||||
};
|
||||
|
||||
const TemplatePreviewPanel = () => {
|
||||
@@ -524,17 +524,18 @@ const newProjLayout: LayoutNode = {
|
||||
],
|
||||
};
|
||||
|
||||
const newProjRegistry = {
|
||||
...appRegistry,
|
||||
'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
|
||||
'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
|
||||
'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||
'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||
};
|
||||
const NewProjectForm = () => {
|
||||
const { registry } = useAppRegistry();
|
||||
const newProjRegistry = {
|
||||
...registry,
|
||||
'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
|
||||
'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
|
||||
'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||
'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||
};
|
||||
|
||||
const NewProjectForm = () => (
|
||||
<WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} />
|
||||
);
|
||||
return <WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} />;
|
||||
};
|
||||
|
||||
const ProjectPreviewEmpty = () => {
|
||||
const [creating, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
@@ -576,7 +577,7 @@ const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
|
||||
|
||||
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
|
||||
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} />;
|
||||
};
|
||||
|
||||
const ProjectPreview = () => {
|
||||
@@ -599,14 +600,14 @@ const ProjectPreview = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const projHomeRegistry = {
|
||||
...appRegistry,
|
||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
|
||||
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
|
||||
};
|
||||
|
||||
export const ProjectListScreen = () => {
|
||||
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', defaultLayout);
|
||||
const { registry } = useAppRegistry();
|
||||
const projHomeRegistry = {
|
||||
...registry,
|
||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
|
||||
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
|
||||
};
|
||||
|
||||
if (!isLoaded) return null;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useProjectsState } from '@/state/useProjectsState';
|
||||
import { useProjectsState } from 'state/useProjectsState';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
|
||||
export const ProjectScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -25,7 +25,7 @@ const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
workspace={workspace}
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={appRegistry}
|
||||
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { themes } from 'themes';
|
||||
|
||||
export const Appearance = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const LANGUAGES = [
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useVisiblePiModels, type ModelOption } from 'state/useModels';
|
||||
|
||||
export const TaskDefaults = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { User, Lock, Globe, ListChecks } from 'lucide-react';
|
||||
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 { UserData } from './UserData';
|
||||
import { ChangePassword } from './ChangePassword';
|
||||
@@ -46,7 +46,7 @@ export const ProfileSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Circle, Server, Wrench } from 'lucide-react';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
|
||||
import { useResources, getResourceCategory, type Resource } from 'state/useResources';
|
||||
|
||||
type ResourceItemProps = {
|
||||
resource: Resource;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
|
||||
import { useResources, getResourceCategory, type Resource, type PingResult } from 'state/useResources';
|
||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
|
||||
|
||||
type ConnectionSectionProps = {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { TerminalView, FileViewerView } from 'officerdev';
|
||||
import { appRegistry } from '../../Workspaces/app-registry';
|
||||
|
||||
import { Resources } from './Resources';
|
||||
import { ResourceSidebar } from './ResourceSidebar';
|
||||
import {
|
||||
@@ -174,7 +174,7 @@ export const ResourceSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
import { useServerSettings } from 'state/useServerSettings';
|
||||
|
||||
type PluginInfo = {
|
||||
id: string;
|
||||
|
||||
@@ -12,12 +12,12 @@ import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { usePiModels, useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||
import type { UserSettings } from '@/state/types/user-settings';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { usePiModels, useVisiblePiModels, type ModelOption } from 'state/useModels';
|
||||
import type { UserSettings } from 'state/useSettings';
|
||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
|
||||
|
||||
@@ -158,7 +158,7 @@ export const SystemSettings = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const TerminalScreen = () => {
|
||||
const { user } = useAuth();
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/terminal', defaultLayout);
|
||||
|
||||
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={workspace} registry={appRegistry} />
|
||||
<WorkspaceView workspace={workspace} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { LayoutNode } from '@/components/Workspace';
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'terminal-screen',
|
||||
appType: 'terminal-host',
|
||||
appType: 'officerdev/terminal',
|
||||
};
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { LayoutGrid, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
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 = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<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 [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(null);
|
||||
const isWorkspacesPage = location.pathname === '/workspaces';
|
||||
const filtered = search
|
||||
? workspaces.filter((ws) => {
|
||||
const q = search.toLowerCase();
|
||||
return [ws.name, ws.id, ws.description ?? '', ws.cwd ?? ''].some((field) => field.toLowerCase().includes(q));
|
||||
})
|
||||
: workspaces;
|
||||
|
||||
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 cwdPath = !ws.cwd || ws.cwd === '~' ? '/' : ws.cwd.replace(/^~\//, '/');
|
||||
setFilePath(cwdPath);
|
||||
};
|
||||
|
||||
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setDeleting(ws);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleting) return;
|
||||
setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id));
|
||||
if (selected === deleting.id) setSelected(null);
|
||||
|
||||
const layoutKey = `ws-layout-${deleting.id}`;
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const { [layoutKey]: _, ...rest } = currentState;
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
|
||||
client.patch('/workspaces', { [layoutKey]: null }).catch(() => {});
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
const handleClick = (ws: WorkspaceDefinition) => {
|
||||
if (isWorkspacesPage) {
|
||||
setSelected(ws.id);
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
} else {
|
||||
navigate(`/workspaces/${ws.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Workspaces
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setFilePath('/');
|
||||
setCreating(true);
|
||||
if (!isWorkspacesPage) navigate('/workspaces');
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
New Workspace
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3 pt-2">
|
||||
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search workspaces"
|
||||
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-2">
|
||||
{filtered.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||
isWorkspacesPage && selected === ws.id
|
||||
? 'bg-duck-teal/10 text-duck-teal'
|
||||
: 'text-white/80 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
onClick={() => handleClick(ws)}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{ws.name}</span>
|
||||
{isWorkspacesPage && (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
||||
{search ? 'No matches' : 'No workspaces yet'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,541 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router';
|
||||
import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } 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 { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateSlug, slugify } from 'helpers/slug';
|
||||
import { FileBrowserApp } from 'officerdev';
|
||||
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';
|
||||
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
// --- Layout Templates ---
|
||||
|
||||
let tplCounter = 0;
|
||||
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 (
|
||||
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
|
||||
{node.children.map((child) => (
|
||||
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
|
||||
<ThumbnailNode node={child.node} />
|
||||
</div>
|
||||
))}
|
||||
</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 { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<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) {
|
||||
const existingIds = new Set(workspaces.filter((w) => w.id !== editingId).map((w) => w.id));
|
||||
let newId = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`;
|
||||
|
||||
const idChanged = newId !== editingId;
|
||||
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((ws) =>
|
||||
ws.id === editingId
|
||||
? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx }
|
||||
: ws,
|
||||
),
|
||||
);
|
||||
|
||||
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const newLayoutKey = `ws-layout-${newId}`;
|
||||
|
||||
if (idChanged) {
|
||||
// Remove old keys from cache
|
||||
const oldKeys = [`ws-layout-${editingId}`, `ws-terminals-${editingId}`, `ws-host-terminals-${editingId}`];
|
||||
const cleaned = { ...currentState };
|
||||
for (const k of oldKeys) delete cleaned[k];
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...cleaned, [newLayoutKey]: wsLayout });
|
||||
|
||||
// Delete old files, write new layout
|
||||
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
|
||||
for (const k of oldKeys) patch[k] = null;
|
||||
client.patch('/workspaces', patch).catch(() => {});
|
||||
} else {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
|
||||
client.patch('/workspaces', { [newLayoutKey]: wsLayout }).catch(() => {});
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
setSelected(newId);
|
||||
} else {
|
||||
const existingIds = new Set(workspaces.map((w) => w.id));
|
||||
let id = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||
|
||||
const ws: WorkspaceDefinition = {
|
||||
id,
|
||||
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>>(['WORKSPACES_STATE']) ?? {};
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/workspaces', { [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>
|
||||
);
|
||||
};
|
||||
|
||||
// --- CwdFileBrowser (basePath-constrained file browser for workspace form) ---
|
||||
|
||||
const CwdFileBrowser = () => {
|
||||
const [currentPath] = useUserState<string>('files/currentPath', '/');
|
||||
const [basePath] = useState(currentPath);
|
||||
return <FileBrowserApp basePath={basePath} />;
|
||||
};
|
||||
|
||||
// --- 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-cwd' }, 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,
|
||||
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
|
||||
'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);
|
||||
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 [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||
|
||||
if (creating || editingId) return <NewWorkspaceForm />;
|
||||
|
||||
const handleCreate = () => {
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setFilePath('/');
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<LayoutGrid className="h-10 w-10 text-white/80" />
|
||||
<p className="text-sm text-white/80">Select a workspace or create a new one</p>
|
||||
<Button onClick={handleCreate} 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 ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return <WorkspaceView workspace={ws} registry={appRegistry} cwd={workspace.cwd} />;
|
||||
};
|
||||
|
||||
const WorkspacePreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const { value: workspaces } = useWorkspacesState<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 workspace = useWorkspacesState<LayoutNode>('screens/homepage', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceLayout layout={workspace.value} onLayoutChange={workspace.setValue} registry={registry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewWorkspaceRedirect = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
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 [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(params.get('name') || generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setFilePath(params.get('cwd') || '/');
|
||||
setCreating(true);
|
||||
navigate('/workspaces', { replace: true });
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,26 +1,14 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { appRegistry } from './app-registry';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { value: workspaces, isLoaded } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = workspaces.find((ws) => ws.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!workspace) return <Navigate to="/workspaces" replace />;
|
||||
|
||||
return <WorkspaceScreenInner workspace={workspace} />;
|
||||
};
|
||||
|
||||
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={ws} registry={appRegistry} cwd={workspace.cwd} />
|
||||
<WorkspaceView workspace={workspace} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare } from 'lucide-react';
|
||||
import { Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye, FolderKanban, Columns2, PenLine } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { AppRegistry } from '@/components/Workspace';
|
||||
import { useWorkspace } from '@/components/Workspace';
|
||||
import { CodeEditorView, TerminalView, FileViewerProvider, FileViewerHeader, FileViewerBody, EmbeddableChat, ChatLauncher, usePiChat, FileBrowserApp } from 'officerdev';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||
import { Catalog } from 'sounds';
|
||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||
import { ProjectListApp } from '../Projects/ProjectListApp';
|
||||
import { widgetRegistry } from 'widgets/widget-registry';
|
||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||
|
||||
const ChatWidget = () => <EmbeddableChat className="h-full" />;
|
||||
|
||||
const ChatLauncherWidget = () => {
|
||||
const pi = usePiChat();
|
||||
const models = useVisiblePiModels();
|
||||
console.log(models)
|
||||
return (
|
||||
<ChatLauncher
|
||||
availableModels={models}
|
||||
selectedModel={pi.selectedModel}
|
||||
onModelChange={pi.setSelectedModel}
|
||||
onSubmit={({ prompt, model, attachmentIds, images }) => pi.sendPrompt(prompt, attachmentIds, images)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
||||
|
||||
const FileBrowserWrapper = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
const basePath = cwdToPath(cwd);
|
||||
return <FileBrowserApp basePath={basePath} />;
|
||||
};
|
||||
|
||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||
|
||||
const TerminalHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const HostTerminalHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeEditorHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Code className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Code Editor</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTerminalsRef.current((prev) => {
|
||||
const { [panelId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
}, [panelId]);
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} />;
|
||||
};
|
||||
|
||||
const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { user } = useAuth();
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTerminalsRef.current((prev) => {
|
||||
const { [panelId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
}, [panelId]);
|
||||
|
||||
if (user?.role !== 'Super Admin') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Host Terminal requires Super Admin permissions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
|
||||
};
|
||||
|
||||
const CommandTerminalWrapper = ({ panelId, command, statePrefix }: { panelId: string; command: string; statePrefix: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTerminalsRef.current((prev) => {
|
||||
const { [panelId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
}, [panelId]);
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
||||
};
|
||||
|
||||
const TmuxWrapper = ({ panelId }: { panelId: string }) => (
|
||||
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
|
||||
);
|
||||
|
||||
const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
||||
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
|
||||
);
|
||||
|
||||
const TmuxHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Tmux</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NvimHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Neovim</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type FileViewerChannelState = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
root?: string;
|
||||
} | null;
|
||||
|
||||
const FILE_VIEWER_CHANNEL = 'file-viewer';
|
||||
|
||||
const FileViewerWorkspaceProvider = ({ panelId, children }: { panelId: string; children: ReactNode }) => {
|
||||
const [state] = usePanelChannel<FileViewerChannelState>(`${FILE_VIEWER_CHANNEL}:${panelId}`, null);
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-duck-dark/30 text-sm">
|
||||
No file selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={state.filePath} fileName={state.fileName} root={state.root}>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const appRegistry: AppRegistry = {
|
||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
|
||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory />, availableOnPanel: false },
|
||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog />, availableOnPanel: false },
|
||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper, header: CodeEditorHeader },
|
||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper, header: TerminalHeader },
|
||||
'tmux': { name: 'Tmux', icon: Columns2, component: TmuxWrapper, header: TmuxHeader },
|
||||
'nvim': { name: 'Neovim', icon: PenLine, component: NvimWrapper, header: NvimHeader },
|
||||
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper, header: HostTerminalHeader, availableOnPanel: false },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp />, availableOnPanel: false },
|
||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp />, availableOnPanel: false },
|
||||
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider, availableOnPanel: false },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: ChatLauncherWidget, fixedHeight: 180, availableOnPanel: false },
|
||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||
...widgetRegistry,
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
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';
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
|
||||
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 +1 @@
|
||||
export { WorkspaceListScreen, NewWorkspaceRedirect } from './WorkspaceListScreen';
|
||||
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { AppRegistry } from 'officerdev';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ColorModeProvider } from '@/components/ui/ThemeProvider';
|
||||
import { I18nBridge } from '@/lib/I18nBridge';
|
||||
@@ -39,6 +40,7 @@ const app = (
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} />
|
||||
<AppRegistry />
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</I18nBridge>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import en from '../locales/en.json';
|
||||
import pt from '../locales/pt.json';
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { GroupEntry } from 'officerdev';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function useChatGroups() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: groups = [] } = useQuery<GroupEntry[]>({
|
||||
queryKey: ['PI_GROUPS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<{ groups: GroupEntry[] }>('/pi/groups').then((r) => r.groups),
|
||||
});
|
||||
|
||||
async function createGroup(name: string, slug: string, description?: string, sessionIds?: string[]) {
|
||||
const result = await client.post<{ group: GroupEntry }>('/pi/groups', {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
sessionIds,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
return result.group;
|
||||
}
|
||||
|
||||
async function updateGroup(slug: string, updates: { name?: string; description?: string }) {
|
||||
await client.patch(`/pi/groups/${slug}`, updates);
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
}
|
||||
|
||||
async function deleteGroup(slug: string) {
|
||||
await client.delete(`/pi/groups/${slug}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
}
|
||||
|
||||
async function moveSession(sessionId: string, groupSlug: string | null) {
|
||||
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
moveSession,
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { SessionEntry, ChatMessage, Message } from 'officerdev';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function useChatSessions() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [] } = useQuery<SessionEntry[]>({
|
||||
queryKey: ['PI_SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.post<{ sessions: SessionEntry[] }>('/pi/sessions').then((r) => r.sessions),
|
||||
});
|
||||
|
||||
function getSession(sessionId: string) {
|
||||
return client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
function saveMessages(sessionId: string, messages: ChatMessage[]) {
|
||||
return client.put(`/pi/sessions/${sessionId}/messages`, messages);
|
||||
}
|
||||
|
||||
async function renameSession(sessionId: string, title: string) {
|
||||
await client.patch(`/pi/sessions/${sessionId}`, { title });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string) {
|
||||
await client.delete(`/pi/sessions/${sessionId}`);
|
||||
queryClient.setQueryData<SessionEntry[]>(
|
||||
['PI_SESSIONS'],
|
||||
(prev) => prev?.filter((s) => s.id !== sessionId) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
function searchSessions(query: string) {
|
||||
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
getSession,
|
||||
saveMessages,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
searchSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
|
||||
|
||||
type SessionWithMessages = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
cwd: string;
|
||||
groupSlug?: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
cost: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalUSD: number;
|
||||
};
|
||||
messages: Message[];
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { usePlans } from './usePlans';
|
||||
import { useSettings } from './useSettings';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { usePlans } from 'state/usePlans';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useColorModeSync } from './useThemeSync';
|
||||
|
||||
export const useInitialData = () => {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
export const useLandingPage = () => {
|
||||
const apiClient = useClient();
|
||||
|
||||
const { data: setupData, isLoading } = useQuery({
|
||||
queryKey: ['LANDING_PAGE_DATA'],
|
||||
queryFn: () => apiClient.get<{ registrationOpen: boolean }>('/landing-page-data'),
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
registrationOpen: setupData?.registrationOpen,
|
||||
};
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useSettings } from './useSettings';
|
||||
import type { ModelOption } from 'officerdev';
|
||||
|
||||
export type { ModelOption };
|
||||
|
||||
export function modelKey(m: ModelOption): string {
|
||||
return `${m.provider}:${m.id}`;
|
||||
}
|
||||
|
||||
export function usePiModels() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||
queryKey: ['PI_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const data = await client.get<{ models: ModelOption[] }>('/pi/models');
|
||||
return data.models;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export function useVisiblePiModels() {
|
||||
const models = usePiModels();
|
||||
const { settings } = useSettings();
|
||||
const enabled = settings.ai?.enabledModels ?? [];
|
||||
|
||||
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
|
||||
// If no models match the visibility filter, show all
|
||||
// The provider list changes dynamically based on API keys so the filter may be stale
|
||||
return filtered.length > 0 ? filtered : models;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const usePlans = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['PLANS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
const getPlan = (name: string) => client.getText(`/plans/${name}`);
|
||||
|
||||
return { plans, getPlan };
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['PROJECTS_STATE'];
|
||||
|
||||
export function useProjectsState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/user/projects-state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
clientRef.current
|
||||
.patch<UserState>('/user/projects-state', { [key]: newValue })
|
||||
.then((serverState) => {
|
||||
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue, isSuccess];
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useUserState } from './useUserState';
|
||||
import type { ModelOption } from './useModels';
|
||||
|
||||
const MAX_RECENTS = 5;
|
||||
|
||||
export const useRecentModels = () => {
|
||||
const [recents, setRecents] = useUserState<ModelOption[]>('recentModels', []);
|
||||
const migrated = useRef(false);
|
||||
|
||||
// One-time migration from localStorage
|
||||
useEffect(() => {
|
||||
if (migrated.current) return;
|
||||
migrated.current = true;
|
||||
|
||||
const raw = localStorage.getItem('OC_RECENT_MODELS');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ModelOption[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setRecents(parsed.slice(0, MAX_RECENTS));
|
||||
localStorage.removeItem('OC_RECENT_MODELS');
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('OC_RECENT_MODELS');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRecent = useCallback(
|
||||
(model: ModelOption) => {
|
||||
setRecents((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== model.id);
|
||||
return [model, ...filtered].slice(0, MAX_RECENTS);
|
||||
});
|
||||
},
|
||||
[setRecents],
|
||||
);
|
||||
|
||||
return { recents, addRecent };
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export type ResourceCredentials = {
|
||||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type ResourceConnectionConfig = {
|
||||
url: string;
|
||||
credentials?: ResourceCredentials;
|
||||
};
|
||||
|
||||
export type Resource = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
type: string;
|
||||
port: string | null;
|
||||
description: string;
|
||||
installCommand: string | null;
|
||||
uninstallCommand: string | null;
|
||||
manageCommand: string | null;
|
||||
verifyCommand: string | null;
|
||||
updateCommand: string | null;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
connectionConfig: ResourceConnectionConfig | null;
|
||||
};
|
||||
|
||||
export type PingResult = {
|
||||
reachable: boolean;
|
||||
latencyMs: number | null;
|
||||
};
|
||||
|
||||
export type ResourceCategory = 'api-based' | 'local-cli';
|
||||
|
||||
export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
|
||||
|
||||
const RESOURCES_KEY = ['RESOURCES'];
|
||||
|
||||
export const useResources = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: resources, isLoading } = useQuery({
|
||||
queryKey: RESOURCES_KEY,
|
||||
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
|
||||
});
|
||||
|
||||
const saveConnectionConfig = async (id: string, config: Partial<ResourceConnectionConfig>) => {
|
||||
await client.patch(`/server-settings/resources/config/${id}`, config);
|
||||
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||
};
|
||||
|
||||
const pingResource = async (id: string, url?: string) => {
|
||||
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
|
||||
};
|
||||
|
||||
const runCommand = async (id: string, action: string) => {
|
||||
const result = await client.post<{ exitCode: number; output: string }>(`/server-settings/resources/${id}/run`, { action });
|
||||
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { resources, isLoading, saveConnectionConfig, pingResource, runCommand };
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type AIHarnesses = {
|
||||
claudeCode: boolean;
|
||||
opencode: boolean;
|
||||
piMono: boolean;
|
||||
};
|
||||
|
||||
type ServerSettings = {
|
||||
onboardingComplete?: boolean;
|
||||
accountMode?: 'organization' | 'single';
|
||||
aiHarnesses?: AIHarnesses;
|
||||
plugins?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = ['SERVER_SETTINGS'];
|
||||
|
||||
export const useServerSettings = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: SETTINGS_KEY,
|
||||
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
|
||||
});
|
||||
|
||||
const onboardingComplete = settings?.onboardingComplete ?? true;
|
||||
const accountMode = settings?.accountMode;
|
||||
const aiHarnesses = settings?.aiHarnesses;
|
||||
const plugins = settings?.plugins;
|
||||
const saveSettings = useCallback(
|
||||
async (update: Partial<ServerSettings>) => {
|
||||
const result = await client.put<ServerSettings>('/server-settings', update);
|
||||
queryClient.setQueryData(SETTINGS_KEY, result);
|
||||
},
|
||||
[client, queryClient],
|
||||
);
|
||||
|
||||
return { onboardingComplete, accountMode, aiHarnesses, plugins, isLoading, saveSettings };
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
const QUERY_KEY = ['USER_SETTINGS'];
|
||||
|
||||
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
|
||||
ai: {
|
||||
enabledModels: saved.ai?.enabledModels?.length ? saved.ai.enabledModels : DEFAULT_SETTINGS.ai.enabledModels,
|
||||
enabledProviders: saved.ai?.enabledProviders ?? DEFAULT_SETTINGS.ai.enabledProviders,
|
||||
},
|
||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||
});
|
||||
|
||||
export const useSettings = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings = DEFAULT_SETTINGS } = useQuery<UserSettings>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const saved = await client.get<Partial<UserSettings>>('/user/settings');
|
||||
return mergeWithDefaults(saved);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (newSettings: UserSettings) => {
|
||||
queryClient.setQueryData(QUERY_KEY, newSettings);
|
||||
await client.put<UserSettings>('/user/settings', newSettings);
|
||||
},
|
||||
[client, queryClient],
|
||||
);
|
||||
|
||||
return { settings, saveSettings };
|
||||
};
|
||||
|
||||
export type UseSettingsType = ReturnType<typeof useSettings>;
|
||||
|
||||
export type UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'pi';
|
||||
defaultModel: string | null;
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
defaultPwd: string;
|
||||
};
|
||||
ai: {
|
||||
enabledModels: string[];
|
||||
enabledProviders: string[];
|
||||
};
|
||||
tasks: {
|
||||
defaultProvider: 'pi';
|
||||
defaultModel: string | null;
|
||||
};
|
||||
appearance: {
|
||||
colorMode: 'light' | 'dark';
|
||||
colorTheme: string;
|
||||
};
|
||||
languages: {
|
||||
spoken: string[];
|
||||
default: string;
|
||||
translateTo: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserState = Record<string, unknown>;
|
||||
|
||||
export const DEFAULT_SETTINGS: UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
},
|
||||
ai: {
|
||||
enabledModels: [],
|
||||
enabledProviders: [],
|
||||
},
|
||||
tasks: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
},
|
||||
appearance: {
|
||||
colorMode: 'light',
|
||||
colorTheme: 'DuckPond',
|
||||
},
|
||||
languages: {
|
||||
spoken: ['en'],
|
||||
default: 'en',
|
||||
translateTo: 'en',
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
import { useSettings } from './useSettings';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
|
||||
export const useColorModeSync = () => {
|
||||
const { settings } = useSettings();
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['USER_STATE'];
|
||||
|
||||
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
// Immediate fire-and-forget PATCH
|
||||
clientRef.current.patch('/user/state', { [key]: newValue }).catch(() => {});
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue, isSuccess];
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['WORKSPACES_STATE'];
|
||||
|
||||
export function useWorkspacesState<T>(key: string, defaultValue: T) {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/workspaces'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Seed default to backend when key is missing after initial fetch
|
||||
const seededKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isSuccess || seededKeyRef.current === key) return;
|
||||
seededKeyRef.current = key;
|
||||
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
if (!(key in currentState)) {
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
|
||||
clientRef.current.patch('/workspaces', { [key]: defaultValue }).catch(() => { });
|
||||
}
|
||||
}, [isSuccess, key, defaultValue, queryClient]);
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
clientRef.current.patch('/workspaces', { [key]: newValue }).catch(() => { });
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return { key, value, setValue, isLoaded: isSuccess };
|
||||
}
|
||||
Reference in New Issue
Block a user