-
+ {/* The Agent's own id, never a literal. This rendered agentId="codex"
+ for every desktop Agent, so WorkBuddy -- a different vendor's
+ product -- displayed OpenAI's mark. A literal also bypasses
+ AgentIcon's fallback, which is what handles an Agent that has no
+ mark of its own. */}
+
{desktopApp.name}
diff --git a/frontend/src/components/icons/agents.test.tsx b/frontend/src/components/icons/agents.test.tsx
index 64df46a..0630f1a 100644
--- a/frontend/src/components/icons/agents.test.tsx
+++ b/frontend/src/components/icons/agents.test.tsx
@@ -28,7 +28,9 @@ describe("AgentIcon", () => {
// set rather than one example is what makes an unregistered mark fail here:
// shipping artwork without a source, licence and hash is the defect.
const assetIds = AGENT_ICON_IDS.filter((id) => agentMarkKind(id) === "asset");
- expect(assetIds.sort()).toEqual(["claude-code", "codex", "kilo-cli", "openclaw", "opencode"]);
+ // chatgpt-desktop is a desktop Agent rather than a CLI, and it reuses the
+ // OpenAI mark because it is OpenAI's own product sharing Codex's config.
+ expect(assetIds.sort()).toEqual(["chatgpt-desktop", "claude-code", "codex", "kilo-cli", "openclaw", "opencode"]);
for (const id of assetIds) {
const rights = agentMarkRights(id);
expect(agentMarkKind(id)).toBe("asset");
@@ -110,6 +112,7 @@ describe("AgentIcon", () => {
// recorded in asset-rights.json does not touch geometry.
const PUBLISHED_VIEWBOX: Record
= {
codex: "0 0 24 24",
+ "chatgpt-desktop": "0 0 24 24",
opencode: "0 0 24 24",
"claude-code": "0 0 24 24",
"kilo-cli": "0 0 24 24",
@@ -144,6 +147,30 @@ describe("AgentIcon", () => {
}
});
+ it("gives a desktop Agent its own mark rather than another vendor's", () => {
+ // The desktop card used to pass a literal agentId="codex" for every desktop
+ // Agent, so WorkBuddy -- a Tencent product -- rendered OpenAI's mark. Reusing
+ // one vendor's artwork for another vendor's product is a trademark problem,
+ // not a cosmetic one, so each case is asserted separately.
+ //
+ // ChatGPT Desktop is the one legitimate reuse: it is OpenAI's own app and
+ // shares Codex's configuration, so it renders the same OpenAI mark.
+ const { container: chatgpt } = render();
+ const { container: codex } = render();
+ expect(chatgpt.innerHTML).toBe(codex.innerHTML);
+
+ // WorkBuddy has no licensed mark, so it must fall back to the generic symbol
+ // and must not borrow one that belongs to somebody else.
+ const { container: workbuddy } = render();
+ expect(agentMarkKind("workbuddy")).toBe("fallback");
+ expect(workbuddy.querySelector('[data-mark-kind="fallback"]')).not.toBeNull();
+ expect(workbuddy.innerHTML).not.toBe(codex.innerHTML);
+ for (const id of AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset")) {
+ const { container } = render();
+ expect(workbuddy.innerHTML, `workbuddy must not reuse the ${id} mark`).not.toBe(container.innerHTML);
+ }
+ });
+
it("offers a tagline for hover, distinct from the name", () => {
for (const id of ALL) {
const tagline = agentTagline(id);
diff --git a/frontend/src/components/icons/agents.tsx b/frontend/src/components/icons/agents.tsx
index 4efb1c3..285bdbe 100644
--- a/frontend/src/components/icons/agents.tsx
+++ b/frontend/src/components/icons/agents.tsx
@@ -77,6 +77,16 @@ const MARKS: Record = {
source: assetRightsManifest.assets["kilo-cli"].source,
rights: assetRightsManifest.assets["kilo-cli"],
},
+ // ChatGPT Desktop is OpenAI's own product and shares Codex's configuration, so
+ // it reuses the same OpenAI mark rather than registering a second copy of one
+ // asset. Keyed by desktop Agent id because the desktop card looks itself up by
+ // id; passing a literal here is what put this mark on WorkBuddy.
+ "chatgpt-desktop": {
+ kind: "asset",
+ markup: codexMark,
+ source: assetRightsManifest.assets.codex.source,
+ rights: assetRightsManifest.assets.codex,
+ },
// Aider has no mark in lobe-icons, so it keeps a generic symbol rather than a
// vendor favicon copied in without an auditable redistribution basis.
aider: { kind: "generic", Icon: GitBranch, source: GENERIC_SOURCE },
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index c8fce5a..24d16b5 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -1637,11 +1637,23 @@
.icon-button:hover { color: var(--text-primary); background: var(--surface-pressed); }
.icon-button.is-danger:hover { color: var(--red); background: var(--red-soft); }
+/* auto-fill rather than a media query: the pane width already varies with the
+ sidebar, which itself collapses at two breakpoints, so enumerating column
+ counts would mean tracking both. 340px is the floor at which a card still fits
+ its 92px endpoint label plus a readable value, so below roughly 790px of pane
+ this becomes one column on its own.
+
+ align-items: start keeps a card at its natural height. Without it grid
+ stretches every card in a row to match the tallest, and card heights genuinely
+ differ here: the Anthropic endpoint row only renders when that endpoint is
+ set. */
.provider-list,
.profile-list {
width: 100%;
display: grid;
- gap: 8px;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ align-items: start;
+ gap: 10px;
}
.provider-card,