diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ad041734c..29855908e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ version: 2 updates: - # Python (controller + worker) - - package-ecosystem: "pip" + # Python (controller + worker) -- managed by uv (pyproject.toml + uv.lock) + - package-ecosystem: "uv" directory: "/" target-branch: "dev" schedule: diff --git a/README.md b/README.md index d9f0e2832..1ffa41a01 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@

+

+ Join the taOS Discord +

+ Self-hosted AI agent platform that runs on whatever hardware you have. An old laptop, a Raspberry Pi, a gaming PC, an SBC gathering dust, or all of them at once. taOS turns your spare hardware into a distributed AI compute cluster. A full web desktop environment with 36 bundled apps, 108 catalog apps, 47 MCP plugins, 16 agent frameworks, a curated local model catalog of 112 manifests covering LLMs, vision, embeddings, audio, and image generation (including RK3588 NPU variants via c01zaut/happyme531), plus 167k+ searchable models from HuggingFace, agent deployment, training, image/video/audio generation, and full system monitoring, all from a single web dashboard. Supports Apple Silicon (MLX), NVIDIA, AMD, Rockchip NPU, Raspberry Pi, Android phones, and more. diff --git a/desktop/node_modules b/desktop/node_modules new file mode 120000 index 000000000..f0c65aeaa --- /dev/null +++ b/desktop/node_modules @@ -0,0 +1 @@ +/Volumes/NVMe/Users/jay/Development/tinyagentos/desktop/node_modules \ No newline at end of file diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8d35fc2a6..26f6a018c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinyagentos-desktop", - "version": "1.0.0-beta", + "version": "1.0.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyagentos-desktop", - "version": "1.0.0-beta", + "version": "1.0.0-beta.3", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2", diff --git a/desktop/src/apps/AppStudioApp.test.tsx b/desktop/src/apps/AppStudioApp.test.tsx new file mode 100644 index 000000000..9947b73eb --- /dev/null +++ b/desktop/src/apps/AppStudioApp.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import { AppStudioApp } from "./AppStudioApp"; + +describe("AppStudioApp", () => { + it("renders the titlebar with App Studio", () => { + render(); + expect(screen.getByText("App Studio")).toBeInTheDocument(); + }); + + it("renders all rail items", () => { + const { container } = render(); + const nav = container.querySelector("nav[aria-label='App Studio views']"); + expect(nav).toBeInTheDocument(); + // Rail buttons are inside the nav + const railBtns = nav!.querySelectorAll("button"); + const labels = Array.from(railBtns).map((b) => b.getAttribute("aria-label")); + expect(labels).toContain("Build"); + expect(labels).toContain("Templates"); + expect(labels).toContain("Publish"); + expect(labels).toContain("SDK"); + }); + + it("shows Build view by default", () => { + render(); + expect(screen.getByRole("heading", { name: /^build$/i })).toBeInTheDocument(); + // checkerboard sandbox area has the live preview header text + expect(screen.getByText("Build log")).toBeInTheDocument(); + }); + + it("switches to Templates view and shows template cards", () => { + const { container } = render(); + const nav = container.querySelector("nav[aria-label='App Studio views']")!; + const templatesBtn = Array.from(nav.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Templates" + )!; + fireEvent.click(templatesBtn); + // hero heading + expect(screen.getByText(/build a taOS app in plain words/i)).toBeInTheDocument(); + // template card labels + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Tracker")).toBeInTheDocument(); + expect(screen.getByText("Kanban")).toBeInTheDocument(); + expect(screen.getByText("Blank")).toBeInTheDocument(); + }); + + it("switches to Publish view and shows capability rows", () => { + const { container } = render(); + const nav = container.querySelector("nav[aria-label='App Studio views']")!; + const publishBtn = Array.from(nav.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Publish" + )!; + fireEvent.click(publishBtn); + // app identity + expect(screen.getAllByText("Chore Quest").length).toBeGreaterThan(0); + // capability row labels + expect(screen.getByTestId("perm-row-workspace")).toBeInTheDocument(); + expect(screen.getByTestId("perm-row-notifications")).toBeInTheDocument(); + expect(screen.getByTestId("perm-row-household")).toBeInTheDocument(); + // publish button + expect(screen.getByRole("button", { name: /publish to my store/i })).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/apps/AppStudioApp.tsx b/desktop/src/apps/AppStudioApp.tsx new file mode 100644 index 000000000..1a22179ce --- /dev/null +++ b/desktop/src/apps/AppStudioApp.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Sparkles, LayoutGrid, Share2, CircleDot } from "lucide-react"; +import { BuildView } from "./appstudio/BuildView"; +import { TemplatesView } from "./appstudio/TemplatesView"; +import { PublishView } from "./appstudio/PublishView"; + +/* ------------------------------------------------------------------ */ +/* App Studio -- shell */ +/* */ +/* Build taOS apps from plain words. An agent generates them against */ +/* the taOS SDK, sandboxed and safe. Publish to your Store or share */ +/* with family. */ +/* */ +/* Shell follows the canonical studio pattern from GameStudioApp: */ +/* 46px centered titlebar, 68px icon rail, per-view subfolder. */ +/* ------------------------------------------------------------------ */ + +type AppStudioView = "build" | "templates" | "publish" | "sdk"; + +const RAIL: { id: AppStudioView; label: string; icon: typeof Sparkles }[] = [ + { id: "build", label: "Build", icon: Sparkles }, + { id: "templates", label: "Templates", icon: LayoutGrid }, + { id: "publish", label: "Publish", icon: Share2 }, +]; + +const RAIL_BOTTOM: { id: AppStudioView; label: string; icon: typeof Sparkles }[] = [ + { id: "sdk", label: "SDK", icon: CircleDot }, +]; + +export function AppStudioApp({ windowId: _windowId }: { windowId: string }) { + const [view, setView] = useState("build"); + + function RailButton({ id, label, icon: Icon }: { id: AppStudioView; label: string; icon: typeof Sparkles }) { + const on = view === id; + return ( + + ); + } + + return ( +
+ {/* titlebar */} +
+ App Studio +
+ +
+ {/* left rail */} + + + {/* active surface */} +
+ {view === "build" && } + {view === "templates" && } + {view === "publish" && } + {view === "sdk" && ( +
+ SDK docs coming soon +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/apps/CodingStudioApp.test.tsx b/desktop/src/apps/CodingStudioApp.test.tsx new file mode 100644 index 000000000..d899bced5 --- /dev/null +++ b/desktop/src/apps/CodingStudioApp.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { CodingStudioApp } from "./CodingStudioApp"; + +function renderApp() { + return render(); +} + +describe("CodingStudioApp", () => { + it("renders the app titlebar", () => { + renderApp(); + expect(screen.getByText("Coding Studio")).toBeDefined(); + }); + + it("renders all rail items", () => { + renderApp(); + // Rail buttons use aria-label for exact matching via the nav element + const nav = screen.getByRole("navigation", { name: "Coding Studio views" }); + expect(nav).toBeDefined(); + expect(screen.getByRole("button", { name: "Code" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Preview" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Templates" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Models" })).toBeDefined(); + }); + + it("shows Build view by default with Build rail item active", () => { + renderApp(); + // The rail Build button (inside nav) should be aria-current="page" + const nav = screen.getByRole("navigation", { name: "Coding Studio views" }); + const railBuildBtn = nav.querySelector('[aria-label="Build"]') as HTMLElement; + expect(railBuildBtn).toBeTruthy(); + expect(railBuildBtn.getAttribute("aria-current")).toBe("page"); + }); + + it("switches to Templates view on rail click", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Templates" })); + expect(screen.getByRole("button", { name: "Templates" }).getAttribute("aria-current")).toBe( + "page", + ); + expect(screen.getByText("Describe what you want to build.")).toBeDefined(); + }); + + it("Templates view shows all 8 template cards", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Templates" })); + const expectedNames = [ + "Web App", + "REST API", + "CLI Tool", + "Discord Bot", + "Static Site", + "Data Pipeline", + "Python Script", + "Browser Extension", + ]; + for (const name of expectedNames) { + expect(screen.getByText(name)).toBeDefined(); + } + }); + + it("switches to Preview view on rail click and shows preview header", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Preview" })); + expect(screen.getByRole("button", { name: "Preview" }).getAttribute("aria-current")).toBe( + "page", + ); + // Preview header h2 + expect(screen.getByRole("heading", { name: "Preview" })).toBeDefined(); + }); +}); diff --git a/desktop/src/apps/CodingStudioApp.tsx b/desktop/src/apps/CodingStudioApp.tsx new file mode 100644 index 000000000..7e7a35c2a --- /dev/null +++ b/desktop/src/apps/CodingStudioApp.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { Sparkles, Code2, Play, LayoutGrid, Settings2 } from "lucide-react"; +import { BuildView } from "./codingstudio/BuildView"; +import { TemplatesView } from "./codingstudio/TemplatesView"; +import { PreviewView } from "./codingstudio/PreviewView"; + +type CodingView = "build" | "code" | "preview" | "templates"; + +const RAIL: { id: CodingView; label: string; icon: typeof Sparkles }[] = [ + { id: "build", label: "Build", icon: Sparkles }, + { id: "code", label: "Code", icon: Code2 }, + { id: "preview", label: "Preview", icon: Play }, + { id: "templates", label: "Templates", icon: LayoutGrid }, +]; + +export function CodingStudioApp({ windowId: _windowId }: { windowId: string }) { + const [view, setView] = useState("build"); + + return ( +
+ {/* title strip */} +
+ Coding Studio +
+ +
+ {/* left rail */} + + + {/* active surface */} +
+ {view === "build" && } + {view === "code" && } + {view === "preview" && } + {view === "templates" && } +
+
+
+ ); +} diff --git a/desktop/src/apps/DesignStudioApp.test.tsx b/desktop/src/apps/DesignStudioApp.test.tsx new file mode 100644 index 000000000..9d450cba5 --- /dev/null +++ b/desktop/src/apps/DesignStudioApp.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { DesignStudioApp } from "./DesignStudioApp"; + +function renderApp() { + return render(); +} + +describe("DesignStudioApp", () => { + it("renders the app titlebar", () => { + renderApp(); + expect(screen.getByText("Design Studio")).toBeDefined(); + }); + + it("renders all rail items", () => { + renderApp(); + const nav = screen.getByRole("navigation", { name: "Design Studio views" }); + expect(nav).toBeDefined(); + expect(screen.getByRole("button", { name: "Design" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Templates" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Elements" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Magic" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Brand" })).toBeDefined(); + }); + + it("shows Design view by default with Design rail item active", () => { + renderApp(); + const nav = screen.getByRole("navigation", { name: "Design Studio views" }); + const designBtn = nav.querySelector('[aria-label="Design"]') as HTMLElement; + expect(designBtn).toBeTruthy(); + expect(designBtn.getAttribute("aria-current")).toBe("page"); + }); + + it("default Design view renders the canvas artboard", () => { + renderApp(); + expect(screen.getByText("Untitled poster")).toBeDefined(); + }); + + it("switches to Templates view and shows template cards", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Templates" })); + expect(screen.getByRole("button", { name: "Templates" }).getAttribute("aria-current")).toBe( + "page", + ); + const expectedCards = [ + "Instagram Post", + "Story", + "Poster", + "Presentation", + "Logo", + "Flyer", + "Banner", + "Business Card", + ]; + for (const name of expectedCards) { + expect(screen.getByText(name)).toBeDefined(); + } + }); + + it("switches to Magic view and shows prompt bar and result tiles", () => { + 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.getAllByText("Editorial").length).toBeGreaterThan(0); + }); +}); diff --git a/desktop/src/apps/DesignStudioApp.tsx b/desktop/src/apps/DesignStudioApp.tsx new file mode 100644 index 000000000..bf569d754 --- /dev/null +++ b/desktop/src/apps/DesignStudioApp.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { PenLine, LayoutGrid, Plus, Sparkles, Circle } from "lucide-react"; +import { DesignView } from "./designstudio/DesignView"; +import { TemplatesView } from "./designstudio/TemplatesView"; +import { MagicView } from "./designstudio/MagicView"; + +type DesignStudioView = "design" | "templates" | "elements" | "magic"; + +const RAIL: { id: DesignStudioView; label: string; icon: typeof PenLine }[] = [ + { id: "design", label: "Design", icon: PenLine }, + { id: "templates", label: "Templates", icon: LayoutGrid }, + { id: "elements", label: "Elements", icon: Plus }, + { id: "magic", label: "Magic", icon: Sparkles }, +]; + +export function DesignStudioApp({ windowId: _windowId }: { windowId: string }) { + const [view, setView] = useState("design"); + + return ( +
+ {/* title strip */} +
+ Design Studio +
+ +
+ {/* left rail */} + + + {/* active surface */} +
+ {view === "design" && } + {view === "templates" && } + {view === "elements" && } + {view === "magic" && } +
+
+
+ ); +} diff --git a/desktop/src/apps/MessagesApp.tsx b/desktop/src/apps/MessagesApp.tsx index 8926a6fbe..0145c9144 100644 --- a/desktop/src/apps/MessagesApp.tsx +++ b/desktop/src/apps/MessagesApp.tsx @@ -1262,7 +1262,7 @@ export function MessagesApp({ }); if (res.ok) { const ch = await res.json(); - setChannels((prev) => [...prev, ch]); + await fetchChannels(); setSelectedChannel(ch.id); setShowCreate(false); setNewChannel({ name: "", type: "topic", description: "" }); @@ -1399,11 +1399,15 @@ export function MessagesApp({ }; /* ---- group channels by type ---- */ - const isRoot = (c: Channel) => !c.project_id; + // Standalone Messages: root channels (no project_id) go in the DM/Topics/Groups + // sections; project channels nest under Projects. Project-scoped Messages shows + // only that project's channels in the type sections (Projects nest is hidden). + const inSidebarSection = (c: Channel) => + scope?.projectId ? c.project_id === scope.projectId : !c.project_id; const grouped = { - dm: channels.filter((c) => c.type === "dm" && isRoot(c)), - topic: channels.filter((c) => c.type === "topic" && isRoot(c)), - group: channels.filter((c) => c.type === "group" && isRoot(c)), + dm: channels.filter((c) => c.type === "dm" && inSidebarSection(c)), + topic: channels.filter((c) => c.type === "topic" && inSidebarSection(c)), + group: channels.filter((c) => c.type === "group" && inSidebarSection(c)), }; const allChannels = [...channels, ...archivedChannels]; @@ -2658,7 +2662,7 @@ export function MessagesApp({ /* ---------------------------------------------------------------- */ return ( -
+
{/* Toolbar — hidden on mobile when a channel is selected */} {showToolbar && (
@@ -2754,7 +2758,7 @@ export function MessagesApp({ id: currentChannel.id, name: currentChannel.name, type: currentChannel.type, - topic: currentChannel.topic ?? "", + topic: currentChannel.topic ?? currentChannel.description ?? "", members: currentChannel.members ?? [], settings: currentChannel.settings ?? {}, }} diff --git a/desktop/src/apps/MusicStudioApp.test.tsx b/desktop/src/apps/MusicStudioApp.test.tsx new file mode 100644 index 000000000..157102963 --- /dev/null +++ b/desktop/src/apps/MusicStudioApp.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { MusicStudioApp } from "./MusicStudioApp"; + +function renderApp() { + return render(); +} + +describe("MusicStudioApp", () => { + it("renders the app titlebar with name", () => { + renderApp(); + expect(screen.getByText("Music Studio")).toBeDefined(); + }); + + it("renders all rail items", () => { + renderApp(); + const nav = screen.getByRole("navigation", { name: "Music Studio views" }); + expect(nav).toBeDefined(); + expect(screen.getByRole("button", { name: "Studio" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Compose" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Sounds" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Mixer" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Export" })).toBeDefined(); + }); + + it("shows Studio view by default with Studio rail item active", () => { + renderApp(); + const nav = screen.getByRole("navigation", { name: "Music Studio views" }); + const studioBtn = nav.querySelector('[aria-label="Studio"]') as HTMLElement; + expect(studioBtn).toBeTruthy(); + expect(studioBtn.getAttribute("aria-current")).toBe("page"); + }); + + it("Studio view shows transport controls", () => { + renderApp(); + expect(screen.getByRole("button", { name: "Stop" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Play" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Record" })).toBeDefined(); + }); + + it("Studio view shows a track in the track list", () => { + renderApp(); + expect(screen.getAllByText("Drums").length).toBeGreaterThan(0); + }); + + it("switches to Compose view on rail click", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Compose" })); + const nav = screen.getByRole("navigation", { name: "Music Studio views" }); + const composeBtn = nav.querySelector('[aria-label="Compose"]') as HTMLElement; + expect(composeBtn.getAttribute("aria-current")).toBe("page"); + expect(screen.getByRole("heading", { name: "Compose" })).toBeDefined(); + }); + + it("Compose view shows Generate button and style chips", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Compose" })); + expect(screen.getByRole("button", { name: "Generate" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Lo-fi" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Cinematic" })).toBeDefined(); + }); + + it("switches to Sounds view on rail click", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Sounds" })); + const nav = screen.getByRole("navigation", { name: "Music Studio views" }); + const soundsBtn = nav.querySelector('[aria-label="Sounds"]') as HTMLElement; + expect(soundsBtn.getAttribute("aria-current")).toBe("page"); + expect(screen.getByRole("heading", { name: "Sounds" })).toBeDefined(); + }); + + it("Sounds view shows filter pills and instrument cards", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Sounds" })); + expect(screen.getByRole("button", { name: "All" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Drums" })).toBeDefined(); + expect(screen.getByText("Boom Bap Kit")).toBeDefined(); + expect(screen.getByText("Rhodes Mk I")).toBeDefined(); + }); +}); diff --git a/desktop/src/apps/MusicStudioApp.tsx b/desktop/src/apps/MusicStudioApp.tsx new file mode 100644 index 000000000..6bf007184 --- /dev/null +++ b/desktop/src/apps/MusicStudioApp.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { LayoutList, Sparkles, Music2, LayoutGrid, Download } from "lucide-react"; +import { StudioView } from "./musicstudio/StudioView"; +import { ComposeView } from "./musicstudio/ComposeView"; +import { SoundsView } from "./musicstudio/SoundsView"; + +type MusicView = "studio" | "compose" | "sounds" | "mixer" | "export"; + +const RAIL_MAIN: { id: MusicView; label: string; icon: typeof LayoutList }[] = [ + { id: "studio", label: "Studio", icon: LayoutList }, + { id: "compose", label: "Compose", icon: Sparkles }, + { id: "sounds", label: "Sounds", icon: Music2 }, + { id: "mixer", label: "Mixer", icon: LayoutGrid }, +]; + +export function MusicStudioApp({ windowId: _windowId }: { windowId: string }) { + const [view, setView] = useState("studio"); + + return ( +
+ {/* title strip */} +
+ Music Studio +
+ +
+ {/* left rail */} + + + {/* active surface */} +
+ {view === "studio" && } + {view === "compose" && } + {view === "sounds" && } + {view === "mixer" && ( +
+ Mixer coming soon +
+ )} + {view === "export" && ( +
+ Export coming soon +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/apps/OfficeSuiteApp.test.tsx b/desktop/src/apps/OfficeSuiteApp.test.tsx new file mode 100644 index 000000000..6f7916074 --- /dev/null +++ b/desktop/src/apps/OfficeSuiteApp.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { OfficeSuiteApp } from "./OfficeSuiteApp"; + +function renderApp() { + return render(); +} + +describe("OfficeSuiteApp", () => { + it("renders the app titlebar", () => { + renderApp(); + expect(screen.getByText("Office Suite")).toBeDefined(); + }); + + it("renders all rail items", () => { + renderApp(); + const nav = screen.getByRole("navigation", { name: "Office Suite views" }); + expect(nav).toBeDefined(); + // query within nav to avoid ambiguity with toolbar buttons + expect(nav.querySelector('[aria-label="Write"]')).toBeTruthy(); + expect(nav.querySelector('[aria-label="Calc"]')).toBeTruthy(); + expect(nav.querySelector('[aria-label="Slides"]')).toBeTruthy(); + expect(nav.querySelector('[aria-label="Data"]')).toBeTruthy(); + expect(nav.querySelector('[aria-label="Assist"]')).toBeTruthy(); + }); + + it("shows Write view by default with Write rail item active", () => { + renderApp(); + const nav = screen.getByRole("navigation", { name: "Office Suite views" }); + const writeBtn = nav.querySelector('[aria-label="Write"]') as HTMLElement; + expect(writeBtn).toBeTruthy(); + expect(writeBtn.getAttribute("aria-current")).toBe("page"); + // Write view content + expect(screen.getByText("taOS Studios launch note")).toBeDefined(); + }); + + it("switches to Calc view and shows spreadsheet grid with Total row", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Calc" })); + expect(screen.getByRole("button", { name: "Calc" }).getAttribute("aria-current")).toBe("page"); + // spreadsheet table + expect(screen.getByRole("table", { name: "Spreadsheet" })).toBeDefined(); + // total row value + expect(screen.getByTestId("total-revenue")).toBeDefined(); + expect(screen.getByTestId("total-revenue").textContent).toBe("28,900"); + // Total label + expect(screen.getByText("Total")).toBeDefined(); + }); + + it("switches to Slides view and shows slide thumbnails", () => { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Slides" })); + expect(screen.getByRole("button", { name: "Slides" }).getAttribute("aria-current")).toBe( + "page", + ); + // thumbnail rail + expect(screen.getByRole("complementary", { name: "Slide thumbnails" })).toBeDefined(); + // all 5 slide thumbnails + expect(screen.getByLabelText("Slide 1: Build it your way")).toBeDefined(); + expect(screen.getByLabelText("Slide 2: Ready today")).toBeDefined(); + expect(screen.getByLabelText("Slide 3: On the way")).toBeDefined(); + expect(screen.getByLabelText("Slide 4: Your hardware")).toBeDefined(); + expect(screen.getByLabelText("Slide 5: Get started")).toBeDefined(); + // slide content + expect(screen.getByText("Build it your way.")).toBeDefined(); + }); +}); diff --git a/desktop/src/apps/OfficeSuiteApp.tsx b/desktop/src/apps/OfficeSuiteApp.tsx new file mode 100644 index 000000000..89b245f02 --- /dev/null +++ b/desktop/src/apps/OfficeSuiteApp.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { Sparkles, Type, Grid, Monitor, Database } from "lucide-react"; +import { WriteView } from "./officesuite/WriteView"; +import { CalcView } from "./officesuite/CalcView"; +import { SlidesView } from "./officesuite/SlidesView"; + +type OfficeView = "write" | "calc" | "slides" | "data"; + +const RAIL: { id: OfficeView; label: string; icon: typeof Sparkles }[] = [ + { id: "write", label: "Write", icon: Type }, + { id: "calc", label: "Calc", icon: Grid }, + { id: "slides", label: "Slides", icon: Monitor }, + { id: "data", label: "Data", icon: Database }, +]; + +export function OfficeSuiteApp({ windowId: _windowId }: { windowId: string }) { + const [view, setView] = useState("write"); + + return ( +
+ {/* title strip */} +
+ Office Suite +
+ +
+ {/* left rail */} + + + {/* active surface */} +
+ {view === "write" && } + {view === "calc" && } + {view === "slides" && } + {view === "data" && ( +
+ Data view coming soon +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/apps/ProjectsApp/AddAgentDialog.tsx b/desktop/src/apps/ProjectsApp/AddAgentDialog.tsx index 508e7d12c..b0305fedd 100644 --- a/desktop/src/apps/ProjectsApp/AddAgentDialog.tsx +++ b/desktop/src/apps/ProjectsApp/AddAgentDialog.tsx @@ -5,6 +5,11 @@ import { projectsApi } from "@/lib/projects"; type Mode = "new" | "existing"; type AgentMode = "native" | "clone"; +interface ExternalAgentSummary { + handle: string; + display_name?: string; +} + export function AddAgentDialog({ projectId, onClose, @@ -18,6 +23,7 @@ export function AddAgentDialog({ const [agentMode, setAgentMode] = useState("native"); const [agentId, setAgentId] = useState(""); const [cloneMemory, setCloneMemory] = useState(true); + const [externalAgents, setExternalAgents] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -26,6 +32,45 @@ export function AddAgentDialog({ if (mode === "new") setAgentMode("native"); }, [mode]); + useEffect(() => { + let cancelled = false; + fetch("/api/agents/registry", { credentials: "include" }) + .then((r) => (r.ok ? r.json() : [])) + .then((rows) => { + if (cancelled || !Array.isArray(rows)) return; + const active = rows.filter( + (entry: { origin?: string; status?: string }) => + entry.origin === "external-selfjoin" && entry.status === "active", + ); + setExternalAgents( + active + .map((entry: { handle?: string; display_name?: string }) => ({ + handle: entry.handle || "", + display_name: entry.display_name, + })) + .filter((entry) => entry.handle), + ); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const addExternal = async (handle: string) => { + if (submitting) return; + setSubmitting(true); + setError(null); + try { + await projectsApi.members.addNative(projectId, handle); + onAdded(); + } catch (err) { + setError(String(err)); + } finally { + setSubmitting(false); + } + }; + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (submitting) return; @@ -124,6 +169,27 @@ export function AddAgentDialog({ )} + {externalAgents.length > 0 && ( +
+

External / Connected agents

+
    + {externalAgents.map((agent) => ( +
  • + +
  • + ))} +
+
+ )} + {error &&
{error}
}
+
+ + ); +} + export function ProjectMembers({ project, onChanged }: { project: Project; onChanged: () => void }) { const [members, setMembers] = useState([]); const [agents, setAgents] = useState([]); + const [externalAgents, setExternalAgents] = useState([]); + const [externalRegistryLoaded, setExternalRegistryLoaded] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const refresh = () => @@ -65,12 +191,63 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha }; }, []); + useEffect(() => { + let cancelled = false; + fetch("/api/agents/registry", { credentials: "include" }) + .then((r) => (r.ok ? r.json() : [])) + .then((rows) => { + if (cancelled) return; + if (Array.isArray(rows)) { + const active = rows.filter( + (entry: { origin?: string; status?: string }) => + entry.origin === "external-selfjoin" && entry.status === "active", + ); + setExternalAgents( + active.map((entry: { handle?: string; display_name?: string }) => ({ + handle: entry.handle || "", + display_name: entry.display_name, + })), + ); + } + setExternalRegistryLoaded(true); + }) + .catch(() => { + if (!cancelled) setExternalRegistryLoaded(true); + }); + return () => { + cancelled = true; + }; + }, []); + const byId = useMemo(() => { const m = new Map(); for (const a of agents) m.set(a.id, a); return m; }, [agents]); + const byHandle = useMemo(() => { + const m = new Map(); + for (const a of externalAgents) { + if (a.handle) m.set(a.handle, a); + } + return m; + }, [externalAgents]); + + const { mainMembers, externalMembers } = useMemo(() => { + const main: ProjectMember[] = []; + const external: ProjectMember[] = []; + for (const m of members) { + if (byId.has(m.member_id)) { + main.push(m); + } else if (byHandle.has(m.member_id)) { + external.push(m); + } else if (externalRegistryLoaded) { + main.push(m); + } + } + return { mainMembers: main, externalMembers: external }; + }, [members, byId, byHandle, externalRegistryLoaded]); + return (
@@ -84,81 +261,44 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
    - {members.map((m) => { + {mainMembers.map((m) => { const { label, emoji, hint } = formatMemberLabel(m.member_id, byId); return ( -
  • -
    -
    - {emoji && {emoji}} - {label} - {!!m.is_lead && ( - - ★ Lead - - )} -
    -
    - {m.member_kind} - {m.member_kind === "clone" ? ` · ${m.memory_seed}` : ""} -
    -
    -
    - {(m.member_kind === "native" || m.member_kind === "clone") && ( - - )} - {(m.member_kind === "native" || m.member_kind === "clone") && ( - - )} - -
    -
  • + ); })}
+ {externalMembers.length > 0 && ( +
+

External / Connected agents

+
    + {externalMembers.map((m) => { + const { label, hint } = formatExternalMemberLabel(m.member_id, byHandle); + return ( + + ); + })} +
+
+ )} {dialogOpen && ( ); -} +} \ No newline at end of file diff --git a/desktop/src/apps/SandboxedAppWindow.tsx b/desktop/src/apps/SandboxedAppWindow.tsx new file mode 100644 index 000000000..4d6c7b447 --- /dev/null +++ b/desktop/src/apps/SandboxedAppWindow.tsx @@ -0,0 +1,102 @@ +// desktop/src/apps/SandboxedAppWindow.tsx +import { useEffect, useRef } from "react"; +import { useThemeStore } from "@/stores/theme-store"; +import { ALLOWED_TOKENS } from "@/theme/theme-config"; + +interface Props { + windowId: string; + appId: string; + trust?: "community" | "first-party"; +} + +interface BrokerRequest { + taosApp: string; + id: number; + capability: string; + args?: Record; +} + +/** Read all ALLOWED_TOKENS CSS variables off :root and return as a plain object. */ +function readThemeTokens(): Record { + if (typeof document === "undefined") return {}; + const style = getComputedStyle(document.documentElement); + const tokens: Record = {}; + for (const token of ALLOWED_TOKENS) { + const value = style.getPropertyValue(token).trim(); + if (value) tokens[token] = value; + } + return tokens; +} + +export function SandboxedAppWindow({ appId, trust = "community" }: Props) { + const iframeRef = useRef(null); + const isFirstParty = trust === "first-party"; + // Subscribe to scheme changes (which fire on any applyThemeConfig call) so we + // can push updated tokens when the theme changes. The selector is minimal to + // avoid re-renders on unrelated store updates. + const scheme = useThemeStore((s) => s.scheme); + + // Post theme tokens into the iframe for first-party apps. + useEffect(() => { + if (!isFirstParty) return; + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + iframe.contentWindow.postMessage({ taosTheme: readThemeTokens() }, "*"); + }, [isFirstParty, scheme]); + + // Also post tokens once when the iframe loads (the scheme effect may fire + // before the frame is ready on the first render). + function handleLoad() { + if (!isFirstParty) return; + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + iframe.contentWindow.postMessage({ taosTheme: readThemeTokens() }, "*"); + } + + useEffect(() => { + async function onMessage(e: MessageEvent) { + const iframe = iframeRef.current; + // Only handle messages from THIS app's sandboxed iframe. + if (!iframe || e.source !== iframe.contentWindow) return; + const msg = e.data as BrokerRequest; + if (!msg || msg.taosApp !== appId || typeof msg.id !== "number" || !msg.capability) return; + // Validate args: must be a plain object (not an array, null, or primitive). + // Non-conforming values are coerced to {} rather than forwarded as-is into + // backend capability handling. + const rawArgs = msg.args; + const safeArgs: Record = + rawArgs !== null && typeof rawArgs === "object" && !Array.isArray(rawArgs) + ? rawArgs + : {}; + let result: Record; + try { + const res = await fetch(`/api/userspace-apps/${encodeURIComponent(appId)}/broker`, { + method: "POST", + // Carry the taos_session cookie so the broker authenticates the + // caller -- matches every other SPA API call, and stays correct + // under the Vite dev proxy (SPA :5173 -> API :6969). + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ capability: msg.capability, args: safeArgs }), + }); + result = res.ok ? await res.json() : { error: `broker_${res.status}` }; + } catch { + result = { error: "broker_unreachable" }; + } + iframe.contentWindow?.postMessage({ taosAppReply: msg.id, ...result }, "*"); + } + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [appId]); + + return ( +