diff --git a/desktop/package-lock.json b/desktop/package-lock.json index c4947e0fc..caeb8004a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.41", + "version": "1.0.0-beta.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.41", + "version": "1.0.0-beta.43", "dependencies": { "@codemirror/lang-markdown": "^6.5.1", "@codemirror/language-data": "^6.5.2", @@ -5325,9 +5325,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5345,9 +5342,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5365,9 +5359,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5385,9 +5376,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5405,9 +5393,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5425,9 +5410,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5654,9 +5636,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5674,9 +5653,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5694,9 +5670,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5714,9 +5687,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/desktop/src/apps/LibraryApp.storage.test.tsx b/desktop/src/apps/LibraryApp.storage.test.tsx new file mode 100644 index 000000000..e6c7f5d62 --- /dev/null +++ b/desktop/src/apps/LibraryApp.storage.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { LibraryApp } from "./LibraryApp"; + +const MOCK_ITEMS = [ + { + id: "lib-item-1", + title: "YouTube Video", + source_type: "youtube", + source_url: "https://youtube.com/watch?v=1", + source_id: "yt-1", + author: "YT Author", + summary: "Summary 1", + content: "Content 1", + media_path: null, + thumbnail: null, + categories: [], + tags: [], + metadata: {}, + status: "ready", + monitor: { current_interval: 0, frequency: 0, decay_rate: 0, pinned: false, last_poll: null, last_hash: "" }, + created_at: 1700000000, + updated_at: 1700000000, + }, + { + id: "lib-item-2", + title: "Reddit Post", + source_type: "reddit", + source_url: "https://reddit.com/r/test", + source_id: "rp-1", + author: "Redditor", + summary: "Summary 2", + content: "Content 2", + media_path: null, + thumbnail: null, + categories: [], + tags: [], + metadata: {}, + status: "ready", + monitor: { current_interval: 0, frequency: 0, decay_rate: 0, pinned: false, last_poll: null, last_hash: "" }, + created_at: 1700003600, + updated_at: 1700003600, + }, + { + id: "lib-item-3", + title: "GitHub Repo", + source_type: "github", + source_url: "https://github.com/test/repo", + source_id: "gh-1", + author: "Dev", + summary: "Summary 3", + content: "Content 3", + media_path: null, + thumbnail: null, + categories: [], + tags: [], + metadata: {}, + status: "processing", + monitor: { current_interval: 0, frequency: 0, decay_rate: 0, pinned: false, last_poll: null, last_hash: "" }, + created_at: 1700007200, + updated_at: 1700007200, + }, +]; + +const MOCK_AGENTS = [ + { name: "Agent 1", color: "#ff0000" }, + { name: "Agent 2", color: "#00ff00" }, +]; + +function createFetchMock(overrides: Record> = {}) { + return vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/knowledge/items")) { + for (const [prefix, response] of Object.entries(overrides)) { + if (url.startsWith(prefix)) return response; + } + return Promise.resolve({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve({ items: MOCK_ITEMS, count: MOCK_ITEMS.length }), + } as Response); + } + if (url === "/api/agents") { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve(MOCK_AGENTS), + } as Response); + } + if (url === "/api/knowledge/subscriptions") { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve({ subscriptions: [] }), + } as Response); + } + return Promise.resolve({ + ok: false, + status: 404, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve({}), + } as Response); + }); +} + +describe("LibraryApp storage view", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("renders the view mode toggle and switches to storage view", async () => { + vi.stubGlobal("fetch", createFetchMock() as unknown as typeof fetch); + render(); + + await waitFor(() => screen.getByRole("radio", { name: "storage" })); + fireEvent.click(screen.getByRole("radio", { name: "storage" })); + + await waitFor(() => screen.getByText("Storage Accounting"), { timeout: 5000 }); + }); + + it("shows per-source totals sorted by bytes descending", async () => { + vi.stubGlobal("fetch", createFetchMock() as unknown as typeof fetch); + render(); + + await waitFor(() => screen.getByRole("radio", { name: "storage" })); + fireEvent.click(screen.getByRole("radio", { name: "storage" })); + + await waitFor(() => screen.getByText("Storage Accounting"), { timeout: 5000 }); + expect(screen.getAllByText("YouTube").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Reddit").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("GitHub").length).toBeGreaterThanOrEqual(1); + }); + + it("shows per-item rows sorted by bytes descending", async () => { + vi.stubGlobal("fetch", createFetchMock() as unknown as typeof fetch); + render(); + + await waitFor(() => screen.getByRole("radio", { name: "storage" })); + fireEvent.click(screen.getByRole("radio", { name: "storage" })); + + await waitFor(() => screen.getByText("Storage Accounting"), { timeout: 5000 }); + expect(screen.getByText("YouTube Video")).toBeInTheDocument(); + expect(screen.getByText("Reddit Post")).toBeInTheDocument(); + expect(screen.getByText("GitHub Repo")).toBeInTheDocument(); + }); + + it("does not show paused-at-cap when total is under cap", async () => { + vi.stubGlobal("fetch", createFetchMock() as unknown as typeof fetch); + render(); + + await waitFor(() => screen.getByRole("radio", { name: "storage" })); + fireEvent.click(screen.getByRole("radio", { name: "storage" })); + + await waitFor(() => screen.getByText("Storage Accounting"), { timeout: 5000 }); + expect(screen.queryByText(/Paused at cap/)).toBeNull(); + }); + + it("shows paused-at-cap warning when total exceeds cap", async () => { + const manyItems = Array.from({ length: 60 }, (_, i) => ({ + ...MOCK_ITEMS[0], + id: `overflow-${i}`, + title: `Overflow Item ${i}`, + source_type: "youtube", + created_at: 1700000000 + i, + updated_at: 1700000000 + i, + })); + + const overrides: Record> = { + "/api/knowledge/items": Promise.resolve({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + json: () => Promise.resolve({ items: manyItems, count: manyItems.length }), + } as Response), + }; + + vi.stubGlobal("fetch", createFetchMock(overrides) as unknown as typeof fetch); + render(); + + await waitFor(() => screen.getByRole("radio", { name: "storage" })); + fireEvent.click(screen.getByRole("radio", { name: "storage" })); + + await waitFor(() => screen.getByText("Storage Accounting"), { timeout: 5000 }); + expect(screen.getByText(/Paused at cap/)).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/apps/LibraryApp.tsx b/desktop/src/apps/LibraryApp.tsx index f5e8c52bc..e6c6e91ab 100644 --- a/desktop/src/apps/LibraryApp.tsx +++ b/desktop/src/apps/LibraryApp.tsx @@ -96,6 +96,74 @@ const SOURCE_LABELS: Record = { manual: "Manual", }; +interface SourceStorage { + source_type: string; + bytes: number; + item_count: number; +} + +interface ItemStorageRow { + id: string; + title: string; + source_type: string; + bytes: number; +} + +interface StorageViewData { + total_bytes: number; + cap_bytes: number; + paused_at_cap: boolean; + sources: SourceStorage[]; + items: ItemStorageRow[]; +} + +const STORAGE_CAP_BYTES = 50 * 1024 * 1024 * 1024; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const fmtBytes = (b: number): string => { + if (b < 1024) return `${b} B`; + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; + if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`; + return `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; + +const mockItemBytes = (id: string): number => { + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = ((hash << 5) - hash) + id.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 4000 + 100) * 1024 * 1024; +}; + +const deriveMockStorageData = (items: KnowledgeItem[]): StorageViewData => { + const itemRows: ItemStorageRow[] = items.map((item) => ({ + id: item.id, + title: item.title || "Untitled", + source_type: item.source_type, + bytes: mockItemBytes(item.id), + })); + const totalBytes = itemRows.reduce((sum, row) => sum + row.bytes, 0); + const sourceMap = new Map(); + for (const row of itemRows) { + const existing = sourceMap.get(row.source_type) || { bytes: 0, count: 0 }; + sourceMap.set(row.source_type, { bytes: existing.bytes + row.bytes, count: existing.count + 1 }); + } + const sources: SourceStorage[] = Array.from(sourceMap.entries()) + .map(([source_type, data]) => ({ source_type, bytes: data.bytes, item_count: data.count })) + .sort((a, b) => b.bytes - a.bytes); + return { + total_bytes: totalBytes, + cap_bytes: STORAGE_CAP_BYTES, + paused_at_cap: totalBytes >= STORAGE_CAP_BYTES, + sources, + items: itemRows.sort((a, b) => b.bytes - a.bytes), + }; +}; + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ @@ -160,6 +228,8 @@ export function LibraryApp({ windowId: _windowId }: { windowId: string }) { monitor: null, }); + const [activeView, setActiveView] = useState<"items" | "storage">("items"); + /* ---------- detail state ---------- */ const [snapshots, setSnapshots] = useState([]); const [snapshotsLoading, setSnapshotsLoading] = useState(false); @@ -621,6 +691,21 @@ export function LibraryApp({ windowId: _windowId }: { windowId: string }) { ))} )} +
+ {(["items", "storage"] as const).map((v) => ( + + ))} +
{/* Mobile: search mode + filters inline panel */} @@ -908,6 +993,117 @@ export function LibraryApp({ windowId: _windowId }: { windowId: string }) { ); + /* ---------------------------------------------------------------- */ + /* Storage Accounting View */ + /* ---------------------------------------------------------------- */ + + const storageViewUI = items.length === 0 ? ( +
+
+ Storage Accounting + (mock) +
+
+ No items to account for +
+
+ ) : (() => { + const data = deriveMockStorageData(items); + const totalPct = Math.min(100, (data.total_bytes / data.cap_bytes) * 100); + return ( +
+
+
+ Storage Accounting + (mock) +
+
+
+ + {fmtBytes(data.total_bytes)} used of {fmtBytes(data.cap_bytes)} cap + + {totalPct.toFixed(1)}% +
+
+
= 100 ? "bg-red-500" : totalPct >= 80 ? "bg-amber-500" : "bg-sky-500"}`} + style={{ width: `${totalPct}%` }} + /> +
+ {data.paused_at_cap && ( +
+ + Paused at cap: new heavy-tier downloads are blocked. +
+ )} +
+
+
+
+

By source

+ + + + + + + + + + + {data.sources.map((src) => { + const pct = data.total_bytes > 0 ? (src.bytes / data.total_bytes) * 100 : 0; + return ( + + + + + + + ); + })} + +
SourceItemsBytesShare
{SOURCE_LABELS[src.source_type] ?? src.source_type}{src.item_count}{fmtBytes(src.bytes)} +
+
+
+
+
+
+

By item

+ + + + + + + + + + + {data.items.map((item) => { + const pct = data.cap_bytes > 0 ? (item.bytes / data.cap_bytes) * 100 : 0; + return ( + + + + + + + ); + })} + +
TitleSourceBytesShare of cap
{item.title}{SOURCE_LABELS[item.source_type] ?? item.source_type}{fmtBytes(item.bytes)} +
+
+
+
+
+
+
+ ); + })(); + /* ---------------------------------------------------------------- */ /* Detail View UI */ /* ---------------------------------------------------------------- */ @@ -1485,22 +1681,28 @@ export function LibraryApp({ windowId: _windowId }: { windowId: string }) { detailTitle={selectedItem ? (selectedItem.title || "Untitled") : undefined} listWidth={700} list={ - /* List pane: sidebar filters + item list side by side on desktop, - stacked (filters embedded above list) on mobile */ -
- {/* Sidebar — always visible on desktop; hidden on mobile */} - {!isMobile && sidebarUI} - {listViewUI} -
+ activeView === "items" ? ( + /* List pane: sidebar filters + item list side by side on desktop, + stacked (filters embedded above list) on mobile */ +
+ {/* Sidebar — always visible on desktop; hidden on mobile */} + {!isMobile && sidebarUI} + {listViewUI} +
+ ) : ( + storageViewUI + ) } detail={ - detailViewUI ?? ( - !isMobile ? ( -
- {loading ? "Loading..." : items.length === 0 ? "Add items to get started" : "Select an item"} -
- ) : null - ) + activeView === "items" + ? detailViewUI ?? ( + !isMobile ? ( +
+ {loading ? "Loading..." : items.length === 0 ? "Add items to get started" : "Select an item"} +
+ ) : null + ) + : null } /> {categoryManagerUI}