first
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
export type UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'claude' | 'opencode';
|
||||
defaultModel: string | null;
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
defaultPwd: string;
|
||||
};
|
||||
ai: {
|
||||
enabledModels: string[];
|
||||
enabledProviders: string[];
|
||||
};
|
||||
tasks: {
|
||||
defaultProvider: 'claude' | 'opencode';
|
||||
defaultModel: string | null;
|
||||
};
|
||||
appearance: {
|
||||
theme: string;
|
||||
};
|
||||
languages: {
|
||||
spoken: string[];
|
||||
default: string;
|
||||
translateTo: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserState = Record<string, unknown>;
|
||||
|
||||
export const DEFAULT_SETTINGS: UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'claude',
|
||||
defaultModel: null,
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
},
|
||||
ai: {
|
||||
enabledModels: ['claude-sonnet-4-5', 'claude-opus-4-6', 'claude-haiku-4-5'],
|
||||
enabledProviders: [],
|
||||
},
|
||||
tasks: {
|
||||
defaultProvider: 'claude',
|
||||
defaultModel: null,
|
||||
},
|
||||
appearance: {
|
||||
theme: 'light',
|
||||
},
|
||||
languages: {
|
||||
spoken: ['en'],
|
||||
default: 'en',
|
||||
translateTo: 'en',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSessions } from './useSessions';
|
||||
import { usePlans } from './usePlans';
|
||||
import { useSettings } from './useSettings';
|
||||
import { useThemeSync } from './useThemeSync';
|
||||
|
||||
export const useInitialData = () => {
|
||||
const { sessions } = useSessions();
|
||||
const { plans } = usePlans();
|
||||
const { settings } = useSettings();
|
||||
useThemeSync();
|
||||
|
||||
return { sessions, plans, settings };
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useSettings } from './useSettings';
|
||||
|
||||
export type ModelOption = { id: string; name: string; provider?: string; providerId?: string };
|
||||
|
||||
export const modelKey = (m: ModelOption) => (m.provider ? `${m.provider}:${m.id}` : m.id);
|
||||
|
||||
// Hardcoded fallback in case the API call fails
|
||||
const CLAUDE_MODELS: ModelOption[] = [
|
||||
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
|
||||
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
|
||||
];
|
||||
|
||||
export const useClaudeModels = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: models = CLAUDE_MODELS } = useQuery<ModelOption[]>({
|
||||
queryKey: ['CLAUDE_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const data = await client.get<ModelOption[]>('/claude/models');
|
||||
return data.length > 0 ? data : CLAUDE_MODELS;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
/** @deprecated Use useClaudeModels() instead */
|
||||
export const claudeModels = CLAUDE_MODELS;
|
||||
|
||||
export const useOpenCodeModels = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||
queryKey: ['OC_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<ModelOption[]>('/opencode/models'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
export const useVisibleClaudeModels = () => {
|
||||
const models = useClaudeModels();
|
||||
const { settings } = useSettings();
|
||||
const enabled = settings.ai?.enabledModels ?? [];
|
||||
return useMemo(() => models.filter((m) => enabled.includes(modelKey(m))), [models, enabled]);
|
||||
};
|
||||
|
||||
export const useVisibleOpenCodeModels = () => {
|
||||
const models = useOpenCodeModels();
|
||||
const { settings } = useSettings();
|
||||
const providers = settings.ai?.enabledProviders ?? [];
|
||||
const enabled = settings.ai?.enabledModels ?? [];
|
||||
return useMemo(
|
||||
() => models.filter((m) => providers.includes(m.provider ?? '') && enabled.includes(modelKey(m))),
|
||||
[models, providers, enabled],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SessionEntry, ChatMessage } from '@/Screens/Dashboard/Chat/types';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const useOpenCodeSessions = () => {
|
||||
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
|
||||
queryKey: ['OC_SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<RawSessionEntry[]>('/opencode/sessions'),
|
||||
select: (data) => data.map((s) => ({ ...s, provider: 'opencode' as const })),
|
||||
});
|
||||
|
||||
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/opencode/sessions/${sessionId}/messages`);
|
||||
|
||||
const renameSession = async (sessionId: string | null, title: string) => {
|
||||
if (!title) return;
|
||||
if (!sessionId) return;
|
||||
|
||||
await client.put(`/opencode/sessions/${sessionId}`, { title: title.slice(0, 200) });
|
||||
queryClient.setQueryData<SessionEntry[]>(
|
||||
['OC_SESSIONS'],
|
||||
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
||||
);
|
||||
};
|
||||
|
||||
const deleteSession = async (sessionId: string) => {
|
||||
await client.delete(`/opencode/sessions/${sessionId}`);
|
||||
queryClient.setQueryData<SessionEntry[]>(['OC_SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||
};
|
||||
|
||||
return { sessions, getMessages, renameSession, deleteSession };
|
||||
};
|
||||
@@ -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,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 };
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type AIHarnesses = {
|
||||
claudeCode: boolean;
|
||||
opencode: boolean;
|
||||
};
|
||||
|
||||
type ServerSettings = {
|
||||
onboardingComplete?: boolean;
|
||||
accountMode?: 'organization' | 'single';
|
||||
aiHarnesses?: AIHarnesses;
|
||||
plugins?: Record<string, boolean>;
|
||||
terminalSandboxed?: 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 terminalSandboxed = settings?.terminalSandboxed;
|
||||
|
||||
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, terminalSandboxed, isLoading, saveSettings };
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SessionEntry, ChatMessage } from '@/Screens/Dashboard/Chat/types';
|
||||
import type { SlashCommandResult } from './useSlashCommands';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const useSessions = () => {
|
||||
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
|
||||
queryKey: ['SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<RawSessionEntry[]>('/sessions'),
|
||||
select: (data) => data.map((s) => ({ ...s, provider: 'claude' as const })),
|
||||
});
|
||||
|
||||
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/sessions/${sessionId}/messages`);
|
||||
|
||||
const saveMessages = (sessionId: string, messages: ChatMessage[]) =>
|
||||
client.put(`/sessions/${sessionId}/messages`, messages);
|
||||
|
||||
const renameSession = async (sessionId: string | null, args: string): Promise<SlashCommandResult> => {
|
||||
if (!args) return { handled: true, feedback: 'Usage: /rename <new title>' };
|
||||
if (!sessionId) return { handled: true, feedback: 'No active session to rename.' };
|
||||
|
||||
const title = args.slice(0, 200);
|
||||
try {
|
||||
await client.put(`/sessions/${sessionId}`, { title });
|
||||
queryClient.setQueryData<SessionEntry[]>(
|
||||
['SESSIONS'],
|
||||
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
||||
);
|
||||
return { handled: true, feedback: `Session renamed to "${title}"` };
|
||||
} catch {
|
||||
return { handled: true, feedback: 'Failed to rename session.' };
|
||||
}
|
||||
};
|
||||
|
||||
const archiveSession = async (sessionId: string) => {
|
||||
await client.post(`/sessions/${sessionId}/archive`);
|
||||
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||
};
|
||||
|
||||
const deleteSession = async (sessionId: string) => {
|
||||
await client.delete(`/sessions/${sessionId}`);
|
||||
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||
};
|
||||
|
||||
return { sessions, getMessages, saveMessages, renameSession, archiveSession, deleteSession };
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserSettings } from './types/user-settings';
|
||||
import { DEFAULT_SETTINGS } from './types/user-settings';
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useSessions } from './useSessions';
|
||||
|
||||
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
||||
|
||||
type UseSlashCommandsParams = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
||||
const { renameSession } = useSessions();
|
||||
|
||||
const execute = async (input: string): Promise<SlashCommandResult> => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) return { handled: false };
|
||||
|
||||
const spaceIndex = trimmed.indexOf(' ');
|
||||
const command = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
|
||||
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
|
||||
|
||||
switch (command) {
|
||||
case 'rename':
|
||||
return renameSession(sessionId, args);
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
};
|
||||
|
||||
return { execute };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '@/components/ui/ThemeProvider';
|
||||
import { useSettings } from './useSettings';
|
||||
import { DEFAULT_SETTINGS } from './types/user-settings';
|
||||
|
||||
export const useThemeSync = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const { setTheme } = useTheme();
|
||||
const migrated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
|
||||
// One-time migration: if settings are default and localStorage has a theme, save it
|
||||
if (!migrated.current) {
|
||||
migrated.current = true;
|
||||
const lsTheme = localStorage.getItem('officer-theme');
|
||||
if (
|
||||
lsTheme &&
|
||||
settings.appearance.theme === DEFAULT_SETTINGS.appearance.theme &&
|
||||
lsTheme !== settings.appearance.theme
|
||||
) {
|
||||
const updated = { ...settings, appearance: { ...settings.appearance, theme: lsTheme } };
|
||||
saveSettings(updated);
|
||||
setTheme(lsTheme);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(settings.appearance.theme);
|
||||
}, [settings?.appearance.theme]);
|
||||
};
|
||||
@@ -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 './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['USER_STATE'];
|
||||
|
||||
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {} } = 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];
|
||||
}
|
||||
Reference in New Issue
Block a user