Skip to content
Merged
5 changes: 5 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
String, Text, event, false, true,
)

from sqlalchemy.orm import column_property

from app.database import Base
from app.timeutil import now_naive_utc

Expand Down Expand Up @@ -543,6 +545,9 @@ class App(Base):
# are small (~10-50KB at 512x512) and per-app — avoids needing a
# separate file store + cleanup path.
icon_png = Column(LargeBinary, nullable=True, default=None)
# Lightweight drawer/catalog projection. This selects only an IS NOT NULL
# boolean, so list_apps can advertise artwork without hydrating icon bytes.
has_custom_icon = column_property(icon_png.isnot(None))
# Absolute directory holding this app's source files. Editable app source lives
# under `/data/apps/<dirname>`. Stored explicitly so source apply can map a
# directory back to its DB row without slugify-guessing the name.
Expand Down
24 changes: 18 additions & 6 deletions backend/app/routes/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2155,15 +2155,27 @@ async def update_app(
app.capability_contract = contract_from_app_state(app)
db.commit()
db.refresh(app)
get_system_broadcast().publish(
{"type": "app_updated", "appId": str(app.id)}
# A pin toggle is drawer-local ORDERING, not a change to the app itself, so
# it must not ride the app_updated wire. Drag-reorder re-stamps every pinned
# app in sequence; one list-invalidating event per step would refetch the
# drawer repeatedly and visibly re-shuffle it mid-drop. Broadcast only when a
# meaningful app field actually changed (this also drops the stray "Preview
# updated ✓" flash a bare pin/unpin used to cause).
pin_only = body.pinned is not None and all(
field is None
for field in (
body.name, body.description, body.chat_id, body.share_with_apps,
body.cross_app_access, body.share_manifest_url, body.manage_skills,
)
)
if not pin_only:
get_system_broadcast().publish(
{"type": "app_updated", "appId": str(app.id)}
)
# The in-chat "Open <App>" CTA is DERIVED on the frontend from the apps
# query's chat_id + updated_at, so app_updated alone surfaces it in the
# owning chat. A metadata-only PATCH still bumps updated_at, so a
# pin/rename can flash "Preview updated ✓" — sanctioned (see
# chatRuntimeState.builtAppPulseDecision), the wire carries no source-only
# version key to gate on.
# owning chat. A metadata-only PATCH still bumps updated_at; the wire carries
# no source-only version key to gate on.
return app


Expand Down
4 changes: 2 additions & 2 deletions backend/app/routes/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,8 +890,8 @@ async def patch_chat(
chat.title = new_title
chat.title_locked = True

# Drawer pin toggle. We stamp the time on pin so the pinned group
# sorts newest-pinned-first within itself.
# Drawer pin toggle. We stamp the time on pin so the shared pinned group
# can append the newest pin after the items already there.
if body.pinned is not None:
chat.pinned_at = now_naive_utc() if body.pinned else None

Expand Down
3 changes: 3 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ class AppOut(BaseModel):
# Optional standalone PWA colors persisted from installed app manifests.
theme_color: str | None = None
background_color: str | None = None
# True when the raw app-icon endpoint can return stored artwork. Lets shell
# chrome avoid using expected 404 responses as an icon-existence probe.
has_custom_icon: bool = False
# Optional PWA display mode (web-manifest `display`). Null → "standalone".
display: str | None = None
# Offline contract from the manifest `offline` block (P1-D). None when no
Expand Down
7 changes: 6 additions & 1 deletion backend/tests/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,15 @@ def capture(_conn, _cursor, statement, _parameters, _context, _many):
event.remove(engine, "before_cursor_execute", capture)

assert response.status_code == 200
payload = response.json()
heavy = next(item for item in payload if item["id"] == app.id)
assert heavy["has_custom_icon"] is True
assert len(statements) == 1
projection = statements[0].split("FROM apps", 1)[0]
assert "apps.jsx_source" not in projection
assert "apps.icon_png" not in projection
# The projection may contain `icon_png IS NOT NULL`; it must never select the
# blob itself into an ORM attribute.
assert "apps.icon_png AS apps_icon_png" not in projection


def test_delete_then_purge_removes_non_slug_source_dir(client, auth, db):
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,15 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload),
}),
// Re-stamp a chat's pin time WITHOUT invalidating the shell list. Used by
// drag-reorder, which re-stamps every pinned row in sequence: the shared
// per-mutation invalidation would refetch the list N times mid-sequence and
// visibly re-shuffle it. The caller has already applied the final order
// optimistically, so no refetch is wanted.
repin: (chatId) => apiFetch(`/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify({ pinned: true }),
}),
remove: (chatId) => listAffectingMutation(
'chats', `/chats/${chatId}`, { method: 'DELETE' },
),
Expand All @@ -429,6 +438,13 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload),
}),
// Re-stamp an app's pin time WITHOUT invalidating the shell list — see
// chats.repin. Drag-reorder persists the whole pinned order this way so the
// list does not refetch-and-reshuffle on every step.
repin: (appId) => apiFetch(`/apps/${appId}`, {
method: 'PATCH',
body: JSON.stringify({ pinned: true }),
}),
remove: (appId) => listAffectingMutation(
'apps', `/apps/${appId}`, { method: 'DELETE' },
),
Expand Down
Loading