diff --git a/HOOKS.md b/HOOKS.md new file mode 100644 index 00000000..b47a2e4f --- /dev/null +++ b/HOOKS.md @@ -0,0 +1,46 @@ +## Authentication + +src/apps/officer-web/Screens/Authentication/ForgotPassword/useResetPassword.ts +src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts + +## Files + +src/apps/officer-web/Screens/Dashboard/Files/state/usePinnedFiles.ts +src/apps/officer-web/Screens/Dashboard/Files/state/useRecentFiles.ts + +## Officer-web State + +src/apps/officer-web/state/useChatGroups.ts +src/apps/officer-web/state/useChatSessions.ts +src/apps/officer-web/state/useInitialData.ts +src/apps/officer-web/state/useLandingPage.ts +src/apps/officer-web/state/useModels.ts +src/apps/officer-web/state/usePlans.ts +src/apps/officer-web/state/useProjectsState.ts +src/apps/officer-web/state/useRecentModels.ts +src/apps/officer-web/state/useResources.ts +src/apps/officer-web/state/useServerSettings.ts +src/apps/officer-web/state/useSettings.ts +src/apps/officer-web/state/useThemeSync.ts +src/apps/officer-web/state/useUserState.ts +src/apps/officer-web/state/useWorkspacesState.ts + +## Chat (apps/Chat) + +src/workspaces/apps/Chat/useChatSessions.ts +src/workspaces/apps/Chat/useChatSession.ts +src/workspaces/apps/Chat/usePi.ts +src/workspaces/apps/Chat/useSlashCommands.ts + +## Other Workspaces + +src/workspaces/apps/CodeEditor/useEditorState.ts +src/workspaces/apps/FileBrowser/useFiles.ts +src/workspaces/apps/FileBrowser/useTasks.ts +src/workspaces/components/DataTable/useFixedHeightPagination.ts +src/workspaces/components/ui/hooks/use-mobile.tsx +src/workspaces/components/ui/hooks/use-toast.ts +src/workspaces/components/ui/use-toast.ts +src/workspaces/i18n/src/useTranslation.ts +src/workspaces/injector/use-client.ts +Done! diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index b26e3efc..9afaabb7 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -10,8 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { useClient } from 'hooks/useClient'; import { useVisiblePiModels } from '@/state/useModels'; import { Card } from '@/components/Card'; -import { usePi } from '@/Screens/Dashboard/Chat/usePi'; -import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; +import { usePi, EmbeddableChat } from 'apps/Chat'; type CapabilitySummary = { dirName: string; name: string; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx index 7d78467d..c4aabb16 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx @@ -1,35 +1,28 @@ import { useRef, useEffect, useState } from 'react'; import { useNavigate, useLocation } from 'react-router'; import { useChatSessions } from '@/state/useChatSessions'; -import { useSlashCommands } from '@/state/useSlashCommands'; import { SessionBar } from 'apps/ChatHistory'; -import type { ModelOption } from '@/state/useModels'; -import type { usePi } from './usePi'; -import { EmbeddableChat, type Attachment } from './EmbeddableChat'; +import { EmbeddableChat, type UsePiType, type Attachment } from 'apps/Chat'; import { Card } from '@/components/Card'; export type { Attachment }; type ChatPanelProps = { - chat: ReturnType; - availableModels?: ModelOption[]; + chat: UsePiType; }; -export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => { +export const ChatPanel = ({ chat }: ChatPanelProps) => { const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat; const location = useLocation(); const navigate = useNavigate(); - const [commandFeedback, setCommandFeedback] = useState(null); const [fullscreen, setFullscreen] = useState(false); - const initialSentRef = useRef(false); const { sessions, deleteSession } = useChatSessions(); - const slashCommands = useSlashCommands({ sessionId }); const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined; const listPath = '/chat'; - // Capture prefill input from location.state (one-time, before first render completes) + // Capture initial state from navigation const locationState = location.state as { initialMessage?: string; prefillInput?: string; @@ -38,42 +31,30 @@ export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => { attachmentIds?: string[]; images?: { filename: string; dataUrl: string }[]; } | null; - const initialPrefill = useRef(locationState?.prefillInput ?? ''); - - const handleBeforeSend = async (text: string) => { - if (text.startsWith('/')) { - const result = await slashCommands.execute(text); - if (result.handled) { - setCommandFeedback(result.feedback); - return true; + + const initialMessage = locationState?.initialMessage + ? { + text: locationState.initialMessage, + attachmentIds: locationState.attachmentIds, + images: locationState.images, + cwd: locationState.cwd, } - } - setCommandFeedback(null); - return false; - }; + : undefined; + + const defaultInput = locationState?.prefillInput ?? ''; + const initialModel = locationState?.model ?? null; - // Auto-send initial message from Home launcher + // Clear location state after capturing useEffect(() => { - const state = location.state as typeof locationState; - if (!state || initialSentRef.current) return; - if (state.prefillInput) { - initialSentRef.current = true; + if (locationState) { window.history.replaceState({}, '', location.pathname); - return; } - if (!state.initialMessage || !isConnected) return; - initialSentRef.current = true; - if (state.model) setSelectedModel(state.model); - sendPrompt(state.initialMessage, state.attachmentIds, state.images, state.cwd); - // Clear the location state so refresh doesn't re-send - window.history.replaceState({}, '', location.pathname); - }, [isConnected, location.state]); + }, []); return ( { /> diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/OpenCodeModelPicker.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/OpenCodeModelPicker.tsx deleted file mode 100644 index bcfaebff..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Chat/OpenCodeModelPicker.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Check, ChevronsUpDown } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import type { ModelOption } from '@/state/useModels'; -import { useRecentModels } from '@/state/useRecentModels'; - -type OpenCodeModelPickerProps = { - models: ModelOption[]; - selectedModel: string | null; - onSelect: (modelId: string) => void; - isConnected: boolean; - isGenerating: boolean; -}; - -export const OpenCodeModelPicker = ({ - models, - selectedModel, - onSelect, - isConnected, - isGenerating, -}: OpenCodeModelPickerProps) => { - const [open, setOpen] = useState(false); - const { recents, addRecent } = useRecentModels(); - - const selected = models.find((m) => m.id === selectedModel); - - const groupedByProvider = useMemo(() => { - const groups: Record = {}; - for (const m of models) { - const provider = m.provider ?? 'Other'; - if (!groups[provider]) groups[provider] = []; - groups[provider].push(m); - } - return Object.entries(groups) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([provider, items]) => ({ - provider, - models: items.sort((a, b) => a.name.localeCompare(b.name)), - })); - }, [models]); - - const handleSelect = (modelId: string) => { - const model = models.find((m) => m.id === modelId); - if (model) { - onSelect(model.id); - addRecent(model); - } - setOpen(false); - }; - - return ( - - - - - - - - - No models found. - {recents.length > 0 && ( - - {recents.map((m) => ( - handleSelect(m.id)} - > - - {m.name} - {m.provider && ({m.provider})} - - ))} - - )} - {groupedByProvider.map(({ provider, models: providerModels }) => ( - - {providerModels.map((m) => ( - handleSelect(m.id)}> - - {m.name} - {m.provider && ({m.provider})} - - ))} - - ))} - - - - - ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/index.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/index.tsx index 9d452827..10b0bc8b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/index.tsx @@ -1,6 +1,3 @@ export { ChatPanel } from './ChatPanel'; -export { EmbeddableChat, type Attachment } from './EmbeddableChat'; -export { InputArea } from './InputArea'; -export { Settings } from './Settings'; -export { usePi } from './usePi'; -export { ChatList } from './ChatList'; +export { EmbeddableChat, usePi, ChatList } from 'apps/Chat'; +export type { Attachment } from 'apps/Chat'; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx index 742a9369..6d7b5549 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx @@ -1,11 +1,9 @@ -import { useEffect, useRef } from 'react'; +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 { useVisiblePiModels } from '@/state/useModels'; -import { usePi } from '@/Screens/Dashboard/Chat/usePi'; -import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; +import { usePi, EmbeddableChat } from 'apps/Chat'; export type SelectedSession = { id: string; @@ -66,12 +64,14 @@ type SessionChatProps = { }; function SessionChat({ sessionId, model }: SessionChatProps) { - const chat = usePi(sessionId, model, { replaceUrl: false }); - const models = useVisiblePiModels(); const { sessions, deleteSession } = useChatSessions(); const [, setSelected] = usePanelChannel(CHANNEL, null); const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; + // We need connection status for the DetailBar, so we still call usePi here + // TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it + const chat = usePi(sessionId, model, { replaceUrl: false }); + return (
- +
); } @@ -92,30 +96,25 @@ function SessionChat({ sessionId, model }: SessionChatProps) { function NewChat() { const location = useLocation(); const locationState = location.state as ChatLocationState; - const initialSentRef = useRef(false); - const chat = usePi(); - const models = useVisiblePiModels(); const [, setSelected] = usePanelChannel(CHANNEL, null); + // We need connection status for DetailBar, so call usePi + const chat = usePi(); + useEffect(() => { if (chat.sessionId) { setSelected({ id: chat.sessionId, model: chat.model }); } }, [chat.sessionId]); - useEffect(() => { - if (!locationState || initialSentRef.current || !chat.isConnected) return; - if (locationState.prefillInput) { - initialSentRef.current = true; - window.history.replaceState({}, '', location.pathname); - return; - } - if (!locationState.initialMessage) return; - initialSentRef.current = true; - if (locationState.model) chat.setSelectedModel(locationState.model); - chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images, locationState.cwd); - window.history.replaceState({}, '', location.pathname); - }, [chat.isConnected, location.state]); + const initialMessage = locationState?.initialMessage + ? { + text: locationState.initialMessage, + attachmentIds: locationState.attachmentIds, + images: locationState.images, + cwd: locationState.cwd, + } + : undefined; return (
@@ -126,8 +125,9 @@ function NewChat() { onDelete={undefined} /> = { - anthropic: 'Anthropic', - openai: 'OpenAI', - opencode: 'OpenCode Zen', - google: 'Google', - groq: 'Groq', - mistral: 'Mistral', - xai: 'xAI', - openrouter: 'OpenRouter', - huggingface: 'Hugging Face', - 'github-copilot': 'GitHub Copilot', - minimax: 'MiniMax', - bedrock: 'Amazon Bedrock', - 'google-vertex': 'Google Vertex AI', - 'azure-openai': 'Azure OpenAI', -}; +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 client = useClient(); const [model, setModel] = useState(settings.chat.defaultModel); - const [input, setInput] = useState(''); - const [attachments, setAttachments] = useState([]); - const [urlDialogOpen, setUrlDialogOpen] = useState(false); - const [urlInput, setUrlInput] = useState(''); - const textareaRef = useRef(null); - const imageInputRef = useRef(null); useEffect(() => { setModel(settings.chat.defaultModel); }, [settings.chat.defaultModel]); - const providers = useMemo( - () => [...new Set(piModels.map((m: ModelOption) => m.provider).filter(Boolean))] as string[], - [piModels], - ); - - const activeProvider = piModels.find((m: ModelOption) => m.id === model)?.provider ?? providers[0]; - const providerModels = piModels.filter((m: ModelOption) => m.provider === activeProvider); - - const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider; - - const handleProviderClick = (provider: string) => { - const firstModel = piModels.find((m: ModelOption) => m.provider === provider); - if (firstModel) setModel(firstModel.id); - }; - - const handleAttachWebpage = async (url: string) => { - const idx = attachments.length; - setAttachments((prev) => [ - ...prev, - { type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true }, - ]); - - try { - const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', { - url, - provider: 'pi-mono', - }); - setAttachments((prev) => - prev.map((a, i) => - i === idx - ? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false } - : a, - ), - ); - } catch { - setAttachments((prev) => prev.filter((_, i) => i !== idx)); - toast.error('Failed to scrape webpage'); - } - }; - - const handleAttachImage = async (file: File) => { - const idx = attachments.length; - setAttachments((prev) => [ - ...prev, - { type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true }, - ]); - - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('provider', 'pi-mono'); - - const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData); - setAttachments((prev) => - prev.map((a, i) => - i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a, - ), - ); - } catch { - setAttachments((prev) => prev.filter((_, i) => i !== idx)); - toast.error('Failed to upload image'); - } - }; - - const handleUrlSubmit = () => { - const url = urlInput.trim(); - if (!url) return; - handleAttachWebpage(url); - setUrlInput(''); - setUrlDialogOpen(false); - }; - - const handleSubmit = () => { - const text = input.trim(); - if (!text) return; - - let prompt = text; - const attachmentIds: string[] = []; - const images: { filename: string; dataUrl: string }[] = []; - for (const a of attachments) { - if (a.loading) continue; - if (a.type === 'webpage' && a.content) { - prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`; - } else if (a.type === 'image' && a.dataUrl) { - prompt = `[Attached image: ${a.filename}]\n\n${prompt}`; - images.push({ filename: a.filename, dataUrl: a.dataUrl }); - } - attachmentIds.push(a.attachmentId); - } - + const handleSubmit = (data: { + prompt: string; + model: string | null; + attachmentIds?: string[]; + images?: { filename: string; dataUrl: string }[]; + }) => { navigate('/chat/new', { state: { - initialMessage: prompt, - model, - attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, - images: images.length > 0 ? images : undefined, + initialMessage: data.prompt, + model: data.model, + attachmentIds: data.attachmentIds, + images: data.images, }, }); }; - const handleKeyDown = (ev: KeyboardEvent) => { - if (ev.key === 'Enter' && !ev.shiftKey) { - ev.preventDefault(); - handleSubmit(); - } - }; - - useEffect(() => { - const textarea = textareaRef.current; - if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px'; - }, [input]); - return ( - <> - -
- {attachments.length > 0 && ( -
- {attachments.map((a, i) => ( - - {a.loading ? ( - - ) : a.type === 'image' && a.dataUrl ? ( - {a.filename} - ) : a.type === 'image' ? ( - - ) : ( - - )} - - {a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url} - - - - ))} -
- )} -
- - - - - - imageInputRef.current?.click()}> - - Image - - - - Text File - - - - PDF - - setUrlDialogOpen(true)}> - - Webpage URL - - - - { - const file = ev.target.files?.[0]; - if (file) handleAttachImage(file); - ev.target.value = ''; - }} - /> -