diff --git a/.gitignore b/.gitignore index 778294a5d..840819983 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,22 @@ data/browser_cookie_key.hex # (a tracked copy dirties the tree and blocks the in-app git-pull update). desktop/tsconfig.tsbuildinfo +# never track installed deps (root-level node_modules can appear when a tool is +# run from the repo root; ignore it everywhere so it is never swept into a commit) +node_modules/ +desktop/node_modules + +# There is no root node project: the SPA lives in desktop/. A stray root +# package.json invites `npm install` at the repo root, which creates the +# root node_modules that lane worktrees then sweep into commits (see #1134). +# Lanes sometimes add these as a workaround for running vitest from root; the +# gate runs vitest from desktop/, so they are pure cruft. Ignore them, anchored +# to the repo root so desktop/package.json and desktop/vitest.config are kept. +/package.json +/package-lock.json +/vitest.config.ts +/vitest.config.js + # Local-only operational playbook (contains LAN IP + ops detail; agents read it from the working tree) docs/AGENT_HANDOFF.md docs/audit/ diff --git a/CLA.md b/CLA.md index f4e852488..f65f7f304 100644 --- a/CLA.md +++ b/CLA.md @@ -2,17 +2,17 @@ > Interim version, to be ratified by an IP lawyer. By contributing to taOS you agree to the terms below so the project can stay sustainable: source-available for everyone, with a separate commercial license for commercial use. -Thank you for contributing to **taOS** (the "Project"), maintained by **JAN LABS LTD** (the "Company"). +Thank you for contributing to **taOS** (the "Project"), maintained by **jaylfc** (the "Maintainer"). By submitting any contribution (code, documentation, configuration, or other material) to the Project, you agree to the following: -1. **Grant of rights.** You grant the Company a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license to use, reproduce, modify, prepare derivative works of, publicly display, sublicense, and distribute your contribution and such derivative works, **and to relicense your contribution under any license terms the Company chooses** — including source-available and commercial licenses. +1. **Grant of rights.** You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license to use, reproduce, modify, prepare derivative works of, publicly display, sublicense, and distribute your contribution and such derivative works, **and to relicense your contribution under any license terms the Maintainer chooses** — including source-available and commercial licenses. 2. **Original work / right to submit.** Each contribution is your own original creation, or you otherwise have the right to submit it, and submitting it does not violate any third party's rights or any agreement you are bound by. -3. **Retained ownership.** You retain all right, title, and interest in your contributions. This is a license to the Company, **not** an assignment of copyright. +3. **Retained ownership.** You retain all right, title, and interest in your contributions. This is a license to the Maintainer, **not** an assignment of copyright. -4. **No obligation.** The Company is under no obligation to use or include your contribution. +4. **No obligation.** The Maintainer is under no obligation to use or include your contribution. 5. **No warranty.** Your contribution is provided "as is", without warranties of any kind. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56d17387d..25186c1d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Welcome — and thanks for your interest in contributing. taOS is a self-hosted taOS is licensed under the **taOS Sustainable Use License** (source-available, not open source; see [`LICENSE`](LICENSE)) — free to use, modify, and self-host for personal use and for your own organisation's internal business purposes, with a separate commercial license required to sell it, host it as a paid service, or build it into a product you monetise. -To keep this sustainable, **all contributors must agree to the Contributor License Agreement ([`CLA.md`](CLA.md))** before their contributions are merged. The CLA grants JAN LABS LTD the right to include and **relicense** your contributions under the project's licenses; **you keep ownership of your work**. You sign once — on your first pull request, comment **"I have read the CLA Document and I hereby sign the CLA"** and the CLA check turns green; it then covers all your future contributions. +To keep this sustainable, **all contributors must agree to the Contributor License Agreement ([`CLA.md`](CLA.md))** before their contributions are merged. The CLA grants jaylfc the right to include and **relicense** your contributions under the project's licenses; **you keep ownership of your work**. You sign once — on your first pull request, comment **"I have read the CLA Document and I hereby sign the CLA"** and the CLA check turns green; it then covers all your future contributions. --- diff --git a/LICENSE b/LICENSE index 60338ad99..8998d0af2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ taOS Sustainable Use License, Version 0.1 -Copyright (c) 2026 JAN LABS LTD. All rights reserved. +Copyright (c) 2026 jaylfc. All rights reserved. This license governs use of the accompanying software, "taOS" (the "Software"). By using the Software you agree to these terms. The Software is source-available, @@ -8,7 +8,7 @@ not open source. 1. Grant -Subject to the conditions below, JAN LABS LTD grants you a non-exclusive, +Subject to the conditions below, jaylfc grants you a non-exclusive, worldwide, royalty-free license to use, copy, and modify the Software, and to distribute your copies and modifications, free of charge. @@ -27,7 +27,7 @@ this license with every copy you distribute. 3. Commercial use requires a separate license You may not, without a separate commercial license granted in writing by -JAN LABS LTD, use the Software (in whole or in part, modified or not) for a +jaylfc, use the Software (in whole or in part, modified or not) for a Commercial Purpose. A "Commercial Purpose" means using the Software in, or to provide, any product or @@ -49,17 +49,16 @@ free of charge and is not a Commercial Purpose, provided the Software (and anything built on it) is not offered, sold, hosted, or otherwise made available to any third party. -To obtain a commercial license, contact JAN LABS LTD at info@taos.my. +To obtain a commercial license, contact jaylfc at info@taos.my. 4. Trademarks -This license grants no rights to the "taOS" or "JAN LABS" names, logos, or -trademarks. +This license grants no rights to the "taOS" name, logo, or trademarks. 5. Contributions Unless agreed otherwise in writing, any contribution you submit for inclusion in -the Software is provided under this license, and you grant JAN LABS LTD a +the Software is provided under this license, and you grant jaylfc a perpetual, irrevocable, worldwide right to use, modify, sublicense, and relicense that contribution on any terms. @@ -71,7 +70,7 @@ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. 7. Limitation of liability -TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL JAN LABS LTD BE LIABLE +TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL jaylfc BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 1ffa41a01..bd1624014 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ # taOS +**Your AI, your hardware, your data.** Self-hosted AI where your agents' memory, conversations, and files live on machines you own, fully offline when you want, cloud only when you choose. + > **Beta (2026-06-02).** This is beta software meant for testers running it on their own hardware, so expect rough edges. The install script, backend, API, memory system (taOSmd), and multi-framework group chat all work; the desktop GUI is wired up for everyday use but a few flows (some agent management, worker connections, model routing) are still being smoothed out. Star or watch the repo to follow progress and catch the next release. > > **A heads-up on the catalogs:** with 100+ apps, 16 frameworks, and a large model catalog, plenty of install manifests have not been exercised on real hardware yet, so some apps, frameworks, and models will fail to install. If one does, [open an issue](https://github.com/jaylfc/tinyagentos/issues) with the name and the error you saw and I will fix the manifest as soon as I can. These reports are genuinely useful, most manifest fixes ship same-day. @@ -30,6 +32,17 @@ A full web desktop environment with 36 bundled apps, 108 catalog apps, 47 MCP pl **[taOSmd](https://github.com/jaylfc/taosmd) -- Framework-agnostic AI memory system.** 97.0% **end-to-end Judge accuracy** on [LongMemEval-S](https://github.com/xiaowu0162/LongMemEval) -- retrieve → generate → judge-with-LLM-grader, 500 questions across 50+ sessions each. For context, the most-cited open comparators -- MemPalace (96.6%) and agentmemory (95.2%) -- publish **Recall@5** retrieval scores on the same dataset, which measures only whether the correct session lands in the top-5 (no generation, no judge). The metrics aren't apples-to-apples until one of us re-runs end-to-end; ours is the stricter measurement. Per-category on our hybrid-plus-query-expansion config: knowledge-update 100%, multi-session 98.5%, single-session-user 97.1%, single-session-assistant 96.4%, temporal-reasoning 94.0%, single-session-preference 90.0%. Everything runs on a £170 Orange Pi 5 Plus with no cloud dependencies. The stack: temporal knowledge graph with validity windows + contradiction detection, hybrid semantic+keyword vector search with cross-encoder rerank and LLM-assisted query expansion (the "Librarian" layer), zero-loss append-only archive, automatic fact extraction, intent-aware retrieval routing, multi-layer context assembly. Any agent framework can read/write through the HTTP API. +### Your data stays yours + +Most AI assistants keep your memory, conversations, and files on their servers. taOS keeps them on yours. + +- **Offline memory.** Your AI's long-term memory is a self-hosted knowledge graph on your own box (a £170 Orange Pi is enough), not a row in someone else's cloud database. +- **Self-hosted agents and chat.** Your agents, their conversations, and your channels run on hardware you control. +- **Your compute, your models.** A local model catalog and cluster inference on your own devices; cloud models are opt-in, never required. +- **Auditable and exit-able.** Source-available, with a self-hostable binary mirror and an air-gapped install path. Nothing locks you in. + +Sovereignty by default, cloud by choice. Run taOS fully offline, or connect a cloud model or paid remote access (taOSgo) when you decide it is worth it. The default is that your data never leaves your hardware. + ---

@@ -744,4 +757,4 @@ taOS is better for the people testing it, filing issues, and sending fixes: taOS Sustainable Use License v0.1 -- source-available, not open source. See [LICENSE](LICENSE). -Free to use, modify, and self-host for personal use and for your own organisation's internal business purposes -- forever. A separate commercial license from JAN LABS LTD is required to sell taOS, host it as a paid service, or build it into a product or service you monetise (contact info@taos.my). Prior releases tagged under AGPL-3.0 remain available under AGPL-3.0. +Free to use, modify, and self-host for personal use and for your own organisation's internal business purposes -- forever. A separate commercial license from jaylfc is required to sell taOS, host it as a paid service, or build it into a product or service you monetise (contact info@taos.my). Prior releases tagged under AGPL-3.0 remain available under AGPL-3.0. diff --git a/desktop/app.html b/desktop/app.html new file mode 100644 index 000000000..ed085b01e --- /dev/null +++ b/desktop/app.html @@ -0,0 +1,52 @@ + + + + + + taOS + + + + + + + + + + + + + + + + + + + + + + + +

+ + + diff --git a/desktop/node_modules b/desktop/node_modules deleted file mode 120000 index f0c65aeaa..000000000 --- a/desktop/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Volumes/NVMe/Users/jay/Development/tinyagentos/desktop/node_modules \ No newline at end of file diff --git a/desktop/src/AppStandalone.test.tsx b/desktop/src/AppStandalone.test.tsx new file mode 100644 index 000000000..9fb932d13 --- /dev/null +++ b/desktop/src/AppStandalone.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ComponentType } from "react"; + +// The registry mock is defined per-test via vi.mock hoisting; we override +// getApp inside each test group instead. + +const FakeApp = ({ windowId }: { windowId: string }) => ( +
+ app content +
+); + +// Default registry mock: messages is pwa-enabled, unknown is not present. +vi.mock("./registry/app-registry", () => ({ + getApp: vi.fn((id: string) => { + if (id === "messages") { + return { + id: "messages", + name: "Messages", + pwa: true, + component: () => Promise.resolve({ default: FakeApp as ComponentType<{ windowId: string }> }), + }; + } + return undefined; + }), +})); + +// InstallPromptBanner has side-effects (window.matchMedia, beforeinstallprompt) +// that are not relevant to these tests; stub it out. +vi.mock("./shell/InstallPromptBanner", () => ({ + InstallPromptBanner: () => null, +})); + +import { AppStandalone } from "./AppStandalone"; + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("AppStandalone", () => { + it("renders the app component for a known pwa-enabled app", async () => { + render(); + await flush(); + expect(screen.getByTestId("fake-app")).toBeTruthy(); + expect(screen.getByTestId("fake-app").getAttribute("data-window")).toBe("standalone-messages"); + }); + + it("renders nothing when the app id is not in the registry", () => { + render(); + expect(screen.queryByTestId("fake-app")).toBeNull(); + }); +}); diff --git a/desktop/src/AppStandalone.tsx b/desktop/src/AppStandalone.tsx new file mode 100644 index 000000000..4d9f09523 --- /dev/null +++ b/desktop/src/AppStandalone.tsx @@ -0,0 +1,49 @@ +import { Suspense, lazy, useMemo } from "react"; +import { getApp } from "./registry/app-registry"; +import { InstallPromptBanner } from "./shell/InstallPromptBanner"; +import type { ComponentType } from "react"; + +interface Props { + appId: string; +} + +export function AppStandalone({ appId }: Props) { + const manifest = getApp(appId); + + // Create the lazy component ONCE per appId. Calling lazy() in the render body + // makes a new component type every render, which unmounts and remounts the + // app (losing its state) on any re-render. + const AppComponent = useMemo( + () => + manifest + ? lazy(() => manifest.component() as Promise<{ default: ComponentType<{ windowId: string }> }>) + : null, + [appId], + ); + + // Guard: caller should verify pwa:true before mounting this component. + if (!manifest || !AppComponent) return null; + + return ( +
+ + + Loading... +
+ }> + + + + ); +} diff --git a/desktop/src/ChatStandalone.tsx b/desktop/src/ChatStandalone.tsx index b4178a573..a1a659d4b 100644 --- a/desktop/src/ChatStandalone.tsx +++ b/desktop/src/ChatStandalone.tsx @@ -6,8 +6,15 @@ const MessagesApp = lazy(() => import("./apps/MessagesApp").then((m) => ({ defau export function ChatStandalone() { return (
+
+ This app is not available as a standalone PWA. +
+ , + ); +} else { + // Inject the dynamic manifest link so the browser picks up the correct + // name, icons, and start_url for this specific app. + const link = document.createElement("link"); + link.rel = "manifest"; + link.href = `/manifest?app=${encodeURIComponent(appId)}`; + document.head.appendChild(link); + + document.title = manifest.name; + + createRoot(document.getElementById("root")!).render( + + + + + , + ); +} diff --git a/desktop/src/apps/AppStudioApp.tsx b/desktop/src/apps/AppStudioApp.tsx index 1a22179ce..7e988b22b 100644 --- a/desktop/src/apps/AppStudioApp.tsx +++ b/desktop/src/apps/AppStudioApp.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { SHOW_BUILD_VIEW_EVENT } from "./appstudio/build-state"; import { Sparkles, LayoutGrid, Share2, CircleDot } from "lucide-react"; import { BuildView } from "./appstudio/BuildView"; import { TemplatesView } from "./appstudio/TemplatesView"; @@ -30,6 +31,12 @@ const RAIL_BOTTOM: { id: AppStudioView; label: string; icon: typeof Sparkles }[] export function AppStudioApp({ windowId: _windowId }: { windowId: string }) { const [view, setView] = useState("build"); + useEffect(() => { + const onShowBuild = () => setView("build"); + window.addEventListener(SHOW_BUILD_VIEW_EVENT, onShowBuild); + return () => window.removeEventListener(SHOW_BUILD_VIEW_EVENT, onShowBuild); + }, []); + function RailButton({ id, label, icon: Icon }: { id: AppStudioView; label: string; icon: typeof Sparkles }) { const on = view === id; return ( diff --git a/desktop/src/apps/CodingStudioApp.tsx b/desktop/src/apps/CodingStudioApp.tsx index 7e7a35c2a..6c649963d 100644 --- a/desktop/src/apps/CodingStudioApp.tsx +++ b/desktop/src/apps/CodingStudioApp.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Sparkles, Code2, Play, LayoutGrid, Settings2 } from "lucide-react"; import { BuildView } from "./codingstudio/BuildView"; +import { CodeView } from "./codingstudio/CodeView"; import { TemplatesView } from "./codingstudio/TemplatesView"; import { PreviewView } from "./codingstudio/PreviewView"; @@ -64,7 +65,7 @@ export function CodingStudioApp({ windowId: _windowId }: { windowId: string }) { {/* active surface */}
{view === "build" && } - {view === "code" && } + {view === "code" && } {view === "preview" && } {view === "templates" && }
diff --git a/desktop/src/apps/DesignStudioApp.test.tsx b/desktop/src/apps/DesignStudioApp.test.tsx index 9d450cba5..2744a6ea4 100644 --- a/desktop/src/apps/DesignStudioApp.test.tsx +++ b/desktop/src/apps/DesignStudioApp.test.tsx @@ -57,13 +57,13 @@ describe("DesignStudioApp", () => { } }); - it("switches to Magic view and shows prompt bar and result tiles", () => { + it("switches to Magic view and shows prompt bar and style chips", () => { renderApp(); fireEvent.click(screen.getByRole("button", { name: "Magic" })); expect(screen.getByRole("button", { name: "Magic" }).getAttribute("aria-current")).toBe("page"); expect(screen.getByText("Describe the design you need.")).toBeDefined(); - expect(screen.getByText("Bold, centered")).toBeDefined(); - expect(screen.getByText("Split layout")).toBeDefined(); + expect(screen.getByRole("button", { name: "Generate" })).toBeDefined(); + expect(screen.getByPlaceholderText(/launch poster/i)).toBeDefined(); expect(screen.getAllByText("Editorial").length).toBeGreaterThan(0); }); }); diff --git a/desktop/src/apps/DesignStudioApp.tsx b/desktop/src/apps/DesignStudioApp.tsx index bf569d754..9f47f5316 100644 --- a/desktop/src/apps/DesignStudioApp.tsx +++ b/desktop/src/apps/DesignStudioApp.tsx @@ -1,10 +1,15 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { PenLine, LayoutGrid, Plus, Sparkles, Circle } from "lucide-react"; +import { ModelBrowser } from "@/components/ModelBrowser"; import { DesignView } from "./designstudio/DesignView"; import { TemplatesView } from "./designstudio/TemplatesView"; import { MagicView } from "./designstudio/MagicView"; - -type DesignStudioView = "design" | "templates" | "elements" | "magic"; +import { + type CanvasElement, + type DesignStudioView, + type GeneratedImage, +} from "./designstudio/types"; +import type { ImageModel } from "./images/types"; const RAIL: { id: DesignStudioView; label: string; icon: typeof PenLine }[] = [ { id: "design", label: "Design", icon: PenLine }, @@ -13,18 +18,173 @@ const RAIL: { id: DesignStudioView; label: string; icon: typeof PenLine }[] = [ { id: "magic", label: "Magic", icon: Sparkles }, ]; +function randomSeed(): number { + return Math.floor(Math.random() * 1_000_000); +} + export function DesignStudioApp({ windowId: _windowId }: { windowId: string }) { const [view, setView] = useState("design"); + const [canvasElements, setCanvasElements] = useState([]); + + const [magicPrompt, setMagicPrompt] = useState(""); + const [magicStyle, setMagicStyle] = useState(null); + const [magicResults, setMagicResults] = useState([]); + const [generating, setGenerating] = useState(false); + const [error, setError] = useState(null); + const [errorNeedsModel, setErrorNeedsModel] = useState(false); + + const [models, setModels] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(""); + const [selectedVariantId, setSelectedVariantId] = useState(""); + const [browserOpen, setBrowserOpen] = useState(false); + + const refreshModels = useCallback(async () => { + try { + const res = await fetch("/api/models", { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return [] as ImageModel[]; + const data = await res.json(); + if (!data || !Array.isArray(data.models)) return [] as ImageModel[]; + const imageModels: ImageModel[] = data.models.filter( + (m: ImageModel) => + Array.isArray(m.capabilities) && + m.capabilities.includes("image-generation"), + ); + setModels(imageModels); + return imageModels; + } catch { + return [] as ImageModel[]; + } + }, []); + + useEffect(() => { + (async () => { + const imageModels = await refreshModels(); + for (const m of imageModels) { + const dl = m.variants?.find((v) => v.downloaded); + if (dl) { + setSelectedModelId(m.id); + setSelectedVariantId(dl.id); + return; + } + } + })(); + }, [refreshModels]); + + const selectedModel = useMemo( + () => models.find((m) => m.id === selectedModelId), + [models, selectedModelId], + ); + const selectedVariant = useMemo( + () => selectedModel?.variants.find((v) => v.id === selectedVariantId), + [selectedModel, selectedVariantId], + ); + + const needsModel = !selectedVariant?.downloaded; + const canGenerate = + !!magicPrompt.trim() && !generating && !!selectedVariant?.downloaded; + + const placeOnCanvas = useCallback((img: GeneratedImage) => { + setCanvasElements((prev) => { + const offset = prev.length * 12; + const element: CanvasElement = { + id: `${img.id}-${prev.length}-${Date.now()}`, + type: "image", + url: img.url, + prompt: img.prompt, + x: 24 + offset, + y: 280 + offset, + width: 312, + height: 140, + }; + return [...prev, element]; + }); + setView("design"); + }, []); + + const runGenerate = useCallback(async () => { + const usePrompt = magicPrompt.trim(); + if (!usePrompt) return; + if (!selectedVariant?.downloaded) { + setError("Install an image generation model first."); + return; + } + + setGenerating(true); + setError(null); + setErrorNeedsModel(false); + + const styledPrompt = magicStyle + ? `${usePrompt}, ${magicStyle.toLowerCase()} style` + : usePrompt; + + try { + const res = await fetch("/api/images/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: styledPrompt, + model: selectedModelId, + variant: selectedVariantId, + size: "512x512", + steps: 4, + seed: randomSeed(), + guidance_scale: 7.5, + }), + }); + + if (res.ok) { + const ct = res.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) { + setError("Generation returned an unexpected response format."); + } else { + try { + const data = await res.json(); + if (data.filename || data.id) { + const id = (data.filename as string) ?? (data.id as string); + const url = (data.path as string) ?? `/data/workspace/images/generated/${id}`; + const img: GeneratedImage = { id, url, prompt: styledPrompt }; + setMagicResults((prev) => [img, ...prev]); + placeOnCanvas(img); + } else if (data.error) { + setError(String(data.error)); + } else { + setError("Generation succeeded but returned no image data."); + } + } catch { + setError("Generation returned invalid JSON."); + } + } + } else { + const data = await res.json().catch(() => ({})); + setErrorNeedsModel(res.status === 502 || res.status === 503); + setError( + (data as { error?: string }).error ?? + `Generation failed (${res.status})`, + ); + } + } catch (e) { + setError(`Generation error: ${(e as Error).message}`); + } + + setGenerating(false); + }, [ + magicPrompt, + magicStyle, + selectedVariant, + selectedModelId, + selectedVariantId, + placeOnCanvas, + ]); return (
- {/* title strip */}
Design Studio
- {/* left rail */} - {/* active surface */}
- {view === "design" && } + {view === "design" && } {view === "templates" && } - {view === "elements" && } - {view === "magic" && } + {view === "elements" && } + {view === "magic" && ( + void runGenerate()} + onPickModel={() => setBrowserOpen(true)} + onUseResult={placeOnCanvas} + /> + )}
+ + setBrowserOpen(false)} + capability="image-generation" + onModelDownloaded={async (modelId, variantId) => { + await refreshModels(); + setSelectedModelId(modelId); + setSelectedVariantId(variantId); + setError(null); + setErrorNeedsModel(false); + }} + />
); -} +} \ No newline at end of file diff --git a/desktop/src/apps/FeedbackApp.test.tsx b/desktop/src/apps/FeedbackApp.test.tsx new file mode 100644 index 000000000..a39a7347a --- /dev/null +++ b/desktop/src/apps/FeedbackApp.test.tsx @@ -0,0 +1,189 @@ +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FeedbackApp } from "./FeedbackApp"; + +/* ------------------------------------------------------------------ */ +/* Fetch mock helpers */ +/* ------------------------------------------------------------------ */ + +function mockFetch(responses: Record) { + return vi.fn().mockImplementation((input: string, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const key = `${method} ${input}`; + const hit = responses[key] ?? responses[input] ?? responses["*"]; + if (!hit) throw new Error(`Unmocked fetch: ${key}`); + return Promise.resolve({ + ok: hit.ok, + status: hit.status ?? (hit.ok ? 200 : 422), + json: () => Promise.resolve(hit.body), + }); + }); +} + +async function flush() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe("FeedbackApp", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders the form with Bug Report selected by default", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ "GET /api/feedback": { ok: true, body: [] } }), + ); + render(); + await flush(); + + expect(screen.getByRole("group", { name: /feedback type/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /bug report/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /feature request/i })).toBeTruthy(); + expect(screen.getByLabelText(/title/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /submit/i })).toBeTruthy(); + }); + + it("toggles between bug and feature types", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ "GET /api/feedback": { ok: true, body: [] } }), + ); + render(); + await flush(); + + const bugBtn = screen.getByRole("button", { name: /bug report/i }); + const featureBtn = screen.getByRole("button", { name: /feature request/i }); + + // Bug is default + expect(bugBtn.getAttribute("aria-pressed")).toBe("true"); + expect(featureBtn.getAttribute("aria-pressed")).toBe("false"); + + // Click feature + fireEvent.click(featureBtn); + expect(bugBtn.getAttribute("aria-pressed")).toBe("false"); + expect(featureBtn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("shows validation error when title is empty and submit is clicked", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ "GET /api/feedback": { ok: true, body: [] } }), + ); + render(); + await flush(); + + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + await flush(); + + expect(screen.getByRole("alert").textContent).toMatch(/title is required/i); + }); + + it("posts to /api/feedback on valid submit and shows success", async () => { + const fetchMock = mockFetch({ + "GET /api/feedback": { ok: true, body: [] }, + "POST /api/feedback": { + ok: true, + status: 201, + body: { + id: "abc", + type: "bug", + title: "Login broken", + body: "", + app: "", + created_at: new Date().toISOString(), + has_screenshot: false, + }, + }, + }); + vi.stubGlobal("fetch", fetchMock); + render(); + await flush(); + + fireEvent.change(screen.getByLabelText(/title/i), { + target: { value: "Login broken" }, + }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + await flush(); + + // Should show success message + await waitFor(() => + expect(screen.getByRole("status").textContent).toMatch(/thanks for the feedback/i), + ); + + // POST should have been called with the right body + const postCall = fetchMock.mock.calls.find( + (c) => (c[1] as RequestInit)?.method === "POST", + ); + expect(postCall).toBeTruthy(); + const sentBody = JSON.parse((postCall![1] as RequestInit).body as string); + expect(sentBody.type).toBe("bug"); + expect(sentBody.title).toBe("Login broken"); + }); + + it("shows error message when the server returns a failure", async () => { + const fetchMock = mockFetch({ + "GET /api/feedback": { ok: true, body: [] }, + "POST /api/feedback": { + ok: false, + status: 422, + body: { detail: "title must not be empty" }, + }, + }); + vi.stubGlobal("fetch", fetchMock); + render(); + await flush(); + + fireEvent.change(screen.getByLabelText(/title/i), { + target: { value: " x " }, + }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + await flush(); + + await waitFor(() => + expect(screen.getByRole("alert").textContent).toMatch(/title must not be empty/i), + ); + }); + + it("renders past submissions returned by GET /api/feedback", async () => { + const items = [ + { + id: "1", + type: "bug", + title: "Dark mode flicker", + body: "", + app: "", + created_at: new Date(Date.now() - 3600_000).toISOString(), + has_screenshot: true, + }, + { + id: "2", + type: "feature", + title: "Export to PDF", + body: "", + app: "", + created_at: new Date(Date.now() - 86400_000 * 2).toISOString(), + has_screenshot: false, + }, + ]; + vi.stubGlobal( + "fetch", + mockFetch({ "GET /api/feedback": { ok: true, body: items } }), + ); + render(); + await flush(); + + await waitFor(() => { + expect(screen.getByText("Dark mode flicker")).toBeTruthy(); + expect(screen.getByText("Export to PDF")).toBeTruthy(); + }); + // Screenshot indicator for item 1 + expect(screen.getByText(/has screenshot/i)).toBeTruthy(); + }); +}); diff --git a/desktop/src/apps/FeedbackApp.tsx b/desktop/src/apps/FeedbackApp.tsx new file mode 100644 index 000000000..8f097e7c8 --- /dev/null +++ b/desktop/src/apps/FeedbackApp.tsx @@ -0,0 +1,373 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Flag, ImagePlus, X, CheckCircle2, AlertCircle, Clock } from "lucide-react"; +import { Button, Input, Label, Textarea } from "@/components/ui"; + +type FeedbackType = "bug" | "feature"; + +interface FeedbackItem { + id: string; + type: FeedbackType; + title: string; + body: string; + app: string; + created_at: string; + has_screenshot: boolean; +} + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function TypeToggle({ + value, + onChange, +}: { + value: FeedbackType; + onChange: (v: FeedbackType) => void; +}) { + return ( +
+ {(["bug", "feature"] as const).map((t) => ( + + ))} +
+ ); +} + +function ScreenshotPicker({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const fileRef = useRef(null); + + function readFile(file: File) { + const reader = new FileReader(); + reader.onload = (e) => { + const result = e.target?.result; + if (typeof result === "string") onChange(result); + }; + reader.readAsDataURL(file); + } + + function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) readFile(file); + e.target.value = ""; + } + + function handlePaste(e: React.ClipboardEvent) { + for (const item of Array.from(e.clipboardData.items)) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile(); + if (file) { + readFile(file); + e.preventDefault(); + return; + } + } + } + } + + if (value) { + return ( +
+ Screenshot preview + +
+ ); + } + + return ( +
+ + + or paste from clipboard +
+ ); +} + +function TypeBadge({ type }: { type: FeedbackType }) { + return ( + + {type === "bug" ? "Bug" : "Feature"} + + ); +} + +export function FeedbackApp({ windowId: _windowId }: { windowId: string }) { + const [fbType, setFbType] = useState("bug"); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [screenshot, setScreenshot] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(true); + + const loadHistory = useCallback(async () => { + try { + const res = await fetch("/api/feedback"); + if (res.ok) { + setHistory(await res.json()); + } + } catch { + // History is non-critical; silently skip on network error. + } finally { + setLoadingHistory(false); + } + }, []); + + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + setError("Title is required."); + return; + } + + setSubmitting(true); + try { + const res = await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: fbType, + title: trimmedTitle, + body, + screenshot, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const detail = data?.detail; + const msg = + typeof detail === "string" + ? detail + : Array.isArray(detail) + ? detail.map((d: { msg?: string }) => d.msg).join(", ") + : "Submission failed. Please try again."; + setError(msg); + return; + } + + setSuccess(true); + setTitle(""); + setBody(""); + setScreenshot(""); + await loadHistory(); + } catch { + setError("Could not reach the server."); + } finally { + setSubmitting(false); + } + } + + return ( +
+ {/* Header */} +
+ +

Feedback

+
+ +
+ {/* Form section */} +
+ {/* Type toggle */} +
+ + +
+ + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder={ + fbType === "bug" + ? "Short description of the issue" + : "What feature would you like?" + } + maxLength={300} + className="bg-shell-surface border-shell-border text-shell-text placeholder:text-shell-text-tertiary" + aria-required="true" + /> +
+ + {/* Description */} +
+ +