feat(apps): per-app versioning + Updates UI surface (#89 P1)#967
Conversation
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
📝 WalkthroughWalkthroughAdds a ChangesOptional App Catalog Feature
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| ) : activeNav === "updates" && 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>You’re all up to date</span> | ||
| </div> | ||
| <> | ||
| {(() => { | ||
| const updatableOptional = optionalCatalog.filter((e) => e.installed && e.update_available); | ||
| return updatableOptional.length > 0 ? ( | ||
| <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 = OPTIONAL_APPS.find((a) => a.id === 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> |
There was a problem hiding this comment.
⚠️ Bug: Optional app updates hidden when other store updates exist
In StoreApp/index.tsx the updatable optional apps (taOS Apps section) are rendered only inside the branch activeNav === "updates" && filtered.length === 0. That branch is taken exclusively when there are no other updatable framework/store apps. As soon as filtered.length > 0 (i.e. there is at least one regular store/framework update), control falls through to the filtered.map(...) grid at the bottom, and the optional-app updates are never displayed.
This means a user who has both a framework update and an updatable optional (core) app will only see the framework update — the optional-app update silently disappears from the Updates tab. The catalog is fetched correctly, but the surface only appears in the empty state.
Suggested fix: render the updatableOptional section above the regular updates grid regardless of filtered.length, rather than nesting it inside the filtered.length === 0 empty-state branch.
Was this helpful? React with 👍 / 👎
| // Only show installed optional apps that have a matching registry entry for display metadata. | ||
| const installedOptional = optionalCatalog.filter((e) => e.installed); |
There was a problem hiding this comment.
⚠️ Bug: Studio apps render raw ids/generic icons; comment claims filtering
The backend optional_app_catalog returns all 9 allowlisted ids, including the five studios (coding-studio, design-studio, music-studio, app-studio, office-suite). However the frontend display registry OPTIONAL_APPS only contains 4 entries (reddit, youtube-library, github-browser, x-monitor).
In UpdatesPanel.tsx, the comment on line ~298 states: "Only show installed optional apps that have a matching registry entry for display metadata", but the actual code does not filter on registry membership — const installedOptional = optionalCatalog.filter((e) => e.installed). As a result an installed studio renders with its raw id as the display name (meta?.name ?? entry.id → e.g. "coding-studio") and the generic package icon (meta?.icon ?? "package"). The same mismatch exists in StoreApp/index.tsx's updates section (meta?.name ?? entry.id).
Suggested fix: either add the studio entries to the OPTIONAL_APPS registry so they have proper name/icon metadata, or actually filter the catalog to ids present in the registry (and make the comment truthful). Pick one consistently across both UpdatesPanel and StoreApp.
Was this helpful? React with 👍 / 👎
| def _semver_tuple(version: str) -> tuple[int, ...]: | ||
| """Parse a semver string into a comparable tuple of ints. | ||
|
|
||
| Strips leading 'v' and ignores pre-release/build suffixes for ordering. | ||
| Returns (0,) on any parse failure so comparisons degrade gracefully. | ||
| """ | ||
| v = version.lstrip("v").split("-")[0].split("+")[0] | ||
| try: | ||
| return tuple(int(p) for p in v.split(".")) | ||
| except ValueError: | ||
| return (0,) |
There was a problem hiding this comment.
💡 Edge Case: _semver_tuple mismatched-length comparison can falsely flag updates
_semver_tuple parses versions into variable-length tuples. Comparing tuples of unequal length can produce surprising results: e.g. a recorded version "1.0" becomes (1, 0) and the current "1.0.0" becomes (1, 0, 0); since (1, 0) < (1, 0, 0) is True, update_available would falsely flip true even though the versions are semantically equal. Also, a parse failure returns (0,), which compared against a valid (0, 9, 0) yields (0, 9, 0) < (0,) → False, masking a legitimate update.
Currently all APP_VERSIONS are uniform "1.0.0" so this is latent, but it will surface once versions diverge in length. Suggested fix: normalize both tuples to a fixed length (pad with zeros) before comparing, e.g. def _semver_tuple(v): parts = ...; return tuple((parts + (0,0,0))[:3]).
Was this helpful? React with 👍 / 👎
|
Note Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually. Code Review
|
| Compact |
|
Was this helpful? React with 👍 / 👎 | Gitar
| """ | ||
| v = version.lstrip("v").split("-")[0].split("+")[0] | ||
| try: | ||
| return tuple(int(p) for p in v.split(".")) |
There was a problem hiding this comment.
WARNING: Semver tuple comparison is length-sensitive
tuple(int(p) for p in v.split(".")) makes 1.0 compare as older than 1.0.0, so an app recorded as 1.0 would incorrectly show update_available=true against the current 1.0.0. Pad or normalize components before comparing.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| 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(() => {}); |
There was a problem hiding this comment.
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.
| @@ -1154,10 +1157,34 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) { | |||
| <span>No matches for “{search.trim()}”</span> | |||
| </div> | |||
| ) : activeNav === "updates" && filtered.length === 0 ? ( | |||
There was a problem hiding this comment.
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.
Code Review SummaryStatus: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Resolved since previous review
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (4 files)
Fix these issues in Kilo Cloud Previous Review Summary (commit bba39d8)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit bba39d8)Status: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (7 files)
Reviewed by nex-n2-pro:free · 717,044 tokens |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx (1)
77-77: ⚡ Quick winSimplify the fallback pattern.
The current fallback
return jResp(await BASE_FETCH(url).then((r) => r.json()));extracts data fromjRespand immediately re-wraps it. SinceBASE_FETCHalready returns the correct mock response shape, you can simplify this to:- return jResp(await BASE_FETCH(url).then((r) => r.json())); + return BASE_FETCH(url);This pattern appears in all three optional apps section tests (lines 77, 90, 104).
Also applies to: 90-90, 104-104
🤖 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/SettingsApp/UpdatesPanel.test.tsx` at line 77, The fallback pattern in UpdatesPanel.test.tsx at lines 77, 90, and 104 unnecessarily wraps the result of BASE_FETCH with jResp, which extracts and re-wraps the data. Since BASE_FETCH already returns the correct mock response shape, remove the jResp wrapper from all three locations and return the result of BASE_FETCH directly after awaiting and extracting the JSON response. This simplification eliminates the redundant extraction and re-wrapping operation across all three optional apps section tests.desktop/src/apps/SettingsApp/UpdatesPanel.tsx (1)
487-489: ⚡ Quick winKey optional app rows by
id + source, notidalone.Line 488 currently uses
entry.id, which will collide once non-core sources are introduced for the same app id.Proposed refactor
- key={entry.id} + key={`${entry.id}:${entry.source}`}🤖 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/SettingsApp/UpdatesPanel.tsx` around lines 487 - 489, The OptionalAppRow component in the render function is currently using only entry.id as the key prop, which will cause collisions when multiple sources provide the same app id. Modify the key prop on the OptionalAppRow component to combine both entry.id and entry.source (for example, using string concatenation or template literals) to ensure unique keys across different sources.desktop/src/apps/StoreApp/index.tsx (1)
845-845: ⚡ Quick winPreserve
sourcein state and use it in row keys.The backend returns
source, but local typing drops it and rows are keyed only byid. That makes future core/package dual entries for the same app id hard to render safely.Proposed refactor
- const [optionalCatalog, setOptionalCatalog] = useState<Array<{ id: string; version: string; installed: boolean; update_available: boolean }>>([]); + const [optionalCatalog, setOptionalCatalog] = useState<Array<{ + id: string; + source: string; + version: string; + installed: boolean; + update_available: boolean; + }>>([]); @@ - <div key={entry.id} className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-shell-surface border border-shell-border"> + <div key={`${entry.id}:${entry.source}`} className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-shell-surface border border-shell-border">Also applies to: 1170-1170
🤖 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 845, The optionalCatalog state type definition in StoreApp index.tsx is missing the source field that the backend returns, which prevents safely rendering dual entries for the same app id with different sources. Add source as a string property to the optionalCatalog array type definition and update any row key generation logic to use both id and source together instead of id alone, ensuring unique identification of each catalog entry when rendering lists or tables.tinyagentos/routes/apps.py (1)
245-257: ⚡ Quick winFail fast when allowlist/version/trust maps drift.
Using
.get(..., default)here hides missing config entries and silently assigns fallback version/trust. Since this endpoint is allowlist-driven, it’s safer to enforce parity and index directly.Proposed refactor
APP_TRUST: dict[str, str] = { @@ } + +_missing_versions = OPTIONAL_FRONTEND_APPS - set(APP_VERSIONS) +_missing_trust = OPTIONAL_FRONTEND_APPS - set(APP_TRUST) +if _missing_versions or _missing_trust: + raise RuntimeError( + f"Optional app catalog config drift: missing versions={sorted(_missing_versions)}, " + f"missing trust={sorted(_missing_trust)}" + ) @@ - current_version = APP_VERSIONS.get(app_id, "1.0.0") + current_version = APP_VERSIONS[app_id] @@ - "trust": APP_TRUST.get(app_id, "first-party"), + "trust": APP_TRUST[app_id], @@ - version=APP_VERSIONS.get(app_id, "1.0.0"), + version=APP_VERSIONS[app_id],Also applies to: 278-281
🤖 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 `@tinyagentos/routes/apps.py` around lines 245 - 257, Replace the defensive `.get()` calls with default values on the APP_VERSIONS and APP_TRUST dictionaries with direct dictionary indexing to enforce configuration parity and fail fast when an app_id is missing from these maps. Specifically, change `APP_VERSIONS.get(app_id, "1.0.0")` to direct indexing, and change `APP_TRUST.get(app_id, "first-party")` to direct indexing. Apply this same refactoring pattern to all other locations in the file where configuration maps are accessed with `.get()` and default values, ensuring that missing app_id entries will raise a KeyError rather than silently falling back to defaults.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@desktop/src/apps/StoreApp/index.tsx`:
- 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.
In `@tinyagentos/routes/apps.py`:
- Around line 65-75: The _semver_tuple function returns variable-length tuples
which causes incorrect version comparisons when tuples have different lengths
(e.g., (1, 0) compares as less than (1, 0, 0)). Modify the function to normalize
the returned tuple to a consistent width by padding shorter tuples with zeros on
the right side. This ensures semver versions like "1.0" and "1.0.0" are treated
as equal during comparisons and fixes the update_available logic for legacy
version strings.
---
Nitpick comments:
In `@desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx`:
- Line 77: The fallback pattern in UpdatesPanel.test.tsx at lines 77, 90, and
104 unnecessarily wraps the result of BASE_FETCH with jResp, which extracts and
re-wraps the data. Since BASE_FETCH already returns the correct mock response
shape, remove the jResp wrapper from all three locations and return the result
of BASE_FETCH directly after awaiting and extracting the JSON response. This
simplification eliminates the redundant extraction and re-wrapping operation
across all three optional apps section tests.
In `@desktop/src/apps/SettingsApp/UpdatesPanel.tsx`:
- Around line 487-489: The OptionalAppRow component in the render function is
currently using only entry.id as the key prop, which will cause collisions when
multiple sources provide the same app id. Modify the key prop on the
OptionalAppRow component to combine both entry.id and entry.source (for example,
using string concatenation or template literals) to ensure unique keys across
different sources.
In `@desktop/src/apps/StoreApp/index.tsx`:
- Line 845: The optionalCatalog state type definition in StoreApp index.tsx is
missing the source field that the backend returns, which prevents safely
rendering dual entries for the same app id with different sources. Add source as
a string property to the optionalCatalog array type definition and update any
row key generation logic to use both id and source together instead of id alone,
ensuring unique identification of each catalog entry when rendering lists or
tables.
In `@tinyagentos/routes/apps.py`:
- Around line 245-257: Replace the defensive `.get()` calls with default values
on the APP_VERSIONS and APP_TRUST dictionaries with direct dictionary indexing
to enforce configuration parity and fail fast when an app_id is missing from
these maps. Specifically, change `APP_VERSIONS.get(app_id, "1.0.0")` to direct
indexing, and change `APP_TRUST.get(app_id, "first-party")` to direct indexing.
Apply this same refactoring pattern to all other locations in the file where
configuration maps are accessed with `.get()` and default values, ensuring that
missing app_id entries will raise a KeyError rather than silently falling back
to defaults.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b1d8ed99-4407-4fc1-bed6-660ecc2c5dc1
⛔ Files ignored due to path filters (1)
desktop/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (6)
desktop/src/apps/SettingsApp/UpdatesPanel.test.tsxdesktop/src/apps/SettingsApp/UpdatesPanel.tsxdesktop/src/apps/StoreApp/index.tsxdesktop/src/apps/StoreApp/updates-optional.test.tsxtests/test_apps_installed.pytinyagentos/routes/apps.py
| 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(() => {}); |
There was a problem hiding this comment.
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.
- Store: hoist the taOS Apps updates section above the grid so optional-app updates show even when framework updates also exist (was nested in the filtered.length===0 empty-state branch and hidden otherwise) - name/icon for optional apps now come from the app registry (getApp), so the five studios resolve proper names/icons instead of raw ids + generic icon - _semver_tuple pads to (major,minor,patch) so '1.0' and '1.0.0' compare equal, and returns (0,0,0) on parse failure so it never masks a real update
| 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). |
There was a problem hiding this comment.
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.
| <p className="text-[12px] text-shell-text-tertiary mt-0.5">{filtered.length} apps</p> | ||
| </div> | ||
| </div> | ||
| {activeNav === "updates" && (() => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
desktop/src/apps/StoreApp/index.tsx (1)
1150-1155:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdates count under-reports when optional app updates exist.
Line 1151 shows
{filtered.length} apps, but optional updates are rendered fromoptionalCatalogseparately. In Updates, this can display0 appswhile listing entries under “taOS Apps.”Suggested fix
- <p className="text-[12px] text-shell-text-tertiary mt-0.5">{filtered.length} apps</p> + <p className="text-[12px] text-shell-text-tertiary mt-0.5"> + {activeNav === "updates" + ? filtered.length + optionalCatalog.filter((e) => e.installed && e.update_available).length + : filtered.length} apps + </p>🤖 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` around lines 1150 - 1155, The apps count displayed at line 1151 uses only `filtered.length`, which does not account for updatable optional apps when viewing the Updates section. When activeNav equals "updates", the count should include both the regular filtered updates and the updatable optional apps from the optionalCatalog filter. Modify the count expression to conditionally add the length of updatable optional apps (calculated from optionalCatalog.filter((e) => e.installed && e.update_available)) when in the updates view, so the displayed count accurately reflects all apps being rendered including both regular and optional updates.
🤖 Prompt for all review comments with 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.
Outside diff comments:
In `@desktop/src/apps/StoreApp/index.tsx`:
- Around line 1150-1155: The apps count displayed at line 1151 uses only
`filtered.length`, which does not account for updatable optional apps when
viewing the Updates section. When activeNav equals "updates", the count should
include both the regular filtered updates and the updatable optional apps from
the optionalCatalog filter. Modify the count expression to conditionally add the
length of updatable optional apps (calculated from optionalCatalog.filter((e) =>
e.installed && e.update_available)) when in the updates view, so the displayed
count accurately reflects all apps being rendered including both regular and
optional updates.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: cb646e78-248e-4159-b77f-c03dd82b6178
📒 Files selected for processing (3)
desktop/src/apps/SettingsApp/UpdatesPanel.tsxdesktop/src/apps/StoreApp/index.tsxtinyagentos/routes/apps.py
🚧 Files skipped from review as they are similar to previous changes (1)
- desktop/src/apps/SettingsApp/UpdatesPanel.tsx
Phase 1 of userspace app packages (#89, Track A "rails first"): make optional-app versions real and visible, structured for future independent per-app updates. No package format or dynamic code loading yet.
Backend (routes/apps.py)
Frontend
Tests
Backend: catalog shape, install records version, update_available flips on an older install row, allowlist isolation (5). Frontend: UpdatesPanel rows + empty state (3), Store updates tab (3). tsc clean, build green, 17 backend + 10 vitest pass locally.
Honest semantics
While the studios are in-core, their update path IS the system update. P1 surfaces versions + the row structure; it does not fake independent updates. Independent per-app updates land when studios become real .taosapp packages in later phases.
Part of #89.
Summary by CodeRabbit