diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 32db25fa..dac808b0 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -21,13 +21,16 @@ import { LoginScreen } from "@/components/LoginScreen"; import { NotificationToasts } from "@/components/NotificationToast"; import { NotificationCentre } from "@/components/NotificationCentre"; import { useNotificationStore } from "@/stores/notification-store"; +import { TaosAssistantPanel } from "@/components/TaosAssistantPanel"; +import { useTaosAgentStore } from "@/stores/taos-agent-store"; interface SystemShortcutsProps { toggleSearch: () => void; toggleLaunchpad: () => void; + toggleAssistant: () => void; } -function SystemShortcuts({ toggleSearch, toggleLaunchpad }: SystemShortcutsProps) { +function SystemShortcuts({ toggleSearch, toggleLaunchpad, toggleAssistant }: SystemShortcutsProps) { const windows = useProcessStore((s) => s.windows); const closeWindow = useProcessStore((s) => s.closeWindow); const minimizeWindow = useProcessStore((s) => s.minimizeWindow); @@ -72,6 +75,7 @@ function SystemShortcuts({ toggleSearch, toggleLaunchpad }: SystemShortcutsProps useShortcut("Ctrl+Space", toggleSearch, "Toggle search palette", "system"); useShortcut("Ctrl+l", toggleLaunchpad, "Toggle launchpad", "system"); + useShortcut("Ctrl+/", toggleAssistant, "Toggle taOS Assistant", "system"); useShortcut("Ctrl+w", closeFocused, "Close focused window", "system"); useShortcut("Ctrl+m", minimizeFocused, "Minimize focused window", "system"); useShortcut("Ctrl+f", maximizeFocused, "Maximize/restore focused window", "system"); @@ -135,6 +139,7 @@ export function App() { const toggleLaunchpad = useCallback(() => setLaunchpadOpen((v) => !v), []); const toggleSearch = useCallback(() => setSearchOpen((v) => !v), []); + const toggleAssistant = useCallback(() => useTaosAgentStore.getState().togglePanel(), []); // Listen for launchpad open event from context menu useEffect(() => { @@ -213,7 +218,7 @@ export function App() { if (mode === "desktop") { return ( - + {!launched && setLaunched(true)} />} {launched && !isFullscreen && ( @@ -227,13 +232,14 @@ export function App() { )}
- + setLaunchpadOpen(false)} onOpenApp={(wid) => setActiveWindowId(wid)} /> setSearchOpen(false)} onOpenApp={(wid) => setActiveWindowId(wid)} /> +
@@ -244,7 +250,7 @@ export function App() { // Mobile/Tablet layout — no login gate, no fullscreen button (PWA is already fullscreen) return ( - +
({ + TaosAssistantSettings: ({ open }: { open: boolean }) => + open ?
: null, +})); + +// Mock fetch so settings load doesn't throw in jsdom +const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ model: null }), +}); +vi.stubGlobal("fetch", mockFetch); + +function resetStore() { + useTaosAgentStore.setState({ + isOpen: true, + messages: [], + model: null, + streaming: false, + }); +} + +describe("TaosAssistantPanel", () => { + beforeEach(() => { + resetStore(); + mockFetch.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders nothing when closed", () => { + useTaosAgentStore.setState({ isOpen: false }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("shows title when open", () => { + render(); + expect(screen.getByRole("dialog", { name: /taOS Assistant/i })).toBeInTheDocument(); + expect(screen.getByText("taOS Assistant")).toBeInTheDocument(); + }); + + it("shows empty state with pick-model button when no model", () => { + render(); + expect(screen.getByText("Pick a model to get started")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /choose a model/i })).toBeInTheDocument(); + }); + + it("shows message input disabled when no model", () => { + render(); + const textarea = screen.getByRole("textbox", { name: /message taOS Assistant/i }); + expect(textarea).toBeDisabled(); + }); + + it("shows hint when model is set and no messages", () => { + useTaosAgentStore.setState({ model: "qwen3" }); + render(); + expect(screen.getByText(/ask me anything about taOS/i)).toBeInTheDocument(); + }); + + it("renders user and assistant messages", () => { + useTaosAgentStore.setState({ + model: "qwen3", + messages: [ + { role: "user", content: "Hello there", ts: 1 }, + { role: "assistant", content: "Hi! How can I help?", ts: 2 }, + ], + }); + render(); + expect(screen.getByText("Hello there")).toBeInTheDocument(); + expect(screen.getByText("Hi! How can I help?")).toBeInTheDocument(); + }); + + it("close button calls closePanel", () => { + render(); + const closeBtn = screen.getByRole("button", { name: /close taOS Assistant/i }); + fireEvent.click(closeBtn); + expect(useTaosAgentStore.getState().isOpen).toBe(false); + }); + + it("settings cog opens settings modal", () => { + render(); + const cogBtn = screen.getByRole("button", { name: /assistant settings/i }); + fireEvent.click(cogBtn); + expect(screen.getByTestId("settings-modal")).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/components/TaosAssistantPanel.tsx b/desktop/src/components/TaosAssistantPanel.tsx new file mode 100644 index 00000000..b3ba3e76 --- /dev/null +++ b/desktop/src/components/TaosAssistantPanel.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { X, Settings, Send, Sparkles } from "lucide-react"; +import { useTaosAgentStore } from "@/stores/taos-agent-store"; +import { TaosAssistantSettings } from "./TaosAssistantSettings"; + +export function TaosAssistantPanel() { + const { + isOpen, + closePanel, + messages, + appendMessage, + appendDelta, + model, + setModel, + streaming, + setStreaming, + } = useTaosAgentStore(); + + const [input, setInput] = useState(""); + const [settingsOpen, setSettingsOpen] = useState(false); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + // Sync model from backend on first open + useEffect(() => { + if (!isOpen) return; + fetch("/api/taos-agent/settings") + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (data?.model !== undefined) setModel(data.model); + }) + .catch(() => {}); + }, [isOpen, setModel]); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMessage = useCallback(async () => { + const content = input.trim(); + if (!content || streaming) return; + + setInput(""); + + appendMessage({ role: "user", content, ts: Date.now() }); + appendMessage({ role: "assistant", content: "", ts: Date.now() }); + setStreaming(true); + + // Build messages payload — only user/assistant (no system; backend injects system) + const history = useTaosAgentStore.getState().messages; + const payload = history + .filter((m) => m.role !== "system") + .slice(0, -1) // exclude the placeholder assistant we just added + .map((m) => ({ role: m.role, content: m.content })); + payload.push({ role: "user", content }); + + try { + const resp = await fetch("/api/taos-agent/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: payload }), + }); + + if (!resp.ok || !resp.body) { + const err = await resp.text().catch(() => "Unknown error"); + appendDelta(`\n\n_Error: ${err}_`); + setStreaming(false); + return; + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() ?? ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line) as { delta?: string; done?: boolean; error?: string }; + if (obj.error) { + appendDelta(`\n\n_Error: ${obj.error}_`); + } else if (obj.delta) { + appendDelta(obj.delta); + } + // obj.done == true means stream is complete — loop ends naturally + } catch { + // skip malformed lines + } + } + } + } catch (e) { + appendDelta(`\n\n_Network error: ${String(e)}_`); + } finally { + setStreaming(false); + } + }, [input, streaming, appendMessage, appendDelta, setStreaming]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + sendMessage(); + } + }, + [sendMessage], + ); + + const noModel = !model; + const showEmptyState = messages.length === 0; + + if (!isOpen) return null; + + return ( + <> + {/* Transparent backdrop — click to close */} +