diff --git a/desktop/src/components/Launchpad.tsx b/desktop/src/components/Launchpad.tsx
index 66e11816d..4d1db96b9 100644
--- a/desktop/src/components/Launchpad.tsx
+++ b/desktop/src/components/Launchpad.tsx
@@ -124,7 +124,7 @@ export function Launchpad({ open, onClose, onOpenApp }: Props) {
{filteredServices.length > 0 && (
- Services
+ Apps
{filteredServices.map((svc) => (
diff --git a/desktop/src/components/__tests__/Launchpad.test.tsx b/desktop/src/components/__tests__/Launchpad.test.tsx
new file mode 100644
index 000000000..ffe0c7260
--- /dev/null
+++ b/desktop/src/components/__tests__/Launchpad.test.tsx
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import type { InstalledService } from "@/hooks/use-installed-services";
+
+// Mockable list of installed services returned by the hook.
+let mockServices: InstalledService[] = [];
+
+vi.mock("@/hooks/use-installed-services", () => ({
+ useInstalledServices: () => mockServices,
+}));
+
+// Shortcut registry is a no-op in tests.
+vi.mock("@/hooks/use-shortcut-registry", () => ({
+ useShortcut: () => {},
+}));
+
+// Capture window-open calls so we can assert the launch URL.
+const openWindow = vi.fn(() => "wid-1");
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: () => ({ openWindow }),
+}));
+
+// Registry: getAllApps returns no core apps so the test isolates the Apps
+// section; getApp/getOrRegisterServiceApp echo a minimal manifest.
+vi.mock("@/registry/app-registry", () => ({
+ getAllApps: () => [],
+ getApp: (id: string) => ({ id, defaultSize: { w: 100, h: 100 } }),
+ getOrRegisterServiceApp: (appId: string, displayName: string) => ({
+ id: `service:${appId}`,
+ name: displayName,
+ defaultSize: { w: 1100, h: 750 },
+ }),
+}));
+
+import { Launchpad } from "../Launchpad";
+
+const searxng: InstalledService = {
+ app_id: "searxng",
+ display_name: "SearXNG",
+ icon: null,
+ url: "/apps/searxng/",
+ category: "infrastructure",
+ backend: "docker",
+ status: "running",
+};
+
+const gitea: InstalledService = {
+ app_id: "gitea-lxc",
+ display_name: "Gitea",
+ icon: "/static/app-icons/gitea.svg",
+ url: "/apps/gitea-lxc/",
+ category: "dev-tool",
+ backend: "lxc",
+ status: "running",
+};
+
+describe("Launchpad Apps section", () => {
+ beforeEach(() => {
+ mockServices = [];
+ openWindow.mockClear();
+ });
+
+ it("does not render an Apps section when no apps are installed", () => {
+ mockServices = [];
+ render( {}} />);
+ expect(screen.queryByText("Apps")).toBeNull();
+ });
+
+ it("renders an Apps section with a shortcut per installed app/service", () => {
+ mockServices = [searxng, gitea];
+ render( {}} />);
+
+ expect(screen.getByText("Apps")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Open SearXNG" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Open Gitea" })).toBeTruthy();
+ });
+
+ it("opens the proxied service URL when an app shortcut is launched", () => {
+ mockServices = [searxng];
+ render( {}} />);
+
+ fireEvent.click(screen.getByRole("button", { name: "Open SearXNG" }));
+
+ // ServiceAppWindow receives the proxied URL so SearXNG renders its search page.
+ expect(openWindow).toHaveBeenCalledWith(
+ "service:searxng",
+ { w: 1100, h: 750 },
+ { url: "/apps/searxng/", displayName: "SearXNG" },
+ );
+ });
+});
diff --git a/desktop/tsconfig.tsbuildinfo b/desktop/tsconfig.tsbuildinfo
index f370350ab..48726effb 100644
--- a/desktop/tsconfig.tsbuildinfo
+++ b/desktop/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/chatstandalone.tsx","./src/chat-main.tsx","./src/main.tsx","./src/sw.ts","./src/apps/activityapp.tsx","./src/apps/agentbrowsersapp.tsx","./src/apps/agentmessagespanel.tsx","./src/apps/agentskillspanel.tsx","./src/apps/agentsapp.tsx","./src/apps/calculatorapp.tsx","./src/apps/calendarapp.tsx","./src/apps/channelsapp.tsx","./src/apps/chessapp.tsx","./src/apps/clusterapp.tsx","./src/apps/contactsapp.tsx","./src/apps/crosswordsapp.tsx","./src/apps/filesapp.tsx","./src/apps/githubapp.tsx","./src/apps/imageviewerapp.tsx","./src/apps/imagesapp.tsx","./src/apps/importapp.tsx","./src/apps/libraryapp.tsx","./src/apps/mcpapp.tsx","./src/apps/mediaplayerapp.tsx","./src/apps/memoryapp.tsx","./src/apps/messagesapp.a2aselection.ts","./src/apps/messagesapp.tsx","./src/apps/modelsapp.tsx","./src/apps/placeholderapp.tsx","./src/apps/providersapp.tsx","./src/apps/redditapp.tsx","./src/apps/secretsapp.tsx","./src/apps/serviceappwindow.tsx","./src/apps/settingsapp.tsx","./src/apps/tasksapp.tsx","./src/apps/terminalapp.tsx","./src/apps/texteditorapp.tsx","./src/apps/weatherapp.tsx","./src/apps/wordleapp.tsx","./src/apps/xapp.tsx","./src/apps/youtubeapp.tsx","./src/apps/browserapp/addressbar.tsx","./src/apps/browserapp/addresssuggest.tsx","./src/apps/browserapp/agentcapabilitiespanel.tsx","./src/apps/browserapp/agentpanel.tsx","./src/apps/browserapp/agentpickerpopover.tsx","./src/apps/browserapp/agentpresencepill.tsx","./src/apps/browserapp/annotationlayer.tsx","./src/apps/browserapp/bookmarksbar.tsx","./src/apps/browserapp/browserapp.tsx","./src/apps/browserapp/capabilitypromptmodal.tsx","./src/apps/browserapp/chrome.tsx","./src/apps/browserapp/copilotbanner.tsx","./src/apps/browserapp/findinpage.tsx","./src/apps/browserapp/movetabmenu.tsx","./src/apps/browserapp/pagecontextmenu.tsx","./src/apps/browserapp/profilemanager.tsx","./src/apps/browserapp/profileswitcher.tsx","./src/apps/browserapp/readermode.tsx","./src/apps/browserapp/settingspanel.tsx","./src/apps/browserapp/sitepermissionspanel.tsx","./src/apps/browserapp/taboverview.tsx","./src/apps/browserapp/tabrenderer.tsx","./src/apps/browserapp/tabstrip.tsx","./src/apps/browserapp/windowchooser.tsx","./src/apps/browserapp/agent-ws-bridge.ts","./src/apps/browserapp/index.ts","./src/apps/browserapp/keyboard.ts","./src/apps/browserapp/live-exclusion.ts","./src/apps/browserapp/types.ts","./src/apps/projectsapp/addagentdialog.tsx","./src/apps/projectsapp/createprojectdialog.tsx","./src/apps/projectsapp/projectactivity.tsx","./src/apps/projectsapp/projectlist.tsx","./src/apps/projectsapp/projectmembers.tsx","./src/apps/projectsapp/projecttasklist.tsx","./src/apps/projectsapp/projectworkspace.tsx","./src/apps/projectsapp/index.tsx","./src/apps/projectsapp/board/boardcolumn.tsx","./src/apps/projectsapp/board/boardfilters.tsx","./src/apps/projectsapp/board/boardlane.tsx","./src/apps/projectsapp/board/boardtoolbar.tsx","./src/apps/projectsapp/board/projectboard.tsx","./src/apps/projectsapp/board/taskcard.tsx","./src/apps/projectsapp/board/taskcardcover.tsx","./src/apps/projectsapp/board/taskmodal.tsx","./src/apps/projectsapp/board/boarddnd.ts","./src/apps/projectsapp/board/boardfiltering.ts","./src/apps/projectsapp/board/boardgrouping.ts","./src/apps/projectsapp/board/types.ts","./src/apps/projectsapp/board/useboarddata.ts","./src/apps/projectsapp/board/useboardlive.ts","./src/apps/projectsapp/board/modal/activity.tsx","./src/apps/projectsapp/board/modal/hero.tsx","./src/apps/projectsapp/board/modal/metadatapane.tsx","./src/apps/projectsapp/board/modal/relationships.tsx","./src/apps/projectsapp/board/modal/subtasks.tsx","./src/apps/projectsapp/canvas/canvasboard.tsx","./src/apps/projectsapp/canvas/canvasview.tsx","./src/apps/projectsapp/canvas/canvas-api.ts","./src/apps/projectsapp/canvas/canvas-sse.ts","./src/apps/projectsapp/canvas/canvas-store.ts","./src/apps/projectsapp/canvas/shapes/imageshape.tsx","./src/apps/projectsapp/canvas/shapes/linkshape.tsx","./src/apps/projectsapp/canvas/shapes/noteshape.tsx","./src/apps/projectsapp/mobile/mobileboardcarousel.tsx","./src/apps/projectsapp/mobile/mobiletaskmodal.tsx","./src/apps/projectsapp/mobile/projectfab.tsx","./src/apps/projectsapp/mobile/taskcreatesheet.tsx","./src/apps/storeapp/backendpillbar.tsx","./src/apps/storeapp/devicepillbar.tsx","./src/apps/storeapp/incompatibletoggle.tsx","./src/apps/storeapp/backends.ts","./src/apps/storeapp/compat-visuals.ts","./src/apps/storeapp/filter.ts","./src/apps/storeapp/index.tsx","./src/apps/storeapp/resolver-types.ts","./src/apps/storeapp/storage.ts","./src/apps/storeapp/types.ts","./src/apps/chat/agentcontextmenu.tsx","./src/apps/chat/allthreadslist.tsx","./src/apps/chat/attachmentgallery.tsx","./src/apps/chat/attachmentlightbox.tsx","./src/apps/chat/attachmentsbar.tsx","./src/apps/chat/channelsettingspanel.tsx","./src/apps/chat/helppanel.tsx","./src/apps/chat/messageeditor.tsx","./src/apps/chat/messagehoveractions.tsx","./src/apps/chat/messageoverflowmenu.tsx","./src/apps/chat/messagetombstone.tsx","./src/apps/chat/pinbadge.tsx","./src/apps/chat/pinrequestaffordance.tsx","./src/apps/chat/pinnedmessagespopover.tsx","./src/apps/chat/slashmenu.tsx","./src/apps/chat/threadindicator.tsx","./src/apps/chat/threadpanel.tsx","./src/apps/chat/typingfooter.tsx","./src/apps/chat/format-author.ts","./src/components/agentshortcutrow.tsx","./src/components/apperrorboundary.tsx","./src/components/appshell.tsx","./src/components/backendbanner.tsx","./src/components/contextmenu.tsx","./src/components/desktop.tsx","./src/components/dock.tsx","./src/components/dockicon.tsx","./src/components/emojipicker.tsx","./src/components/launchpad.tsx","./src/components/launchpadicon.tsx","./src/components/logingate.tsx","./src/components/loginscreen.tsx","./src/components/migrationbanner.tsx","./src/components/modelbrowser.tsx","./src/components/modelpickerflow.tsx","./src/components/modelpickermodal.tsx","./src/components/notificationcentre.tsx","./src/components/notificationtoast.tsx","./src/components/onboardingscreen.tsx","./src/components/searchpalette.tsx","./src/components/serviceicon.tsx","./src/components/snapoverlay.tsx","./src/components/statusindicators.tsx","./src/components/taosassistantpanel.tsx","./src/components/taosassistantsettings.tsx","./src/components/topbar.tsx","./src/components/updateavailabletoast.tsx","./src/components/wallpaperpicker.tsx","./src/components/widgetlayer.tsx","./src/components/window.tsx","./src/components/windowcontent.tsx","./src/components/agent-settings/frameworktab.tsx","./src/components/agent-settings/memorytab.tsx","./src/components/agent-settings/personatab.tsx","./src/components/memory/agentmemorytable.tsx","./src/components/memory/dashboard.tsx","./src/components/memory/memorysettings.tsx","./src/components/memory/pipelinecontrol.tsx","./src/components/memory/schemaformrenderer.tsx","./src/components/memory/sessionbrowser.tsx","./src/components/memory/sessiondetail.tsx","./src/components/mobile/cardswitcher.tsx","./src/components/mobile/mobileapp.tsx","./src/components/mobile/mobileappwindow.tsx","./src/components/mobile/mobilebottomnav.tsx","./src/components/mobile/mobiledock.tsx","./src/components/mobile/mobilehomepages.tsx","./src/components/mobile/mobilelist.tsx","./src/components/mobile/mobilesplitview.tsx","./src/components/mobile/mobiletopbar.tsx","./src/components/mobile/pillbar.tsx","./src/components/mobile/workspacetabpills.tsx","./src/components/persona-picker/personablank.tsx","./src/components/persona-picker/personabrowse.tsx","./src/components/persona-picker/personacreate.tsx","./src/components/persona-picker/personapicker.tsx","./src/components/persona-picker/types.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toolbar.tsx","./src/components/widgets/agentstatuswidget.tsx","./src/components/widgets/clockwidget.tsx","./src/components/widgets/greetingwidget.tsx","./src/components/widgets/quicknoteswidget.tsx","./src/components/widgets/systemstatswidget.tsx","./src/components/widgets/weatherwidget.tsx","./src/contexts/backendstatuscontext.tsx","./src/hooks/use-agent-shortcuts.ts","./src/hooks/use-clock.ts","./src/hooks/use-device-mode.ts","./src/hooks/use-focus-trap.ts","./src/hooks/use-installed-services.ts","./src/hooks/use-is-mobile.ts","./src/hooks/use-list-nav.ts","./src/hooks/use-server-preference.ts","./src/hooks/use-session-persistence.ts","./src/hooks/use-shortcut-registry.tsx","./src/hooks/use-snap-zones.ts","./src/hooks/use-visual-viewport.ts","./src/hooks/use-widget-size.ts","./src/lib/agent-browsers.ts","./src/lib/agent-emoji.ts","./src/lib/backendstatus.ts","./src/lib/browser-agent-api.ts","./src/lib/browser-bookmarks-api.ts","./src/lib/browser-capability-api.ts","./src/lib/browser-extract-api.ts","./src/lib/browser-profile-api.ts","./src/lib/browser-push-api.ts","./src/lib/browser-push-bootstrap.ts","./src/lib/browser-site-permissions-api.ts","./src/lib/browser-suggest-api.ts","./src/lib/browser-windows-api.ts","./src/lib/channel-admin-api.ts","./src/lib/chat-attachments-api.ts","./src/lib/chat-messages-api.ts","./src/lib/cluster.ts","./src/lib/framework-api.ts","./src/lib/github.ts","./src/lib/hw-detect.ts","./src/lib/knowledge.ts","./src/lib/memory.ts","./src/lib/models.ts","./src/lib/personas-api.ts","./src/lib/projects.ts","./src/lib/reddit.ts","./src/lib/slug.ts","./src/lib/sw-register.ts","./src/lib/taos-fetch.ts","./src/lib/use-thread-panel.ts","./src/lib/use-typing-emitter.ts","./src/lib/utils.ts","./src/lib/x-monitor.ts","./src/lib/youtube.ts","./src/registry/app-registry.ts","./src/shell/bottomsheet.tsx","./src/shell/filepicker.tsx","./src/shell/installpromptbanner.tsx","./src/shell/vfsbrowser.tsx","./src/shell/file-picker-api.ts","./src/shell/dnd/dnd-bus.ts","./src/shell/dnd/types.ts","./src/shell/dnd/use-drag-source.ts","./src/shell/dnd/use-drop-target.ts","./src/stores/browser-agent-store.ts","./src/stores/browser-settings-store.ts","./src/stores/browser-store.ts","./src/stores/dock-store.ts","./src/stores/mobile-home-store.ts","./src/stores/notification-store.ts","./src/stores/process-store.ts","./src/stores/taos-agent-store.ts","./src/stores/theme-store.ts","./src/stores/widget-store.ts","./src/types/css-modules.d.ts","./src/types/pell.d.ts","./src/types/plyr.d.ts","./src/types/react-grid-layout.d.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/chatstandalone.tsx","./src/chat-main.tsx","./src/main.tsx","./src/sw.ts","./src/apps/activityapp.tsx","./src/apps/agentbrowsersapp.tsx","./src/apps/agentmessagespanel.tsx","./src/apps/agentskillspanel.tsx","./src/apps/agentsapp.tsx","./src/apps/calculatorapp.tsx","./src/apps/calendarapp.tsx","./src/apps/channelsapp.tsx","./src/apps/chessapp.tsx","./src/apps/clusterapp.tsx","./src/apps/contactsapp.tsx","./src/apps/crosswordsapp.tsx","./src/apps/filesapp.tsx","./src/apps/githubapp.tsx","./src/apps/imageviewerapp.tsx","./src/apps/imagesapp.tsx","./src/apps/importapp.tsx","./src/apps/libraryapp.tsx","./src/apps/mcpapp.tsx","./src/apps/mediaplayerapp.tsx","./src/apps/memoryapp.tsx","./src/apps/messagesapp.a2aselection.ts","./src/apps/messagesapp.tsx","./src/apps/modelsapp.tsx","./src/apps/placeholderapp.tsx","./src/apps/providersapp.tsx","./src/apps/redditapp.tsx","./src/apps/secretsapp.tsx","./src/apps/serviceappwindow.tsx","./src/apps/settingsapp.tsx","./src/apps/tasksapp.tsx","./src/apps/terminalapp.tsx","./src/apps/texteditorapp.tsx","./src/apps/weatherapp.tsx","./src/apps/wordleapp.tsx","./src/apps/xapp.tsx","./src/apps/youtubeapp.tsx","./src/apps/shortcut-launch.ts","./src/apps/browserapp/addressbar.tsx","./src/apps/browserapp/addresssuggest.tsx","./src/apps/browserapp/agentcapabilitiespanel.tsx","./src/apps/browserapp/agentpanel.tsx","./src/apps/browserapp/agentpickerpopover.tsx","./src/apps/browserapp/agentpresencepill.tsx","./src/apps/browserapp/annotationlayer.tsx","./src/apps/browserapp/bookmarksbar.tsx","./src/apps/browserapp/browserapp.tsx","./src/apps/browserapp/capabilitypromptmodal.tsx","./src/apps/browserapp/chrome.tsx","./src/apps/browserapp/copilotbanner.tsx","./src/apps/browserapp/findinpage.tsx","./src/apps/browserapp/movetabmenu.tsx","./src/apps/browserapp/pagecontextmenu.tsx","./src/apps/browserapp/profilemanager.tsx","./src/apps/browserapp/profileswitcher.tsx","./src/apps/browserapp/readermode.tsx","./src/apps/browserapp/settingspanel.tsx","./src/apps/browserapp/sitepermissionspanel.tsx","./src/apps/browserapp/taboverview.tsx","./src/apps/browserapp/tabrenderer.tsx","./src/apps/browserapp/tabstrip.tsx","./src/apps/browserapp/windowchooser.tsx","./src/apps/browserapp/agent-ws-bridge.ts","./src/apps/browserapp/index.ts","./src/apps/browserapp/keyboard.ts","./src/apps/browserapp/live-exclusion.ts","./src/apps/browserapp/types.ts","./src/apps/projectsapp/addagentdialog.tsx","./src/apps/projectsapp/createprojectdialog.tsx","./src/apps/projectsapp/projectactivity.tsx","./src/apps/projectsapp/projectlist.tsx","./src/apps/projectsapp/projectmembers.tsx","./src/apps/projectsapp/projecttasklist.tsx","./src/apps/projectsapp/projectworkspace.tsx","./src/apps/projectsapp/index.tsx","./src/apps/projectsapp/board/boardcolumn.tsx","./src/apps/projectsapp/board/boardfilters.tsx","./src/apps/projectsapp/board/boardlane.tsx","./src/apps/projectsapp/board/boardtoolbar.tsx","./src/apps/projectsapp/board/projectboard.tsx","./src/apps/projectsapp/board/taskcard.tsx","./src/apps/projectsapp/board/taskcardcover.tsx","./src/apps/projectsapp/board/taskmodal.tsx","./src/apps/projectsapp/board/boarddnd.ts","./src/apps/projectsapp/board/boardfiltering.ts","./src/apps/projectsapp/board/boardgrouping.ts","./src/apps/projectsapp/board/types.ts","./src/apps/projectsapp/board/useboarddata.ts","./src/apps/projectsapp/board/useboardlive.ts","./src/apps/projectsapp/board/modal/activity.tsx","./src/apps/projectsapp/board/modal/hero.tsx","./src/apps/projectsapp/board/modal/metadatapane.tsx","./src/apps/projectsapp/board/modal/relationships.tsx","./src/apps/projectsapp/board/modal/subtasks.tsx","./src/apps/projectsapp/canvas/canvasboard.tsx","./src/apps/projectsapp/canvas/canvasview.tsx","./src/apps/projectsapp/canvas/canvas-api.ts","./src/apps/projectsapp/canvas/canvas-sse.ts","./src/apps/projectsapp/canvas/canvas-store.ts","./src/apps/projectsapp/canvas/shapes/imageshape.tsx","./src/apps/projectsapp/canvas/shapes/linkshape.tsx","./src/apps/projectsapp/canvas/shapes/noteshape.tsx","./src/apps/projectsapp/mobile/mobileboardcarousel.tsx","./src/apps/projectsapp/mobile/mobiletaskmodal.tsx","./src/apps/projectsapp/mobile/projectfab.tsx","./src/apps/projectsapp/mobile/taskcreatesheet.tsx","./src/apps/storeapp/backendpillbar.tsx","./src/apps/storeapp/devicepillbar.tsx","./src/apps/storeapp/incompatibletoggle.tsx","./src/apps/storeapp/backends.ts","./src/apps/storeapp/compat-visuals.ts","./src/apps/storeapp/filter.ts","./src/apps/storeapp/index.tsx","./src/apps/storeapp/resolver-types.ts","./src/apps/storeapp/storage.ts","./src/apps/storeapp/types.ts","./src/apps/chat/agentcontextmenu.tsx","./src/apps/chat/allthreadslist.tsx","./src/apps/chat/attachmentgallery.tsx","./src/apps/chat/attachmentlightbox.tsx","./src/apps/chat/attachmentsbar.tsx","./src/apps/chat/channelsettingspanel.tsx","./src/apps/chat/helppanel.tsx","./src/apps/chat/messageeditor.tsx","./src/apps/chat/messagehoveractions.tsx","./src/apps/chat/messageoverflowmenu.tsx","./src/apps/chat/messagetombstone.tsx","./src/apps/chat/pinbadge.tsx","./src/apps/chat/pinrequestaffordance.tsx","./src/apps/chat/pinnedmessagespopover.tsx","./src/apps/chat/slashmenu.tsx","./src/apps/chat/threadindicator.tsx","./src/apps/chat/threadpanel.tsx","./src/apps/chat/typingfooter.tsx","./src/apps/chat/format-author.ts","./src/components/agentshortcutrow.tsx","./src/components/apperrorboundary.tsx","./src/components/appshell.tsx","./src/components/backendbanner.tsx","./src/components/contextmenu.tsx","./src/components/desktop.tsx","./src/components/dock.tsx","./src/components/dockicon.tsx","./src/components/emojipicker.tsx","./src/components/launchpad.tsx","./src/components/launchpadicon.tsx","./src/components/logingate.tsx","./src/components/loginscreen.tsx","./src/components/migrationbanner.tsx","./src/components/modelbrowser.tsx","./src/components/modelpickerflow.tsx","./src/components/modelpickermodal.tsx","./src/components/notificationcentre.tsx","./src/components/notificationtoast.tsx","./src/components/onboardingscreen.tsx","./src/components/searchpalette.tsx","./src/components/serviceicon.tsx","./src/components/snapoverlay.tsx","./src/components/statusindicators.tsx","./src/components/taosassistantpanel.tsx","./src/components/taosassistantsettings.tsx","./src/components/topbar.tsx","./src/components/updateavailabletoast.tsx","./src/components/wallpaperpicker.tsx","./src/components/widgetlayer.tsx","./src/components/window.tsx","./src/components/windowcontent.tsx","./src/components/agent-settings/frameworktab.tsx","./src/components/agent-settings/memorytab.tsx","./src/components/agent-settings/personatab.tsx","./src/components/memory/agentmemorytable.tsx","./src/components/memory/dashboard.tsx","./src/components/memory/memorysettings.tsx","./src/components/memory/pipelinecontrol.tsx","./src/components/memory/schemaformrenderer.tsx","./src/components/memory/sessionbrowser.tsx","./src/components/memory/sessiondetail.tsx","./src/components/mobile/cardswitcher.tsx","./src/components/mobile/mobileapp.tsx","./src/components/mobile/mobileappwindow.tsx","./src/components/mobile/mobilebottomnav.tsx","./src/components/mobile/mobiledock.tsx","./src/components/mobile/mobilehomepages.tsx","./src/components/mobile/mobilelist.tsx","./src/components/mobile/mobilesplitview.tsx","./src/components/mobile/mobiletopbar.tsx","./src/components/mobile/pillbar.tsx","./src/components/mobile/workspacetabpills.tsx","./src/components/persona-picker/personablank.tsx","./src/components/persona-picker/personabrowse.tsx","./src/components/persona-picker/personacreate.tsx","./src/components/persona-picker/personapicker.tsx","./src/components/persona-picker/types.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toolbar.tsx","./src/components/widgets/agentstatuswidget.tsx","./src/components/widgets/clockwidget.tsx","./src/components/widgets/greetingwidget.tsx","./src/components/widgets/quicknoteswidget.tsx","./src/components/widgets/systemstatswidget.tsx","./src/components/widgets/weatherwidget.tsx","./src/contexts/backendstatuscontext.tsx","./src/hooks/use-agent-shortcuts.ts","./src/hooks/use-clock.ts","./src/hooks/use-device-mode.ts","./src/hooks/use-focus-trap.ts","./src/hooks/use-installed-services.ts","./src/hooks/use-is-mobile.ts","./src/hooks/use-is-pwa.ts","./src/hooks/use-list-nav.ts","./src/hooks/use-server-preference.ts","./src/hooks/use-session-persistence.ts","./src/hooks/use-shortcut-registry.tsx","./src/hooks/use-snap-zones.ts","./src/hooks/use-visual-viewport.ts","./src/hooks/use-widget-size.ts","./src/lib/agent-browsers.ts","./src/lib/agent-emoji.ts","./src/lib/auth-guard.ts","./src/lib/backendstatus.ts","./src/lib/browser-agent-api.ts","./src/lib/browser-bookmarks-api.ts","./src/lib/browser-capability-api.ts","./src/lib/browser-extract-api.ts","./src/lib/browser-profile-api.ts","./src/lib/browser-push-api.ts","./src/lib/browser-push-bootstrap.ts","./src/lib/browser-site-permissions-api.ts","./src/lib/browser-suggest-api.ts","./src/lib/browser-windows-api.ts","./src/lib/channel-admin-api.ts","./src/lib/chat-attachments-api.ts","./src/lib/chat-messages-api.ts","./src/lib/cluster.ts","./src/lib/framework-api.ts","./src/lib/github.ts","./src/lib/hw-detect.ts","./src/lib/knowledge.ts","./src/lib/memory.ts","./src/lib/models.ts","./src/lib/personas-api.ts","./src/lib/projects.ts","./src/lib/reddit.ts","./src/lib/slug.ts","./src/lib/sw-register.ts","./src/lib/taos-fetch.ts","./src/lib/use-thread-panel.ts","./src/lib/use-typing-emitter.ts","./src/lib/utils.ts","./src/lib/x-monitor.ts","./src/lib/youtube.ts","./src/registry/app-registry.ts","./src/shell/bottomsheet.tsx","./src/shell/filepicker.tsx","./src/shell/installpromptbanner.tsx","./src/shell/vfsbrowser.tsx","./src/shell/file-picker-api.ts","./src/shell/dnd/dnd-bus.ts","./src/shell/dnd/types.ts","./src/shell/dnd/use-drag-source.ts","./src/shell/dnd/use-drop-target.ts","./src/stores/browser-agent-store.ts","./src/stores/browser-settings-store.ts","./src/stores/browser-store.ts","./src/stores/dock-store.ts","./src/stores/mobile-home-store.ts","./src/stores/notification-store.ts","./src/stores/process-store.ts","./src/stores/taos-agent-store.ts","./src/stores/theme-store.ts","./src/stores/widget-store.ts","./src/types/css-modules.d.ts","./src/types/pell.d.ts","./src/types/plyr.d.ts","./src/types/react-grid-layout.d.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/tests/test_routes_store.py b/tests/test_routes_store.py
index 9d9009fb5..c4d20b685 100644
--- a/tests/test_routes_store.py
+++ b/tests/test_routes_store.py
@@ -297,3 +297,85 @@ async def fake_update(app_id, host, port, backend="", ui_path="/"):
assert backend == "lxc"
+@pytest.mark.asyncio
+class TestDockerInstallRecordsRuntimeLocation:
+ """Local docker services (e.g. SearxNG) must record a runtime location so
+ they appear in /api/apps/installed and get a Launchpad shortcut.
+
+ Regression: the docker branch previously fell through to a block that only
+ recorded a location for remote installs (and with port=0), so a local
+ docker install succeeded but never surfaced a shortcut.
+ """
+
+ @pytest.fixture
+ def docker_catalog_dir(self, tmp_path):
+ svc = tmp_path / "catalog" / "services" / "searxng"
+ svc.mkdir(parents=True)
+ (svc / "manifest.yaml").write_text(yaml.dump({
+ "id": "searxng", "name": "SearXNG", "type": "service",
+ "category": "infrastructure", "version": "2024.12.0",
+ "description": "Privacy-respecting metasearch engine",
+ "requires": {"ram_mb": 128, "ports": [8080]},
+ "install": {
+ "method": "docker",
+ "image": "searxng/searxng:latest",
+ "ports": [8080],
+ },
+ }))
+ return tmp_path / "catalog"
+
+ @pytest_asyncio.fixture
+ async def docker_client(self, tmp_data_dir, docker_catalog_dir):
+ app = create_app(data_dir=tmp_data_dir, catalog_dir=docker_catalog_dir)
+ store = app.state.metrics
+ if store._db is not None:
+ await store.close()
+ await store.init()
+ await app.state.qmd_client.init()
+ await app.state.installed_apps.init()
+ app.state.auth.setup_user("admin", "Test Admin", "", "testpass")
+ _rec = app.state.auth.find_user("admin")
+ _token = app.state.auth.create_session(user_id=_rec["id"] if _rec else "", long_lived=True)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test", cookies={"taos_session": _token}) as c:
+ yield c, app
+ await store.close()
+ await app.state.qmd_client.close()
+ await app.state.http_client.aclose()
+
+ async def test_local_docker_install_records_runtime_location_and_appears(
+ self, docker_client
+ ):
+ from unittest.mock import AsyncMock, patch
+
+ client, app = docker_client
+
+ # Mock DockerInstaller so no real docker/compose runs; both pull and
+ # the auto-start succeed.
+ with patch(
+ "tinyagentos.installers.docker_installer.DockerInstaller"
+ ) as MockDocker:
+ instance = MockDocker.return_value
+ instance.install = AsyncMock(return_value={"success": True, "path": "/tmp/x"})
+ instance.start = AsyncMock(return_value={"success": True, "output": ""})
+
+ resp = await client.post(
+ "/api/store/install-v2", json={"app_id": "searxng"}
+ )
+ assert resp.status_code == 200
+
+ # Runtime location recorded with the declared host port + docker backend.
+ loc = await app.state.installed_apps.get_runtime_location("searxng")
+ assert loc is not None
+ assert loc["runtime_host"] == "127.0.0.1"
+ assert loc["runtime_port"] == 8080
+ assert loc["backend"] == "docker"
+
+ # And it surfaces in /api/apps/installed so the Launchpad shows a shortcut.
+ listed = await client.get("/api/apps/installed")
+ assert listed.status_code == 200
+ item = next(i for i in listed.json() if i["app_id"] == "searxng")
+ assert item["url"] == "/apps/searxng/"
+ assert item["status"] == "running"
+
+
diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py
index 5f52bec84..a34ad7cf9 100644
--- a/tinyagentos/routes/store_install.py
+++ b/tinyagentos/routes/store_install.py
@@ -183,6 +183,25 @@ def _registry_get(registry, app_id: str):
return registry.get(app_id)
+def _docker_published_port(install_config: dict) -> int:
+ """Return the first host port a docker service publishes, or 0.
+
+ DockerInstaller maps each declared port as ``{p}:{p}`` so the host port
+ equals the container port. Ports may be declared either at the top level
+ (``install.ports``) or nested under ``install.requires.ports`` — mirror
+ the precedence DockerInstaller._generate_compose uses (requires first).
+ """
+ if not isinstance(install_config, dict):
+ return 0
+ ports = (install_config.get("requires") or {}).get("ports") or install_config.get("ports") or []
+ for p in ports:
+ try:
+ return int(p)
+ except (TypeError, ValueError):
+ continue
+ return 0
+
+
async def _legacy_install(request: Request, body: dict, app_id: str | None, target_remote: str | None) -> JSONResponse:
"""Legacy method-driven install path for non-model manifests.
@@ -393,6 +412,30 @@ async def _legacy_install(request: Request, body: dict, app_id: str | None, targ
await store.install(app_id, body.get("version", ""), meta)
raw_remote = body.get("target_remote") or ""
_target_remote = raw_remote if raw_remote and raw_remote != "local" else None
+
+ # Docker services publish their port on the host (compose maps {p}:{p}),
+ # so they are reachable via the service proxy at /apps/{app_id}/. Record a
+ # runtime location so the app appears in /api/apps/installed and gets a
+ # Launchpad shortcut. Without this, a local docker install (e.g. SearxNG)
+ # succeeds but never surfaces a shortcut. Remote docker installs resolve
+ # the host from the registered incus remote; local installs use 127.0.0.1.
+ if backend == "docker":
+ docker_port = _docker_published_port(install_config)
+ if docker_port:
+ runtime_host = (
+ await _resolve_host(_target_remote) if _target_remote else "127.0.0.1"
+ )
+ await store.update_runtime_location(
+ app_id, host=runtime_host, port=docker_port, backend="docker",
+ ui_path=(install_config.get("ui_path", "/") if isinstance(install_config, dict) else "/"),
+ )
+ else:
+ logger.warning(
+ "_legacy_install: docker service %s declares no port; "
+ "no runtime location recorded (won't appear in Launchpad).",
+ app_id,
+ )
+
if _target_remote is not None:
try:
import tinyagentos.containers as containers
@@ -408,12 +451,16 @@ async def _legacy_install(request: Request, body: dict, app_id: str | None, targ
)
except Exception as exc: # noqa: BLE001
logger.warning("_legacy_install default: could not verify remote %r: %s", _target_remote, exc)
- runtime_host = await _resolve_host(_target_remote)
- await store.update_runtime_location(
- app_id, host=runtime_host, port=0,
- backend=meta.get("backend", "") if isinstance(meta, dict) else "",
- ui_path=(install_config.get("ui_path", "/") if isinstance(install_config, dict) else "/"),
- )
+ # Docker installs already recorded a full host:port location above —
+ # don't clobber it with the port=0 placeholder this branch records for
+ # backends (e.g. pip) that have no proxy-routable port.
+ if backend != "docker":
+ runtime_host = await _resolve_host(_target_remote)
+ await store.update_runtime_location(
+ app_id, host=runtime_host, port=0,
+ backend=meta.get("backend", "") if isinstance(meta, dict) else "",
+ ui_path=(install_config.get("ui_path", "/") if isinstance(install_config, dict) else "/"),
+ )
if registry is not None:
version = body.get("version") or (getattr(manifest, "version", "") if manifest else "")
registry.mark_installed(app_id, version)