Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,27 @@ Third-party trademarks
================================================================================

The application identifies supported Agents in a row using either a generic
OneAgent UI symbol or a separately audited image asset. Generic Lucide symbols
are used for Aider and OpenClaw, so no website favicon is copied into the
release: neither has a mark in lobe-icons, and the only SVG of OpenClaw's logo
in circulation was drawn by an unrelated project, which conveys no right to
redistribute it. The four retained image assets, for Codex, Claude Code,
OpenCode, and Kilo CLI, are listed with source, license, owner, and SHA-256 in
OneAgent UI symbol or a separately audited image asset. A generic Lucide symbol
is used for Aider, which has no mark in lobe-icons, so no website favicon is
copied into the release. The five retained image assets are listed with source,
license, owner, and SHA-256 in
frontend/src/components/icons/asset-rights.json.

Four of those five -- for Codex, Claude Code, OpenCode, and Kilo CLI -- come
from lobe-icons and are redistributed unmodified. The fifth, for OpenClaw, is
not the vendor's own artwork: no official vector is published, so the mark is
the lobster drawn by the cc-switch project (MIT, Copyright (c) 2025 Jason
Young), and OneAgent has modified it. The red gradient, the coloured antenna
stroke, and the cyan eye highlights were removed so the mark is a single glyph
painted with fill="currentColor"; its geometry is unchanged. That modification
is recorded in the manifest entry, and the cc-switch license text travels with
it at frontend/src/components/icons/licenses/cc-switch/LICENSE.

The Agent marks described above are nominative use: the labels identify the
referenced products and do not imply endorsement, affiliation or sponsorship.
The image assets keep their published geometry and are not re-drawn. Each is
distributed as a single-colour glyph painted with fill="currentColor", so it
takes the surrounding text colour by design rather than carrying a brand colour
that is being altered.
Every mark is distributed as a single-colour glyph painted with
fill="currentColor", so it takes the surrounding text colour rather than
carrying a brand colour.

Sponsor marks are a separate case and are not nominative use. The PPIO and
Novita logos under docs/assets/sponsors/ are reproduced with each owner's
Expand Down
45 changes: 45 additions & 0 deletions frontend/e2e/wails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,51 @@ test("language selection switches to English and persists", async ({ page }) =>
await expect(page.getByRole("combobox", { name: "Language" })).toHaveValue("en");
});

// The task centre is position: fixed at the viewport's lower-left corner, so it
// takes no space in the sidebar's flex column. When the sidebar did not reserve
// room for it, the rows margin-top: auto pushes to the bottom ended up
// underneath: still rendered, still visible, still in the accessibility tree,
// with every click landing on the overlay. The language picker was the one that
// lost, because the theme row cleared it by 2px.
//
// This has to be an e2e test. jsdom reports every rect as 0x0, so a unit test
// cannot see the collision, and asserting display or visibility would have
// passed throughout -- both were correct the whole time.
test("every sidebar control at the bottom is actually clickable", async ({ page }) => {
// Both sidebar widths: 204px above the 900px breakpoint, and the 72px icon
// rail below it, where the task centre and the selects change size.
for (const viewport of [{ width: 1180, height: 760 }, { width: 860, height: 600 }]) {
await page.setViewportSize(viewport);
await page.goto("/#/overview");
const label = `${viewport.width}x${viewport.height}`;

const covered = await page.evaluate(() => {
const selectors = [".theme-picker select", ".language-select-wide", ".language-select-compact", ".task-center-trigger"];
const blocked: Array<{ selector: string; coveredBy: string }> = [];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (!element) continue;
const box = element.getBoundingClientRect();
// The sidebar swaps the wide and compact selects per breakpoint; the
// hidden one of the pair is not a failure.
if (box.width === 0 || box.height === 0) continue;
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
if (hit !== element && !element.contains(hit)) {
blocked.push({ selector, coveredBy: hit ? `${hit.tagName}.${hit.className}` : "nothing" });
}
}
return blocked;
});
expect(covered, `controls covered at ${label}`).toEqual([]);

// A real pointer click, not selectOption: the regression was that the
// element stayed reachable programmatically while being unreachable by
// pointer, so dispatching events directly would have passed.
await page.locator(".language-select-wide, .language-select-compact").locator("visible=true").click({ timeout: 2000 });
await page.keyboard.press("Escape");
}
});

test("a machine with no OneAgent state opens onboarding from the landing route", async ({ page }) => {
await page.goto("/#/");
await expect(page.getByRole("heading", { name: "选择命令行 Agent" })).toBeVisible();
Expand Down
45 changes: 40 additions & 5 deletions frontend/src/components/icons/agents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ 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", "opencode"]);
expect(assetIds.sort()).toEqual(["claude-code", "codex", "kilo-cli", "openclaw", "opencode"]);
for (const id of assetIds) {
const rights = agentMarkRights(id);
expect(agentMarkKind(id)).toBe("asset");
Expand Down Expand Up @@ -100,12 +100,47 @@ describe("AgentIcon", () => {
});

it("keeps the published geometry of every licensed mark", () => {
// The compliance note in agents.tsx states the marks are not re-drawn.
// The viewBox is the check: inlining must not rescale or crop the artwork.
for (const id of AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset")) {
// The compliance note in agents.tsx states the marks are not re-drawn. The
// viewBox is the check: inlining must not rescale or crop the artwork.
//
// Each mark is checked against its own source coordinate system rather than
// one shared value. The four vendor marks come from lobe-icons at 24x24;
// OpenClaw's is the cc-switch drawing at 120x120, and normalising it to 24
// would be the re-drawing this test exists to prevent. The recolouring
// recorded in asset-rights.json does not touch geometry.
const PUBLISHED_VIEWBOX: Record<string, string> = {
codex: "0 0 24 24",
opencode: "0 0 24 24",
"claude-code": "0 0 24 24",
"kilo-cli": "0 0 24 24",
openclaw: "0 0 120 120",
};
const assetIds = AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset");
// Guards the map itself: a new asset with no entry would otherwise be skipped
// rather than reported.
expect(Object.keys(PUBLISHED_VIEWBOX).sort()).toEqual([...assetIds].sort());
for (const id of assetIds) {
const { container } = render(<AgentIcon agentId={id} />);
const svg = container.querySelector('[data-mark-kind="asset"] svg');
expect(svg!.getAttribute("viewBox"), id).toBe("0 0 24 24");
expect(svg!.getAttribute("viewBox"), id).toBe(PUBLISHED_VIEWBOX[id]);
}
});

it("records that the one modified asset was modified", () => {
// MIT lets a copy be changed, but the change has to be stated. OpenClaw's
// mark was recoloured from a red gradient to currentColor, so shipping it as
// if it were untouched vendor artwork is the defect this catches -- in both
// directions, since claiming an unmodified mark was modified is also wrong.
for (const id of AGENT_ICON_IDS.filter((value) => agentMarkKind(value) === "asset")) {
const rights = agentMarkRights(id)!;
if (id === "openclaw") {
expect(rights.modified, id).toBe(true);
expect(rights.modificationNote, id).toMatch(/currentColor/);
// Not the vendor's own artwork, so the owner must not read as OpenClaw's.
expect(rights.copyrightOwner, id).toMatch(/cc-switch/);
} else {
expect("modified" in rights, `${id} should not claim a modification`).toBe(false);
}
}
});

Expand Down
57 changes: 39 additions & 18 deletions frontend/src/components/icons/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,27 @@
* Agent marks used to identify rows in OneAgent.
*
* Five image assets are shipped because their exact source, MIT license text,
* copyright owner, and SHA-256 are tracked in asset-rights.json. Agents whose
* published artwork does not have an auditable redistribution basis use generic
* Lucide marks instead; those marks identify a row without copying a vendor
* favicon into the release.
* copyright owner, and SHA-256 are tracked in asset-rights.json. An Agent with
* no such basis uses a generic Lucide mark instead, which identifies a row
* without copying a vendor favicon into the release.
*
* Trademark note: the labels identify which Agent a row refers to. The generic
* marks are OneAgent UI symbols, not vendor artwork. The licensed marks keep
* their published geometry unchanged; none is restyled or re-drawn. Each is
* distributed as a single-colour glyph painted with fill="currentColor", so it
* takes the surrounding text colour by design rather than carrying a brand
* colour we would be altering -- that is what makes them legible on both
* themes.
* marks are OneAgent UI symbols, not vendor artwork. Every mark is a
* single-colour glyph painted with fill="currentColor", so it takes the
* surrounding text colour rather than carrying a brand colour -- that is what
* makes the set legible on both themes.
*
* Four of the five are the vendors' own artwork, redistributed with their
* published geometry unchanged and not re-drawn. OpenClaw is the exception on
* both counts: no official vector exists, so the mark is the lobster the
* cc-switch project drew, and OneAgent recoloured it from a red gradient to a
* single currentColor glyph to match the set. Its geometry is still unchanged.
* asset-rights.json carries `modified: true` and the specifics, because MIT
* requires a modified copy to travel with its licence and state the change.
*/
import {
Bot,
GitBranch,
Waypoints,
type LucideIcon
} from "lucide-react";

Expand All @@ -28,9 +32,20 @@ import assetRightsManifest from "./asset-rights.json";
import claudeCodeMark from "./assets/claude-code.svg?raw";
import codexMark from "./assets/codex.svg?raw";
import kiloCliMark from "./assets/kilo-cli.svg?raw";
import openclawMark from "./assets/openclaw.svg?raw";
import opencodeMark from "./assets/opencode.svg?raw";

type AssetRights = (typeof assetRightsManifest.assets)[keyof typeof assetRightsManifest.assets];
/**
* The union of every manifest entry, widened so the two fields that exist only
* on a modified asset are readable without narrowing at each use. They stay
* optional: `modified` present and true is the claim, and its absence is the
* claim that the artwork is untouched -- a distinction agents.test.tsx asserts
* in both directions.
*/
type AssetRights = (typeof assetRightsManifest.assets)[keyof typeof assetRightsManifest.assets] & {
modified?: boolean;
modificationNote?: string;
};
type AssetMark = { kind: "asset"; markup: string; source: string; rights: AssetRights };
type GenericMark = { kind: "generic"; Icon: LucideIcon; source: string };
type Mark = AssetMark | GenericMark;
Expand Down Expand Up @@ -65,12 +80,18 @@ const MARKS: Record<string, Mark> = {
// 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 },
// OpenClaw's real logo is a lobster, and the only SVG version available is one
// CC Switch drew itself (see docs/internal/cc-switch-reference-notes.md) -- a
// third party's redrawing is not the vendor's artwork and gives OneAgent no
// right to redistribute it. lobe-icons has no OpenClaw mark either, so it takes
// a generic symbol until an official asset with a licence appears.
openclaw: { kind: "generic", Icon: Waypoints, source: GENERIC_SOURCE },
// Unlike the four above, this mark is not the vendor's own artwork: it is the
// lobster the cc-switch project drew, MIT, and OneAgent recoloured it to a
// single currentColor glyph so it adapts to the theme like the rest of the set.
// asset-rights.json records both facts, because MIT requires a modified copy to
// carry its licence and state the change. lobe-icons has no OpenClaw mark, and
// no official vector exists to prefer instead.
openclaw: {
kind: "asset",
markup: openclawMark,
source: assetRightsManifest.assets.openclaw.source,
rights: assetRightsManifest.assets.openclaw,
},
};

/** One-line positioning shown on hover; never a restatement of the name. */
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/icons/asset-rights.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
"licenseSource": "licenses/lobehub/LICENSE",
"copyrightOwner": "LobeHub contributors",
"sha256": "a2aef0e0bb992ebe768bfb8c3a3ab6ff2c2638448b09dd88db27877de1c07a22"
},
"openclaw": {
"file": "assets/openclaw.svg",
"source": "https://github.com/farion1231/cc-switch/blob/main/src/icons/extracted/claw.svg",
"license": "MIT",
"licenseSource": "licenses/cc-switch/LICENSE",
"copyrightOwner": "Jason Young (cc-switch contributors)",
"sha256": "9eac78bcfa8e106ed3267293235281743e161b5accf2ff5c676700b64cfcdbbc",
"modified": true,
"modificationNote": "Recoloured to a single currentColor glyph for theme adaptation: the linearGradient (#ff4d4d to #991b1b), the antenna stroke colour and the cyan eye highlights were removed, and the eyes became a mask cut-out. Geometry is unchanged. Not an official OpenClaw mark; drawn by the cc-switch project."
}
}
}
1 change: 1 addition & 0 deletions frontend/src/components/icons/assets/openclaw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions frontend/src/components/icons/licenses/cc-switch/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Jason Young

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
45 changes: 36 additions & 9 deletions frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
background: var(--window-bg);
}

/* The bottom padding clears the viewport-docked task centre. It is not spacing:
.task-center is position: fixed and contributes no height here, so without it
the theme and language rows -- pushed down by margin-top: auto -- sit behind
the overlay. They stay visible and focusable while every click lands on the
overlay instead, which reads as a control that vanished. */
.navigation-sidebar {
min-height: 0;
padding: 22px 12px 16px;
padding: 22px 12px calc(16px + var(--task-center-reserve));
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
Expand Down Expand Up @@ -123,13 +128,18 @@
flex: 1;
}

/* background-color, not the background shorthand: the shorthand resets the arrow
image base.css sets on every select. Same below for the language picker and
the Provider picker. */
.theme-picker select {
width: 82px;
min-width: 0;
padding: 5px 6px;
padding: 5px 22px 5px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--window-bg);
background-color: var(--window-bg);
background-position: right 5px center;
background-size: 11px 11px;
color: var(--text-primary);
font: inherit;
}
Expand All @@ -141,10 +151,12 @@
.language-picker select {
width: 82px;
min-width: 0;
padding: 5px 6px;
padding: 5px 22px 5px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--window-bg);
background-color: var(--window-bg);
background-position: right 5px center;
background-size: 11px 11px;
color: var(--text-primary);
font: inherit;
}
Expand Down Expand Up @@ -637,11 +649,11 @@
.provider-picker select {
width: 100%;
min-height: 42px;
padding: 0 12px;
padding: 0 30px 0 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text-primary);
background: var(--window-bg);
background-color: var(--window-bg);
}

.provider-picker select:focus {
Expand Down Expand Up @@ -1016,11 +1028,26 @@
.sidebar-link.is-active::before { left: -12px; }
.language-picker { padding: 0; }
.language-select-wide { display: none; }
.language-select-compact { display: block; width: 48px; padding-inline: 3px; font-size: 10px; }
.language-select-compact { display: block; }

/* Centre what is left, now that the flex: 1 spacer spans are gone. */
.theme-picker { padding: 0; justify-content: center; }
.theme-picker select { width: 48px; padding-inline: 3px; font-size: 10px; }

/* 48px of column has no room for both the value and the arrow, so the arrow
goes and the value keeps the width. Position and behaviour still identify
the control as a select, and its aria-label is unchanged.

Both selectors are (0,1,1), matching `.language-picker select` in the base
layer. Written as `.language-picker .language-select-compact` rather than the
bare class because that base rule would otherwise win on specificity and
keep the wide padding, leaving the arrow's 22px gap with no arrow in it. */
.language-picker .language-select-compact,
.theme-picker select {
width: 48px;
padding-inline: 3px;
font-size: 10px;
background-image: none;
}
}

@media (max-height: 760px) {
Expand Down
Loading
Loading