From 669593b646e0ba2dd6bbc32f492a734be1f5f123 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:55:29 +0530 Subject: [PATCH] feat(app): add New icons experiment --- packages/app/src/runtime/i18n/en.ts | 2 + .../settings/experimental/experimental.tsx | 11 ++ packages/app/src/settings/model.test.ts | 1 + packages/app/src/settings/model.tsx | 11 ++ packages/app/src/settings/search-catalog.ts | 7 + packages/gui-extensions/src/browser/panel.tsx | 4 +- packages/ui/src/icons/icon/icon.tsx | 35 +++- packages/ui/src/icons/new-icons.ts | 158 ++++++++++++++++++ 8 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/icons/new-icons.ts diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 724f82fb76b..1afeb7ebbee 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -770,6 +770,8 @@ export const dict = { "settings.experimental.description": "Try experimental features", "settings.experimental.row.generatedUI.title": "Generated UI", "settings.experimental.row.generatedUI.description": "Let agents show dashboards, charts and forms in the timeline", + "settings.experimental.row.newIcons.title": "New icons", + "settings.experimental.row.newIcons.description": "Preview the updated icon set", "settings.guiExtensions.title": "Extensions", "settings.guiExtensions.description": "Built-in GUI extensions in this window. Development builds only.", "settings.guiExtensions.reload": "Reload", diff --git a/packages/app/src/settings/experimental/experimental.tsx b/packages/app/src/settings/experimental/experimental.tsx index 03aff096b6a..fd47795e991 100644 --- a/packages/app/src/settings/experimental/experimental.tsx +++ b/packages/app/src/settings/experimental/experimental.tsx @@ -39,6 +39,17 @@ export const SettingsExperimental: Component = () => { /> + +
+ settings.general.setNewIcons(checked)} + /> +
+
diff --git a/packages/app/src/settings/model.test.ts b/packages/app/src/settings/model.test.ts index 49c6de167a8..eba8060e87a 100644 --- a/packages/app/src/settings/model.test.ts +++ b/packages/app/src/settings/model.test.ts @@ -95,6 +95,7 @@ describe("settings schema", () => { showTurnDiffs: false, backgroundActivity: "left", generatedUI: true, + newIcons: false, mobileTitlebarPosition: "top", followUpBehavior: "steer", }, diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index 7d9d38e441d..5d09bb3528c 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -3,6 +3,7 @@ import { createEffect, createMemo } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect" import { createSimpleContext } from "@opencode/ui/context" +import { setNewIcons } from "@opencode/ui/icon" import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode/session-ui/timeline/detail" import { persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" @@ -125,6 +126,8 @@ const generalSchema = Persistence.struct({ backgroundActivity: Schema.Literals(["off", "left", "center", "right"]), // Lets agents render generated UI in the timeline; an experiment users can turn off. generatedUI: Schema.Boolean, + // The "New icons" design experiment, off until it ships. + newIcons: Schema.Boolean, mobileTitlebarPosition: Schema.Literals(["top", "bottom"]), // Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first. mobileDiffWrap: Persistence.optional(Schema.Boolean), @@ -292,6 +295,7 @@ export const defaultSettings: Settings = { showTurnDiffs: false, backgroundActivity: "left", generatedUI: true, + newIcons: false, mobileTitlebarPosition: "top", followUpBehavior: "steer", }, @@ -337,6 +341,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined) }) + const newIcons = withFallback(() => store.general?.newIcons, defaultSettings.general.newIcons) + createEffect(() => setNewIcons(newIcons())) + const reduceMotion = withFallback(() => store.appearance?.reduceMotion, defaultSettings.appearance.reduceMotion) const systemReducedMotion = createMediaQuery("(prefers-reduced-motion: reduce)") @@ -385,6 +392,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setGeneratedUI(value: boolean) { setStore("general", "generatedUI", value) }, + newIcons, + setNewIcons(value: boolean) { + setStore("general", "newIcons", value) + }, mobileTitlebarPosition: withFallback( () => store.general?.mobileTitlebarPosition, defaultSettings.general.mobileTitlebarPosition, diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index fd631e2f1f4..b601dd92521 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -96,6 +96,13 @@ export const clientSettings: Entry[] = [ description: "settings.experimental.row.generatedUI.description", keywords: "genui generative interface dashboards charts forms", }, + { + tab: "experimental", + label: "settings.experimental.row.newIcons.title", + target: "settings-new-icons", + description: "settings.experimental.row.newIcons.description", + keywords: "icons iconography glyphs redesign", + }, { tab: "appearance", label: "settings.general.row.colorScheme.title", diff --git a/packages/gui-extensions/src/browser/panel.tsx b/packages/gui-extensions/src/browser/panel.tsx index aac710adcec..48a6d590227 100644 --- a/packages/gui-extensions/src/browser/panel.tsx +++ b/packages/gui-extensions/src/browser/panel.tsx @@ -1,6 +1,6 @@ import { Popover } from "@kobalte/core/popover" import { Button } from "@opencode/ui/button" -import { Icon } from "@opencode/ui/icon" +import { Icon, newIcons } from "@opencode/ui/icon" import type { IconName } from "@opencode/ui/icons/catalog" import { IconButton } from "@opencode/ui/icon-button" import { LineCommentEditor } from "@opencode/ui/line-comment" @@ -766,7 +766,7 @@ export default function SessionBrowserPane(props: { disabled={!reachable()} onPointerDown={(event) => event.preventDefault()} onClick={() => system.openExternal(address())} - icon={} + icon={} /> diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx index 1eaea65d174..30709fdb3e4 100644 --- a/packages/ui/src/icons/icon/icon.tsx +++ b/packages/ui/src/icons/icon/icon.tsx @@ -1,16 +1,34 @@ -import { onMount, type ComponentProps, splitProps } from "solid-js" +import { createSignal, onMount, type ComponentProps, splitProps } from "solid-js" import { iconNames, isIconName, getIcon, type IconName } from "../catalog" +import { getNewIcon, newIconViewBox } from "../new-icons" import "./icon.css" const spriteID = "opencode-v2-icon-sprite" const symbol = (name: IconName) => `opencode-v2-icon-${name}` +const newSymbol = (name: IconName) => `opencode-v2-icon-new-${name}` + let spriteInserted = false +// The "New icons" experiment, set by the app from its settings. +const [newIconsEnabled, setNewIconsEnabled] = createSignal(false) + +export function setNewIcons(enabled: boolean) { + setNewIconsEnabled(enabled) +} + +/** Whether the "New icons" experiment is on, for call sites that pick a different icon under it. */ +export const newIcons = newIconsEnabled + function spriteContent() { return iconNames() - .map((name) => `${getIcon(name).body}`) + .map((name) => { + const current = `${getIcon(name).body}` + const next = getNewIcon(name) + + return next ? `${current}${next}` : current + }) .join("") } @@ -53,7 +71,10 @@ export function Icon(props: IconProps) { } const icon = () => getIcon(iconName()) - const pixelSize = split.size === "small" ? 14 : split.size === "large" ? 20 : 16 + // Names without new artwork keep today's under the experiment. + const next = () => newIconsEnabled() && getNewIcon(iconName()) !== undefined + // New artwork is drawn on a 16px grid, so it stays at 16px where today's small icons shrink to 14px. + const pixelSize = () => (split.size === "large" ? 20 : split.size === "small" && !next() ? 14 : 16) onMount(ensureSprite) return ( @@ -71,14 +92,14 @@ export function Icon(props: IconProps) { ? "" : undefined } - width={pixelSize} - height={pixelSize} - viewBox={icon().viewBox} + width={pixelSize()} + height={pixelSize()} + viewBox={next() ? newIconViewBox : icon().viewBox} fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden={rest["aria-hidden"] ?? "true"} > - + ) } diff --git a/packages/ui/src/icons/new-icons.ts b/packages/ui/src/icons/new-icons.ts new file mode 100644 index 00000000000..a2dac032758 --- /dev/null +++ b/packages/ui/src/icons/new-icons.ts @@ -0,0 +1,158 @@ +import type { IconName } from "./catalog" + +// Artwork for the "New icons" experiment. Each body is the inner markup of a 16×16 SVG drawn with `currentColor`, +// such as ``. An empty body keeps today's artwork for every name it replaces. +export const newIcons = { + checkmark: ``, + "circle-checkmark": ``, + search: ``, + ellipsis: ``, + worktree: ``, + monitor: ``, + "monitor-dot": ``, + copy: ``, + code: ``, + box: ``, + lock: ``, + sliders: ``, + trash: ``, + cross: ``, + server: ``, + globe: ``, + archive: ``, + appearance: ``, + submit: ``, + reset: ``, + annotate: ``, + download: ``, + skill: ``, + "eye-open": ``, + "eye-closed": ``, + "chevron-up": ``, + "arrow-up": ``, + plus: ``, + minus: ``, + folder: ``, + "folder-plus": ``, + edit: ``, + "chat-bubble": ``, + calendar: ``, + extensions: ``, + settings: ``, + "square-arrow": ``, + "circle-ban": ``, + "window-analytics": ``, + "globe-plus": ``, + diff: ``, + providers: ``, +} + +export type NewIconName = keyof typeof newIcons + +export const newIconViewBox = "0 0 16 16" + +// The new artwork each current name draws, turned clockwise by `rotate` degrees for the other directions. +const replacements: Partial> = { + check: { name: "checkmark" }, + "check-small": { name: "checkmark" }, + "circle-check": { name: "circle-checkmark" }, + "magnifying-glass": { name: "search" }, + "outline-dots": { name: "ellipsis" }, + "outline-worktree": { name: "worktree" }, + "branch-out": { name: "worktree" }, + monitor: { name: "monitor" }, + notifications: { name: "monitor-dot" }, + copy: { name: "copy" }, + "outline-copy": { name: "copy" }, + code: { name: "code" }, + "code-slash": { name: "code" }, + cube: { name: "box" }, + lock: { name: "lock" }, + sliders: { name: "sliders" }, + "outline-sliders": { name: "sliders" }, + trash: { name: "trash" }, + "outline-trash": { name: "trash" }, + close: { name: "cross" }, + "close-small": { name: "cross" }, + "xmark-small": { name: "cross" }, + "outline-xmark": { name: "cross" }, + server: { name: "server" }, + globe: { name: "globe" }, + "outline-globe": { name: "globe" }, + archive: { name: "archive" }, + appearance: { name: "appearance" }, + enter: { name: "submit" }, + reset: { name: "reset" }, + "outline-reset": { name: "reset" }, + "outline-undo": { name: "reset" }, + "window-cursor": { name: "annotate" }, + "outline-browser-annotate": { name: "annotate" }, + "select-element": { name: "annotate" }, + download: { name: "download" }, + "post-skill": { name: "skill" }, + eye: { name: "eye-open" }, + "outline-eye": { name: "eye-open" }, + "outline-eye-slash": { name: "eye-closed" }, + "chevron-down": { name: "chevron-up", rotate: 180 }, + "outline-chevron-down": { name: "chevron-up", rotate: 180 }, + "chevron-left": { name: "chevron-up", rotate: 270 }, + "chevron-right": { name: "chevron-up", rotate: 90 }, + "outline-chevron-right": { name: "chevron-up", rotate: 90 }, + "arrow-up": { name: "arrow-up" }, + "arrow-left": { name: "arrow-up", rotate: 270 }, + "outline-arrow-left": { name: "arrow-up", rotate: 270 }, + "arrow-right": { name: "arrow-up", rotate: 90 }, + "outline-arrow-right": { name: "arrow-up", rotate: 90 }, + plus: { name: "plus" }, + "plus-small": { name: "plus" }, + minus: { name: "minus" }, + dash: { name: "minus" }, + folder: { name: "folder" }, + "file-tree": { name: "folder" }, + "folder-add-left": { name: "folder-plus" }, + edit: { name: "edit" }, + comment: { name: "chat-bubble" }, + "bubble-5": { name: "chat-bubble" }, + "speech-bubble": { name: "chat-bubble" }, + extensions: { name: "extensions" }, + "settings-gear": { name: "settings" }, + "outline-square-arrow": { name: "square-arrow" }, + "square-arrow-top-right": { name: "square-arrow" }, + "circle-ban-sign": { name: "circle-ban" }, + "window-analytics": { name: "window-analytics" }, + providers: { name: "providers" }, + review: { name: "diff" }, + "outline-globe-plus": { name: "globe-plus" }, +} + +// Still drawn with today's artwork under the experiment, awaiting a new design. Uses in the desktop app in brackets. +// Pairs on one line likely share a design. +// +// Status: circle-xmark (10) / circle-x (1), outline-hexagonal-warning (7) / warning (6), status / status-active (4), +// help (3), info (2) +// Features: terminal (6), mcp (5), branch (5, held back), console / outline-console (2), stop, models, +// link, share, brain / outline-brain, shield, graduation-cap +// Arrows: arrow-up-right (5) / outline-arrow-up-right (3), arrow-down-to-line (2), arrow-undo-down +// Layout: layout-left / layout-left-full (4), layout-right / layout-right-full (2), chevron-grabber-vertical (4), +// fill-triangle-down (3), menu (2), expand / collapse, split / unified, dot-grid +// Files: grid-plus (4), filetree, file-document (3), open-file (2), +// outline-quote (3), outline-cookie, outline-steer, outline-wave-audio, +// refresh / outline-rotate-clockwise +// Brand: discord +// Search: magnifying-glass-menu (only passed to BasicTool, which draws no icon) + +/** + * The new artwork a current name draws under the experiment, or undefined while it has none. + * @param name - A current catalog name. + */ +export function getNewIcon(name: IconName) { + const replacement = replacements[name] + + if (!replacement) return + + const body = newIcons[replacement.name] + + if (!body) return + + return replacement.rotate ? `${body}` : body +}