ready for app-registry refactor

This commit is contained in:
2026-02-21 17:28:10 +00:00
parent 6d97edb6ab
commit fe7874dd65
21 changed files with 281 additions and 246 deletions
@@ -1,44 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { Widget } from 'widgets/Widget';
import { useSettings } from '@/state/useSettings';
import { useVisiblePiModels } from '@/state/useModels';
import { ChatLauncher as ChatLauncherComponent } from 'apps/Chat';
export const ChatLauncher = () => {
const navigate = useNavigate();
const { settings } = useSettings();
const piModels = useVisiblePiModels();
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
useEffect(() => {
setModel(settings.chat.defaultModel);
}, [settings.chat.defaultModel]);
const handleSubmit = (data: {
prompt: string;
model: string | null;
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
}) => {
navigate('/chat/new', {
state: {
initialMessage: data.prompt,
model: data.model,
attachmentIds: data.attachmentIds,
images: data.images,
},
});
};
return (
<Widget title="Start Chat">
<ChatLauncherComponent
availableModels={piModels}
selectedModel={model}
onModelChange={setModel}
onSubmit={handleSubmit}
/>
</Widget>
);
};
@@ -0,0 +1,15 @@
import { WorkspaceView } from '@/components/Workspace';
import { useWorkspacesState } from '@/state/useWorkspacesState';
import { appRegistry } from '../Workspaces/app-registry';
import { defaultLayout } from './defaultLayout';
export const HomeScreen = () => {
const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout);
return (
<div className="h-full w-full">
<WorkspaceView workspace={workspace} registry={appRegistry} />
</div>
);
};
@@ -0,0 +1,24 @@
import type { LayoutNode } from '@/components/Workspace';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'home-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'home-left', appType: 'workspace-list' }, size: 20 },
{
node: {
type: 'group',
id: 'home-center',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
],
},
size: 60,
},
{ node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
],
};
@@ -1,36 +1 @@
import { useState } from 'react';
import type { LayoutNode } from '@/components/Workspace';
import { WorkspaceView } from '@/components/Workspace';
import { appRegistry } from '../Workspaces/app-registry';
const initialLayout: LayoutNode = {
type: 'group',
id: 'home-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'home-left', appType: 'workspace-list' }, size: 20 },
{
node: {
type: 'group',
id: 'home-center',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
],
},
size: 60,
},
{ node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
],
};
export const HomeScreen = () => {
const [layout, setLayout] = useState<LayoutNode>(initialLayout);
return (
<div className="h-full w-full">
<WorkspaceView layout={layout} onLayoutChange={setLayout} registry={appRegistry} />
</div>
);
};
export * from './HomeScreen';
@@ -0,0 +1,20 @@
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 { 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} />
</div>
);
};
@@ -0,0 +1,7 @@
import type { LayoutNode } from '@/components/Workspace';
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'terminal-screen',
appType: 'terminal-host',
};
@@ -1,30 +1 @@
import { useState } from 'react';
import { Navigate } from 'react-router';
import { useAuth } from 'hooks/useAuth';
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
import type { LayoutNode } from '@/components/Workspace';
import { appRegistry } from '../Workspaces/app-registry';
export const TerminalScreen = () => {
const { user } = useAuth();
const [layout, setLayout] = useState<LayoutNode>(defaultLayout);
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
return (
<div className="h-full w-full">
<WorkspaceView
workspace={null}
layout={layout}
onLayoutChange={setLayout}
registry={appRegistry} />
</div>
);
};
const defaultLayout: LayoutNode = {
type: 'panel',
id: 'terminal-screen',
appType: 'terminal-host'
};
export * from './TerminalScreen';
@@ -32,7 +32,7 @@ export const WorkspaceListApp = () => {
const navigate = useNavigate();
const client = useClient();
const queryClient = useQueryClient();
const [workspaces, setWorkspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
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);
@@ -77,7 +77,7 @@ export const WorkspaceListApp = () => {
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState;
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
client.patch('/workspaces', { [layoutKey]: null }).catch(() => {});
setDeleting(null);
};
@@ -279,7 +279,7 @@ const CreatePanel = () => {
const client = useClient();
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [filePath] = useUserState<string>('files/currentPath', '/');
const [workspaces, setWorkspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
@@ -325,10 +325,10 @@ const CreatePanel = () => {
// Delete old files, write new layout
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
for (const k of oldKeys) patch[k] = null;
client.patch('/user/workspaces-state', patch).catch(() => {});
client.patch('/workspaces', patch).catch(() => {});
} else {
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
client.patch('/user/workspaces-state', { [newLayoutKey]: wsLayout }).catch(() => {});
client.patch('/workspaces', { [newLayoutKey]: wsLayout }).catch(() => {});
}
setEditingId(null);
@@ -351,7 +351,7 @@ const CreatePanel = () => {
setWorkspaces((prev) => [...prev, ws]);
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout });
client.patch('/user/workspaces-state', { [layoutKey]: wsLayout }).catch(() => {});
client.patch('/workspaces', { [layoutKey]: wsLayout }).catch(() => {});
setName('');
setDescription('');
@@ -478,14 +478,14 @@ const WorkspacePreviewEmpty = () => {
};
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
const [layout, setLayout] = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
return <WorkspaceView workspace={ws} registry={appRegistry} cwd={workspace.cwd} />;
};
const WorkspacePreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const [workspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const { value: workspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
if (!workspace) return <WorkspacePreviewEmpty />;
@@ -506,13 +506,11 @@ const WorkspacePreview = () => {
const registry = { ...appRegistry, 'workspace-preview': { name: 'Workspace Preview', icon: LayoutGrid, component: WorkspacePreview } };
export const WorkspaceListScreen = () => {
const [layout, setLayout, isLoaded] = useWorkspacesState<LayoutNode>('ws-layout-ws-homepage', defaultLayout);
if (!isLoaded) return null;
const workspace = useWorkspacesState<LayoutNode>('screens/homepage', defaultLayout);
return (
<div className="h-full w-full">
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={registry} />
<WorkspaceLayout layout={workspace.value} onLayoutChange={workspace.setValue} registry={registry} />
</div>
);
};
@@ -6,7 +6,7 @@ import { appRegistry } from './app-registry';
export const WorkspaceScreen = () => {
const { id } = useParams<{ id: string }>();
const [workspaces, , isLoaded] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const { value: workspaces, isLoaded } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const workspace = workspaces.find((ws) => ws.id === id);
if (!isLoaded) return null;
@@ -16,16 +16,11 @@ export const WorkspaceScreen = () => {
};
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
const [layout, setLayout] = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
return (
<div className="h-full w-full">
<WorkspaceView
workspace={workspace}
layout={layout}
onLayoutChange={setLayout}
registry={appRegistry}
/>
<WorkspaceView workspace={ws} registry={appRegistry} cwd={workspace.cwd} />
</div>
);
};
@@ -9,22 +9,30 @@ import { TerminalView } from 'apps/Terminal';
import { useWorkspacesState } from '@/state/useWorkspacesState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
import { usePi } from 'apps/Chat';
import { ChatPanel } from 'apps/Chat';
import { EmbeddableChat, ChatLauncher, usePi } from 'apps/Chat';
import { useVisiblePiModels } from '@/state/useModels';
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
import { Files } from '../Files';
import { Catalog } from 'sounds';
import { WorkspaceListApp } from './WorkspaceListApp';
import { ProjectListApp } from '../Projects/ProjectListApp';
import { ChatLauncher } from '../Home/ChatLauncher';
import { widgetRegistry } from 'widgets/widget-registry';
import { WidgetPanel } from 'widgets/WidgetPanel';
const ChatWidget = () => {
const ChatWidget = () => <EmbeddableChat className="h-full" />;
const ChatLauncherWidget = () => {
const pi = usePi();
const models = useVisiblePiModels();
return <ChatPanel chat={pi} availableModels={models} />;
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));
@@ -75,7 +83,7 @@ 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 [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
@@ -105,7 +113,7 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
const { user } = useAuth();
const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
@@ -142,7 +150,7 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
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 [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
@@ -239,7 +247,7 @@ export const appRegistry: AppRegistry = {
'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: () => <ChatLauncher />, fixedHeight: 180, 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,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
@@ -6,7 +6,7 @@ import type { UserState } from './types/user-settings';
const QUERY_KEY = ['WORKSPACES_STATE'];
export function useWorkspacesState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
export function useWorkspacesState<T>(key: string, defaultValue: T) {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
@@ -16,10 +16,23 @@ export function useWorkspacesState<T>(key: string, defaultValue: T): [T, (value:
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/user/workspaces-state'),
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(
@@ -30,10 +43,10 @@ export function useWorkspacesState<T>(key: string, defaultValue: T): [T, (value:
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current.patch('/user/workspaces-state', { [key]: newValue }).catch(() => {});
clientRef.current.patch('/workspaces', { [key]: newValue }).catch(() => { });
},
[key, defaultValue, queryClient],
);
return [value, setValue, isSuccess];
return { key, value, setValue, isLoaded: isSuccess };
}