diff --git a/frontend/src/components/DesktopAppSection.test.tsx b/frontend/src/components/DesktopAppSection.test.tsx index 42de8dc..cf2676b 100644 --- a/frontend/src/components/DesktopAppSection.test.tsx +++ b/frontend/src/components/DesktopAppSection.test.tsx @@ -180,4 +180,27 @@ describe("DesktopAppSection", () => { expect(screen.queryByRole("heading", { name: "桌面 Agent" })).toBeNull(); }); + + // This component renders every desktop Agent, and it used to hardcode + // agentId="codex" for all of them. WorkBuddy is a Tencent product, so its card + // displayed OpenAI's mark -- a trademark problem rather than a styling one. + it("marks each desktop Agent with its own icon, not the first one's", () => { + const markOf = (value: DesktopAgentStatus) => { + const { container } = render( + + + , + ); + return container.querySelector(".desktop-app-icon")!.innerHTML; + }; + + const chatgpt = markOf(app({ id: "chatgpt-desktop", name: "ChatGPT Desktop" })); + const workbuddy = markOf(app({ id: "workbuddy", name: "WorkBuddy", profileAgentId: "workbuddy", protocol: "openai" })); + + expect(workbuddy).not.toBe(chatgpt); + // ChatGPT Desktop is OpenAI's own app, so a licensed asset is correct there. + expect(chatgpt).toContain('data-mark-kind="asset"'); + // WorkBuddy has no licensed mark, so the generic fallback is the right answer. + expect(workbuddy).toContain('data-mark-kind="fallback"'); + }); }); diff --git a/frontend/src/components/DesktopAppSection.tsx b/frontend/src/components/DesktopAppSection.tsx index 9b1771e..b005a6d 100644 --- a/frontend/src/components/DesktopAppSection.tsx +++ b/frontend/src/components/DesktopAppSection.tsx @@ -119,7 +119,12 @@ export function DesktopAppSection({ app: desktopApp, onChanged, onSetup, onConfi ) : null}
- + {/* 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,