Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 59 additions & 11 deletions desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import { UpdatesPanel } from "./UpdatesPanel";

const jResp = (b: any) => Promise.resolve({ ok: true, json: async () => b } as any);

const BASE_FETCH = async (url: string) => {
if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true });
if (url === "/api/settings/update-check")
return jResp({ has_updates: false, current_version: "1.0.0-beta.2", current_commit: "abc x" });
if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null });
if (url === "/api/settings/branches") return jResp({ branches: ["master", "dev"], current: "dev" });
if (url === "/api/settings/update-channel") return jResp({ status: "switching", branch: "master" });
if (url === "/api/apps/optional/catalog") return jResp({ apps: [] });
return jResp({});
};

beforeEach(() => {
global.fetch = vi.fn(async (url: string) => {
if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true });
if (url === "/api/settings/update-check")
return jResp({ has_updates: false, current_version: "1.0.0-beta.2", current_commit: "abc x" });
if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null });
if (url === "/api/settings/branches") return jResp({ branches: ["master", "dev"], current: "dev" });
if (url === "/api/settings/update-channel") return jResp({ status: "switching", branch: "master" });
return jResp({});
}) as any;
global.fetch = vi.fn(BASE_FETCH) as any;
});

describe("UpdatesPanel version display", () => {
describe("UpdatesPanel -- version display", () => {
it("shows current version prominently when up to date", async () => {
render(<UpdatesPanel />);
await waitFor(() => expect(screen.getByText("1.0.0-beta.2")).toBeInTheDocument());
Expand All @@ -34,6 +37,7 @@ describe("UpdatesPanel — version display", () => {
new_commit: "xyz uvwx",
});
if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null });
if (url === "/api/apps/optional/catalog") return jResp({ apps: [] });
return jResp({});
});
render(<UpdatesPanel />);
Expand All @@ -42,7 +46,7 @@ describe("UpdatesPanel — version display", () => {
});
});

describe("UpdatesPanel branch selector", () => {
describe("UpdatesPanel -- branch selector", () => {
it("hides the branch selector until Advanced is expanded", async () => {
render(<UpdatesPanel />);
expect(screen.queryByRole("combobox", { name: /branch/i })).toBeNull();
Expand All @@ -63,3 +67,47 @@ describe("UpdatesPanel — branch selector", () => {
);
});
});

describe("UpdatesPanel -- optional apps section", () => {
it("renders nothing in the Apps section when no optional apps are installed", async () => {
(global.fetch as any) = vi.fn(async (url: string) => {
if (url === "/api/apps/optional/catalog") return jResp({ apps: [
{ id: "reddit", version: "1.0.0", trust: "first-party", source: "core", installed: false, update_available: false },
]});
return jResp(await BASE_FETCH(url).then((r) => r.json()));
});
render(<UpdatesPanel />);
await waitFor(() => {
expect(screen.getByText("No optional apps installed.")).toBeInTheDocument();
});
});

it("renders an installed app row with its version and Core badge", async () => {
(global.fetch as any) = vi.fn(async (url: string) => {
if (url === "/api/apps/optional/catalog") return jResp({ apps: [
{ id: "reddit", version: "1.0.0", trust: "first-party", source: "core", installed: true, update_available: false },
]});
return jResp(await BASE_FETCH(url).then((r) => r.json()));
});
render(<UpdatesPanel />);
await waitFor(() => expect(screen.getByText("Reddit")).toBeInTheDocument());
expect(screen.getByText("1.0.0")).toBeInTheDocument();
expect(screen.getByText("Core")).toBeInTheDocument();
expect(screen.getByText("Up to date")).toBeInTheDocument();
});

it("shows update available copy when catalog flags it", async () => {
(global.fetch as any) = vi.fn(async (url: string) => {
if (url === "/api/apps/optional/catalog") return jResp({ apps: [
{ id: "reddit", version: "1.0.0", trust: "first-party", source: "core", installed: true, update_available: true },
]});
return jResp(await BASE_FETCH(url).then((r) => r.json()));
});
render(<UpdatesPanel />);
await waitFor(() =>
expect(screen.getByText(/Update available, included in the next system update/i)).toBeInTheDocument()
);
// Should have a button/link to trigger the system update flow.
expect(screen.getByRole("button", { name: /scroll to system update/i })).toBeInTheDocument();
});
});
118 changes: 115 additions & 3 deletions desktop/src/apps/SettingsApp/UpdatesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Settings, RefreshCw, AlertCircle, Check, ChevronDown, ChevronRight } from "lucide-react";
import { Settings, RefreshCw, AlertCircle, Check, ChevronDown, ChevronRight, Package } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { Button, Card, Label, Switch } from "@/components/ui";
import { RestartProgressModal } from "@/apps/SettingsApp/_shared";
import { getApp } from "@/registry/app-registry";

interface UpdateInfo {
has_updates: boolean;
Expand All @@ -27,6 +29,76 @@ interface BranchInfo {
current: string;
}

interface OptionalAppCatalogEntry {
id: string;
version: string;
trust: string;
source: "core" | string;
installed: boolean;
update_available: boolean;
}

// Render a lucide icon by kebab-case name (matches the format used in optional-apps registry).
function AppIconGlyph({ iconName, size = 16 }: { iconName: string; size?: number }) {
const pascal = iconName
.split("-")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
const Glyph = (LucideIcons[pascal as keyof typeof LucideIcons] as LucideIcons.LucideIcon) ?? Package;
return <Glyph size={size} />;
}

// A single optional app row in the Updates panel. Keyed by `source` so a
// future "package" source can add its own Update button without touching this
// component layout.
function OptionalAppRow({
entry,
displayName,
iconName,
onScrollToSystem,
}: {
entry: OptionalAppCatalogEntry;
displayName: string;
iconName: string;
onScrollToSystem: () => void;
}) {
return (
<div className="flex items-center gap-3 py-2.5">
<div className="w-8 h-8 rounded-lg bg-shell-surface-active flex items-center justify-center shrink-0 text-shell-text-secondary">
<AppIconGlyph iconName={iconName} size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-shell-text truncate">{displayName}</span>
<span className="font-mono text-[10px] text-shell-text-tertiary">{entry.version}</span>
{entry.source === "core" && (
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-shell-surface-active text-shell-text-tertiary border border-shell-border-strong">
Core
</span>
)}
</div>
{entry.source === "core" && entry.update_available ? (
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[11px] text-amber-300/80">
Update available, included in the next system update
</span>
<button
type="button"
onClick={onScrollToSystem}
className="text-[11px] text-accent underline underline-offset-2 hover:text-shell-text-secondary transition-colors"
aria-label="Scroll to system update"
>
Install now
</button>
</div>
) : (
<span className="text-[11px] text-shell-text-tertiary mt-0.5">Up to date</span>
)}
</div>
</div>
);
}

export function UpdatesPanel() {
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
Expand All @@ -36,6 +108,7 @@ export function UpdatesPanel() {
const [prefs, setPrefs] = useState<AutoUpdatePrefs>({ check_enabled: true });
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [showRestartModal, setShowRestartModal] = useState(false);
const [optionalCatalog, setOptionalCatalog] = useState<OptionalAppCatalogEntry[]>([]);

// Advanced / branch selector state
const [advancedOpen, setAdvancedOpen] = useState(false);
Expand All @@ -45,6 +118,7 @@ export function UpdatesPanel() {
const [showSwitchConfirm, setShowSwitchConfirm] = useState(false);
const branchFetched = useRef(false);
const confirmBtnRef = useRef<HTMLButtonElement>(null);
const systemCardRef = useRef<HTMLDivElement>(null);

// Focus management for the confirm dialog: move focus into the dialog when it
// opens and return it to the previously focused control when it closes, so
Expand Down Expand Up @@ -79,6 +153,13 @@ export function UpdatesPanel() {
const r3 = await fetch("/api/settings/update-status");
if (r3.ok) setUpdateStatus(await r3.json());
} catch { /* ignore */ }
try {
const r4 = await fetch("/api/apps/optional/catalog");
if (r4.ok) {
const data = await r4.json();
if (data && Array.isArray(data.apps)) setOptionalCatalog(data.apps);
}
} catch { /* ignore */ }
})();
}, []);

Expand Down Expand Up @@ -121,7 +202,7 @@ export function UpdatesPanel() {
try {
const res = await fetch("/api/settings/update", { method: "POST" });
if (res.ok) {
// Server always restarts after a successful install show the modal.
// Server always restarts after a successful install -- show the modal.
setShowRestartModal(true);
} else {
const err = await res.json().catch(() => ({}));
Expand Down Expand Up @@ -208,8 +289,16 @@ export function UpdatesPanel() {
setSwitching(false);
};

const scrollToSystem = useCallback(() => {
systemCardRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);

const hasPendingRestart = !!updateStatus?.pending_restart_sha;

// Installed optional apps; display name/icon come from the app registry
// (getApp covers every optional app, including the studios).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Backend/frontend optional app metadata can drift

The comment says getApp covers every optional app, but that guarantee lives outside this component. If the backend allowlist gains an optional app that is missing from the frontend registry, this will still render raw ids and generic icons via meta?.name ?? entry.id / meta?.icon ?? "package".

Consider keeping the backend allowlist and frontend registry synchronized through shared metadata or a startup/config validation check so this fallback is not silently relied on.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const installedOptional = optionalCatalog.filter((e) => e.installed);

return (
<section aria-label="System updates">
<h2 className="text-lg font-semibold mb-5">Updates</h2>
Expand All @@ -226,7 +315,7 @@ export function UpdatesPanel() {
</div>
)}

<Card className="p-4 space-y-4">
<Card ref={systemCardRef} className="p-4 space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-white/5 text-sky-400">
<Settings size={20} />
Expand Down Expand Up @@ -386,6 +475,29 @@ export function UpdatesPanel() {
</div>
</Card>

{/* Apps section: installed optional apps with version + source badge */}
<div className="mt-6">
<h3 className="text-sm font-semibold text-shell-text mb-3">Apps</h3>
{installedOptional.length === 0 ? (
<p className="text-[12px] text-shell-text-tertiary">No optional apps installed.</p>
) : (
<Card className="px-4 py-1 divide-y divide-shell-border">
{installedOptional.map((entry) => {
const meta = getApp(entry.id);
return (
<OptionalAppRow
key={entry.id}
entry={entry}
displayName={meta?.name ?? entry.id}
iconName={meta?.icon ?? "package"}
onScrollToSystem={scrollToSystem}
/>
);
})}
</Card>
)}
</div>

{/* Branch-switch confirm dialog */}
{showSwitchConfirm && (
<div
Expand Down
35 changes: 31 additions & 4 deletions desktop/src/apps/StoreApp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TaosAppsSection } from "./TaosAppsSection";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { MobileStore } from "./MobileStore";
import { AppIcon, StoreCover } from "./AppIcon";
import { getApp } from "@/registry/app-registry";
import { StudiosView } from "./StudiosView";

/* ------------------------------------------------------------------
Expand Down Expand Up @@ -841,6 +842,7 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) {
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const [selectedBackends, setSelectedBackends] = useState<string[]>([]);
const [compatMap, setCompatMap] = useState<Map<string, ResolveResponse>>(new Map());
const [optionalCatalog, setOptionalCatalog] = useState<Array<{ id: string; version: string; installed: boolean; update_available: boolean }>>([]);

const userId = typeof window !== "undefined" ? window.localStorage.getItem("taos.user.id") || "anon" : "anon";
const profileId = typeof window !== "undefined" ? window.localStorage.getItem("taos.profile.id") || "default" : "default";
Expand Down Expand Up @@ -928,6 +930,7 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) {
fetchLatestFrameworks().then(setLatest).catch(() => {});
fetch("/api/agents").then((r) => r.ok ? r.json() : []).then((j) => setAgentList(Array.isArray(j) ? j : (j?.agents ?? []))).catch(() => {});
fetch("/api/cluster/install-targets", { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { if (Array.isArray(data)) setInstallTargets(data); }).catch(() => {});
fetch("/api/apps/optional/catalog", { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { if (data?.apps) setOptionalCatalog(data.apps); }).catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Optional catalog is fetched only on mount

Optional app install/remove emits APP_OPTIONAL_CHANGED, but this component does not subscribe to that event. The Updates tab can therefore keep stale optionalCatalog state after installing or removing an optional app until the Store remounts.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard catalog payload shape before setting optionalCatalog.

Line 933 accepts any truthy data.apps. If it’s not an array, Line 1162 throws when calling .filter, breaking the Updates view.

Proposed fix
-    fetch("/api/apps/optional/catalog", { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { if (data?.apps) setOptionalCatalog(data.apps); }).catch(() => {});
+    fetch("/api/apps/optional/catalog", { headers: { Accept: "application/json" } })
+      .then((r) => (r.ok ? r.json() : null))
+      .then((data) => {
+        if (Array.isArray(data?.apps)) setOptionalCatalog(data.apps);
+      })
+      .catch(() => {});

Also applies to: 1162-1162

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src/apps/StoreApp/index.tsx` at line 933, The fetch call for the
optional catalog in the StoreApp component accepts any truthy value for
data.apps without validating its shape, which causes a crash when later code
calls .filter on it. Add validation to ensure data.apps is actually an array
before calling setOptionalCatalog, by checking both that data.apps exists and
that it is an Array (using Array.isArray or similar validation) in the promise
chain where setOptionalCatalog is currently invoked.

refreshInstalled();
}, [refreshInstalled]);

Expand Down Expand Up @@ -1148,16 +1151,40 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) {
<p className="text-[12px] text-shell-text-tertiary mt-0.5">{filtered.length} apps</p>
</div>
</div>
{activeNav === "updates" && (() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Optional app updates ignore the Updates tab search

When activeNav === "updates" and the user types a search query, this section still renders every installed optional app with an update, even if its name/id does not match the query. The header/count below ({filtered.length} apps) also excludes these optional rows, so search results can include non-matching optional updates and the count can look inconsistent.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const updatableOptional = optionalCatalog.filter((e) => e.installed && e.update_available);
if (updatableOptional.length === 0) return null;
return (
<div className="mb-4">
<h3 className="text-[13px] font-semibold text-shell-text mb-2">taOS Apps</h3>
<div className="flex flex-col gap-1">
{updatableOptional.map((entry) => {
const meta = getApp(entry.id);
return (
<div key={entry.id} className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-shell-surface border border-shell-border">
<span className="text-[12.5px] font-medium text-shell-text">{meta?.name ?? entry.id}</span>
<span className="font-mono text-[10px] text-shell-text-tertiary">{entry.version}</span>
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-shell-surface-active text-shell-text-tertiary border border-shell-border-strong">Core</span>
<span className="ml-auto text-[11px] text-amber-300/80">Included in next system update</span>
</div>
);
})}
</div>
</div>
);
})()}
{searching && filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-shell-text-tertiary text-sm gap-2">
<Package className="w-8 h-8" />
<span>No matches for &ldquo;{search.trim()}&rdquo;</span>
</div>
) : activeNav === "updates" && filtered.length === 0 ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Optional app updates are hidden when normal store updates exist

This branch only renders when filtered.length === 0. If there are any regular CatalogApp updates, the optional catalog section is skipped entirely, so updatable taOS apps will not surface in the Updates tab even though optionalCatalog has entries.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

<div className="flex flex-col items-center justify-center h-40 text-shell-text-tertiary text-sm gap-2">
<Package className="w-8 h-8" />
<span>You&rsquo;re all up to date</span>
</div>
optionalCatalog.some((e) => e.installed && e.update_available) ? null : (
<div className="flex flex-col items-center justify-center h-40 text-shell-text-tertiary text-sm gap-2">
<Package className="w-8 h-8" />
<span>You&rsquo;re all up to date</span>
</div>
)
) : activeNav === "installed" && filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-shell-text-tertiary text-sm gap-2">
<Package className="w-8 h-8" />
Expand Down
Loading
Loading