This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -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 };
};
+68
View File
@@ -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 };
};
+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,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 };
};
+53
View File
@@ -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 };
};
+45
View File
@@ -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];
}