[eric] outputs: an app's icon is one emoji (ported from eric/feature-requests, ENG-425); Material icon names were stored and never rendered, so every tile fell back to the grid

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-03 17:09:25 -07:00
co-authored by Claude Fable 5.1
parent da1d6bd95e
commit aadcf45398
9 changed files with 137 additions and 8 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"",
f"NEXT: read {path}/SKILL.md now — it's the full App Builder reference (stack, layout, rules); follow it.",
"Then build by writing files under the workspace path; the preview hot-reloads on save.",
"Housekeeping: write meta.json (name/description) first; `bash restart.sh` restarts the runtime; `.openswarm/terminal.log` is the live terminal (check it before declaring done).",
"Housekeeping: write meta.json (name, description, and icon: ONE emoji that stands for the app, shown in the dock and the Applications window) first; `bash restart.sh` restarts the runtime; `.openswarm/terminal.log` is the live terminal (check it before declaring done).",
]
return {"content": [{"type": "text", "text": "\n".join(lines)}]}
+4 -2
View File
@@ -559,7 +559,7 @@ Common deps already in the template:
- **Edits are auto-saved**. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms.
- **`bash restart.sh` restarts the app runtime yourself** — backend + vite, no user action needed. Use it after `bash backend_init.sh`, after editing `.env`, or whenever backend code must reload (uvicorn runs WITHOUT --reload, so backend edits do NOT hot-apply). Never ask the user to restart for you, and never try to kill/rerun run.sh — the harness owns the process. If `restart.sh` is missing (older app), `mkdir -p .openswarm && touch .openswarm/restart-requested` does the same thing.
- After a restart, wait a few seconds and check `.openswarm/terminal.log` to confirm the boot looked clean.
- **`meta.json`** at workspace root drives the app's name + description in the OpenSwarm sidebar and on the app's live card on the dashboard. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
- **`meta.json`** at workspace root drives the app's name, description and icon (one emoji) in the OpenSwarm dock, the Applications window and the app's live card on the dashboard. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
---
@@ -638,8 +638,10 @@ When making a new app from scratch:
The sidebar and the app's dashboard card show this name to the user; until
you write it, both surfaces sit at "Untitled App". Don't wait until the end of
the turn to fill it in, pick a name from the user's prompt and ship it now.
Add `icon`: ONE emoji that stands for the app (it becomes the app's mark in the dock and the
Applications window; words and icon names render as the generic grid, never as a letter).
Example: prompt "make doodle jump" → `{"name": "Doodle Jumper", "description":
"Endless platform-hopper inspired by Doodle Jump."}`. You can revise it later
"Endless platform-hopper inspired by Doodle Jump.", "icon": "🦘"}`. You can revise it later
if the app's purpose shifts.
2. **REPLACE** `frontend/src/pages/index.tsx`. The starter ships with a
"Brewing your app" placeholder — this is intentional, it's what the user
+20
View File
@@ -0,0 +1,20 @@
"""One rule for what counts as an app icon, shared by the meta.json sync and the API.
An icon is a single emoji symbol. The model's default ("view_quilt") is a Material icon NAME
that nothing renders, so names and words are rejected rather than stored; the renderer's
`appIconGlyph` applies the same rule, so the two can never disagree about a value.
"""
from __future__ import annotations
import re
WORDISH = re.compile(r"[\w]", re.UNICODE)
def glyph_icon(value: object) -> str | None:
if not isinstance(value, str):
return None
g = value.strip()
if not g or len(g) > 4:
return None
return None if WORDISH.search(g) else g
+18 -4
View File
@@ -19,6 +19,7 @@ from backend.apps.outputs.models import (
)
from backend.apps.outputs.code_safety import get_code_warnings
from backend.apps.outputs.executor import execute_backend_code
from backend.apps.outputs.app_icon import glyph_icon
from backend.apps.outputs.publish_capability import check_publish_capability
from backend.apps.outputs.publish_common import slugify, PublishError
from backend.apps.outputs.publish_scan import scan_for_publish, quick_ast_gate
@@ -217,13 +218,15 @@ def write_meta_json_fields(workspace_id: str, fields: dict) -> bool:
def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = None) -> bool:
"""Sync the Output row's name/description from meta.json (or fallback_name when
meta.json has no name). Only overwrites placeholder values; user renames win."""
"""Sync the Output row's name/description/icon from meta.json (or fallback_name when
meta.json has no name). Name and description only overwrite placeholder values (user renames
win); the icon is the agent's channel, so a new emoji there always lands."""
try:
folder = os.path.join(WORKSPACE_DIR, workspace_id)
meta_path = os.path.join(folder, "meta.json")
name = ""
description = ""
icon = None
if os.path.exists(meta_path):
try:
with open(meta_path) as f:
@@ -231,11 +234,12 @@ def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = No
if isinstance(meta, dict):
name = str(meta.get("name") or "").strip()
description = str(meta.get("description") or "").strip()
icon = glyph_icon(meta.get("icon"))
except (OSError, json.JSONDecodeError, ValueError):
pass
if not name and fallback_name:
name = str(fallback_name).strip()
if not name and not description:
if not name and not description and not icon:
return False
matching = [o for o in load_all() if o.workspace_id == workspace_id]
if not matching:
@@ -248,6 +252,9 @@ def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = No
if description and not output.description and output.description != description:
output.description = description
changed = True
if icon and output.icon != icon:
output.icon = icon
changed = True
if changed:
output.updated_at = datetime.now().isoformat()
save(output)
@@ -685,9 +692,16 @@ async def update_output(output_id: str, body: OutputUpdate):
save(output)
# A rename must reach meta.json too, or the UI and the file agents read drift apart (ENG-308).
p_sent = body.model_dump(exclude_unset=True)
p_meta = {k: getattr(output, k) for k in ("name", "description") if k in p_sent and getattr(output, k)}
p_meta = {k: getattr(output, k) for k in ("name", "description", "icon") if k in p_sent and getattr(output, k)}
if p_meta:
write_meta_json_fields(getattr(output, "workspace_id", "") or "", p_meta)
# The dock and the Applications window hold their own copy of the row; without this an icon or
# name an agent set through the API stayed on the old glyph until the next full fetch.
from backend.apps.agents.core.ws_manager import ws_manager
try:
await ws_manager.broadcast_global("agent:output_upserted", {"output": output.model_dump(mode="json")})
except Exception:
logger.exception("output update broadcast failed for %s", output_id)
return {"ok": True, "output": output.model_dump()}
+14
View File
@@ -0,0 +1,14 @@
from backend.apps.outputs.app_icon import glyph_icon
def test_an_emoji_is_an_icon_and_names_words_and_initials_are_not():
assert glyph_icon("🚀") == "🚀"
assert glyph_icon(" 🇫🇷 ") == "🇫🇷"
assert glyph_icon("👨‍💻") == "👨‍💻"
assert glyph_icon("view_quilt") is None
assert glyph_icon("rocket") is None
assert glyph_icon("A") is None
assert glyph_icon("42") is None
assert glyph_icon("") is None
assert glyph_icon(None) is None
assert glyph_icon({"emoji": "🚀"}) is None
+50
View File
@@ -0,0 +1,50 @@
"""meta.json is the agent's channel for an app's icon: a new emoji there lands on the row, a word does not."""
import json
import os
import uuid
from backend.apps.outputs import outputs as outputs_mod
from backend.apps.outputs.models import Output
from backend.apps.outputs.outputs import WORKSPACE_DIR, sync_output_from_meta_json
def workspace_with_meta(meta: dict) -> str:
ws = "icontest-" + uuid.uuid4().hex[:8]
folder = os.path.join(WORKSPACE_DIR, ws)
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, "meta.json"), "w") as f:
json.dump(meta, f)
return ws
def make_row(ws: str, name: str = "Doodle Jumper") -> Output:
return Output(id=uuid.uuid4().hex, name=name, workspace_id=ws)
def test_an_emoji_in_meta_json_lands_on_the_row_and_is_broadcast_worthy(monkeypatch):
ws = workspace_with_meta({"name": "Doodle Jumper", "icon": "🦘"})
row = make_row(ws)
saved = []
monkeypatch.setattr(outputs_mod, "load_all", lambda: [row])
monkeypatch.setattr(outputs_mod, "save", lambda o: saved.append(o.icon))
assert sync_output_from_meta_json(ws) is True
assert row.icon == "🦘"
assert saved == ["🦘"]
def test_a_word_or_icon_name_in_meta_json_changes_nothing(monkeypatch):
ws = workspace_with_meta({"name": "Doodle Jumper", "icon": "rocket"})
row = make_row(ws)
monkeypatch.setattr(outputs_mod, "load_all", lambda: [row])
monkeypatch.setattr(outputs_mod, "save", lambda o: (_ for _ in ()).throw(AssertionError("must not save")))
assert sync_output_from_meta_json(ws) is False
assert row.icon == "view_quilt"
def test_the_same_emoji_again_is_a_no_op(monkeypatch):
ws = workspace_with_meta({"name": "Doodle Jumper", "icon": "🦘"})
row = make_row(ws)
row.icon = "🦘"
monkeypatch.setattr(outputs_mod, "load_all", lambda: [row])
monkeypatch.setattr(outputs_mod, "save", lambda o: (_ for _ in ()).throw(AssertionError("must not save")))
assert sync_output_from_meta_json(ws) is False
@@ -4,6 +4,7 @@ import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import { MessageCircle } from 'lucide-react';
import { pickIcon } from '../canvas/DashboardGlyph';
import { appIconGlyph } from '@/shared/appIconGlyph';
import { displayChatTitle } from '@/shared/state/sessionDisplay';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
@@ -102,7 +103,9 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor
rect: vc,
tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)',
// Never letters in the dock: a real symbol reads as an app, a glyph initial reads as a bug.
icon: <GridViewRoundedIcon sx={{ fontSize: 16, color: '#fff' }} />,
icon: appIconGlyph(output?.icon)
? <span style={{ fontSize: 16, lineHeight: 1 }}>{appIconGlyph(output?.icon)}</span>
: <GridViewRoundedIcon sx={{ fontSize: 16, color: '#fff' }} />,
thumbnail: output?.thumbnail,
});
}
+16
View File
@@ -0,0 +1,16 @@
// Run: npm test (frontend/scripts/run-tests.mjs)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { appIconGlyph } from './appIconGlyph.ts';
test('an emoji is an icon; the stored default, words, initials and empties are not', () => {
assert.equal(appIconGlyph('🚀'), '🚀');
assert.equal(appIconGlyph(' 🇫🇷 '), '🇫🇷');
assert.equal(appIconGlyph('👨‍💻'), '👨‍💻');
assert.equal(appIconGlyph('view_quilt'), null);
assert.equal(appIconGlyph('rocket'), null);
assert.equal(appIconGlyph('A'), null);
assert.equal(appIconGlyph('42'), null);
assert.equal(appIconGlyph(''), null);
assert.equal(appIconGlyph(undefined), null);
});
+10
View File
@@ -0,0 +1,10 @@
/**
* An app's icon is one emoji symbol or nothing. The stored default ("view_quilt") is a Material
* icon NAME that no surface ever rendered, so names and words count as "no icon" and the surface
* falls back to its generic mark (never a letter: an initial on a tile reads as a bug).
*/
export function appIconGlyph(icon: string | null | undefined): string | null {
const g = (icon || '').trim();
if (!g || [...g].length > 4) return null;
return /[\p{L}\p{N}_]/u.test(g) ? null : g;
}