Workspaces

This commit is contained in:
2026-02-22 00:05:29 +00:00
parent 9e9acd9631
commit 23b369c7e1
101 changed files with 701 additions and 512 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"name": "state",
"version": "0.0.1",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"dependencies": {
"hooks": "workspace:*",
"officerdev": "workspace:*"
}
}
+16
View File
@@ -0,0 +1,16 @@
export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState';
export { useWorkspacesState } from './useWorkspacesState';
export { useProjectsState } from './useProjectsState';
export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings';
export { useResources, getResourceCategory } from './useResources';
export type { Resource, ResourceCredentials, ResourceConnectionConfig, PingResult, ResourceCategory } from './useResources';
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
+53
View File
@@ -0,0 +1,53 @@
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,
};
}
@@ -0,0 +1,69 @@
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[];
};
@@ -0,0 +1,15 @@
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,
};
};
+39
View File
@@ -0,0 +1,39 @@
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;
}
+18
View File
@@ -0,0 +1,18 @@
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 };
};
@@ -0,0 +1,44 @@
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 './useSettings';
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];
}
@@ -0,0 +1,41 @@
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 };
};
+68
View File
@@ -0,0 +1,68 @@
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 };
};
@@ -0,0 +1,42 @@
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 };
};
+101
View File
@@ -0,0 +1,101 @@
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',
},
};
+40
View File
@@ -0,0 +1,40 @@
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 './useSettings';
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];
}
@@ -0,0 +1,52 @@
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 './useSettings';
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 };
}