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
2 changes: 2 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ const targets = [
sourcemap: true,
minify: false,
logLevel: "info",
loader: { ".svg": "text" },
},
// bottom-panel Run Inspector webview bundle
{
Expand All@@ -55,6 +56,7 @@ const targets = [
sourcemap: true,
minify: false,
logLevel: "info",
loader: { ".svg": "text" },
},
];

Expand Down
3 changes: 3 additions & 0 deletions packages/extension/media/amico-tab-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/extension/media/amico-tab-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 22 additions & 19 deletions packages/extension/media/amico.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/extension/media/amico_reduced.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 0 additions & 36 deletions packages/extension/media/ui/atoms/icon.ts

This file was deleted.

55 changes: 55 additions & 0 deletions packages/extension/media/ui/atoms/logo.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
// Logo atom — the Amico mark, fed by the two canonical SVG files.
//
// variant "full" (default) → media/amico.svg (detailed mark)
// variant "reduced" → media/amico_reduced.svg (outer bracket only,
// legible at small sizes)
//
// Both files are imported as raw text (esbuild loader {".svg":"text"}, see
// esbuild.config.mjs + the vitest transform) and carry fill="currentColor" on
// their <svg> root — so the mark's color is driven by the host element's CSS
// `color`, which defaults to `var(--vscode-foreground)` and therefore tracks
// the active VS Code theme. Pass `fill` to override (any CSS color, e.g. a
// brand token or another --vscode-* variable).
//
// The .svg files are the single source of truth for the geometry — no path
// data is duplicated as a TS literal (which is exactly what silently drifted
// before: a hardcoded copy here went stale when amico.svg was redesigned).

import { defineStyle } from "../style";
import fullSvg from "../../amico.svg";
import reducedSvg from "../../amico_reduced.svg";

defineStyle(
"logo",
`
.logo { display: inline-flex; align-items: center; color: var(--vscode-foreground); }
.logo svg { width: 18px; height: 18px; display: block; }
`,
);

export type LogoVariant = "full" | "reduced";

export interface LogoOptions {
/** Which mark to render. Defaults to "full" (amico.svg). */
variant?: LogoVariant;
/**
* CSS color for the mark. Omit to inherit `var(--vscode-foreground)` (tracks
* the VS Code theme); pass any CSS color to override.
*/
fill?: string;
}

// Strip a leading <?xml …?> declaration — amico.svg (exported from a design
// tool) carries one; injecting it into innerHTML leaves a stray bogus-comment
// node. amico_reduced.svg has none, so this is a no-op there.
const clean = (svg: string) => svg.replace(/^\s*<\?xml[^>]*\?>\s*/, "");

const SVG: Record<LogoVariant, string> = { full: clean(fullSvg), reduced: clean(reducedSvg) };

export function logo(opts: LogoOptions = {}): HTMLSpanElement {
const el = document.createElement("span");
el.className = "logo";
el.innerHTML = SVG[opts.variant ?? "full"]; // static, bundle-time-embedded markup — no user data
if (opts.fill) el.style.color = opts.fill; // else CSS default: var(--vscode-foreground)
return el;
}
7 changes: 7 additions & 0 deletions packages/extension/media/ui/svg.d.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
// Raw SVG text imports (esbuild loader: {".svg": "text"}, see esbuild.config.mjs)
// — lets icon components inject a canonical .svg file's markup directly
// instead of hand-duplicating its path data as a separate TS literal.
declare module "*.svg" {
const content: string;
export default content;
}
4 changes: 2 additions & 2 deletions packages/extension/media/ui/views/inspector.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
// reshape (freeze 2: the runId-keyed protocol, not the DOM).

import { defineStyle } from "../style";
import { mark } from "../atoms/icon";
import { logo } from "../atoms/logo";
import { pill } from "../atoms/pill";
import { text } from "../atoms/text";
import { button } from "../atoms/button";
Expand DownExpand Up@@ -73,7 +73,7 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel {

const brand = document.createElement("div");
brand.className = "row gap-sm brand";
brand.append(mark(), text("", "Run Inspector").el);
brand.append(logo({ variant: "reduced" }), text("", "Run Inspector").el);

const topbar = document.createElement("div");
topbar.className = "row wrap";
Expand Down
4 changes: 2 additions & 2 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,14 +30,14 @@
{
"id": "amicode",
"title": "Amicode",
"icon": "media/amico.svg"
"icon": "media/amico_reduced.svg"
}
],
"panel": [
{
"id": "amicode-panel",
"title": "Amicode",
"icon": "media/amico.svg"
"icon": "media/amico_reduced.svg"
}
]
},
Expand Down
22 changes: 21 additions & 1 deletion packages/extension/src/chat_panel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,26 @@ function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" {
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark";
}

// Theme-adaptive tab icon for the chat WebviewPanel. The `logo` atom
// (media/ui/atoms/logo.ts) themes via fill="currentColor", but that only
// resolves inside a live webview DOM — a native tab icon is a static image
// with no DOM, so currentColor renders dark (the bug we hit). VS Code's only
// theme-adaptive path for a native icon is a literal {light, dark} URI pair,
// and — verified empirically via a probe — the files MUST live inside the
// extension folder (an icon in globalStorageUri renders as nothing). Since a
// shipped .vsix's extension folder is read-only, the two colored files can't
// be generated at runtime; they're committed under media/, derived from
// amico_reduced.svg (small context → reduced) with currentColor swapped for a
// theme-appropriate foreground gray. A unit test keeps them in sync with the
// source geometry. `light` is shown on light themes (dark mark), `dark` on
// dark themes (light mark).
function tabIconPath(ctx: vscode.ExtensionContext): { light: vscode.Uri; dark: vscode.Uri } {
return {
light: vscode.Uri.joinPath(ctx.extensionUri, "media", "amico-tab-light.svg"),
dark: vscode.Uri.joinPath(ctx.extensionUri, "media", "amico-tab-dark.svg"),
};
}

export class ChatPanel {
private static current?: ChatPanel;
private readonly disposables: vscode.Disposable[] = [];
Expand DownExpand Up@@ -155,7 +175,7 @@ export class ChatPanel {
// itself — we only host one extension-local asset (the loading splash).
localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")],
});
panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "media", "amico.svg");
panel.iconPath = tabIconPath(ctx);
ChatPanel.current = new ChatPanel(panel, opencodeUrl);
return ChatPanel.current;
}
Expand Down
36 changes: 36 additions & 0 deletions packages/extension/test/chat_panel_icon.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";

// The chat WebviewPanel's native tab icon can't use the logo atom's
// fill="currentColor" theming (a native tab icon is a static image with no DOM
// to resolve currentColor against — it renders dark). VS Code's only
// theme-adaptive path is a committed {light, dark} pair inside the extension
// folder (an icon in globalStorageUri renders as nothing — verified via probe).
// These two files are DERIVED from media/amico_reduced.svg with the fill
// swapped; this test keeps them in lockstep with the source so a future edit
// to the mark can't silently leave the tab icons stale.

const mediaDir = join(__dirname, "..", "media");
const read = (f: string) => readFileSync(join(mediaDir, f), "utf8");
const pathD = (svg: string) => svg.match(/<path[^>]*\bd="([^"]+)"/)?.[1];

describe("chat tab icons — committed light/dark pair, in sync with amico_reduced.svg", () => {
const source = read("amico_reduced.svg");
const light = read("amico-tab-light.svg");
const dark = read("amico-tab-dark.svg");

it("carry theme-foreground fills, no leftover currentColor", () => {
expect(light).toContain('fill="#424242"'); // dark mark for light themes
expect(dark).toContain('fill="#CCCCCC"'); // light mark for dark themes
expect(light).not.toContain("currentColor");
expect(dark).not.toContain("currentColor");
});

it("share the exact geometry of the source reduced mark (no drift)", () => {
const d = pathD(source);
expect(d).toBeTruthy();
expect(pathD(light)).toBe(d);
expect(pathD(dark)).toBe(d);
});
});
53 changes: 53 additions & 0 deletions packages/extension/test/logo.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { logo } from "../media/ui/atoms/logo";

// Pins the logo atom's contract:
// - two variants, each derived from its real .svg file on disk (imported as
// raw text at build time — no path data duplicated as a TS literal, the
// exact thing that silently drifted before);
// - "full" (default) carries the detailed mark's internal accents,
// "reduced" is the bare outer bracket;
// - color is theme-responsive by default (fill="currentColor" on the root,
// host color unset → inherits .logo's var(--vscode-foreground)) and
// overridable via `fill`.

const mediaDir = join(__dirname, "..", "media");
const outerPathD = (svgSource: string) => svgSource.match(/<path[^>]*\bd="([^"]+)"/)?.[1];

describe("logo() atom", () => {
it("defaults to the full variant — the detailed mark from amico.svg", () => {
const svg = logo().querySelector("svg")!;
expect(svg).not.toBeNull();

const d = outerPathD(readFileSync(join(mediaDir, "amico.svg"), "utf8"));
expect(d).toBeTruthy();
expect(svg.querySelector("path")?.getAttribute("d")).toBe(d);
// The full mark carries internal circuit-pattern accents (rect/polygon);
// their presence is what distinguishes it from the reduced variant.
expect(svg.querySelectorAll("rect, polygon").length).toBeGreaterThan(0);
});

it('variant "reduced" is the bare bracket from amico_reduced.svg — no accents', () => {
const svg = logo({ variant: "reduced" }).querySelector("svg")!;

const d = outerPathD(readFileSync(join(mediaDir, "amico_reduced.svg"), "utf8"));
expect(d).toBeTruthy();
expect(svg.querySelector("path")?.getAttribute("d")).toBe(d);
expect(svg.querySelectorAll("rect, polygon").length).toBe(0);
});

it("is theme-responsive by default: currentColor root, no inline color override", () => {
const el = logo();
expect(el.classList.contains("logo")).toBe(true); // .logo sets color: var(--vscode-foreground)
expect(el.style.color).toBe(""); // nothing hardcoded — inherits the theme var
expect(el.querySelector("svg")?.getAttribute("fill")).toBe("currentColor");
});

it("fill overrides the host color that drives the mark", () => {
const el = logo({ fill: "var(--vscode-button-foreground)" });
expect(el.style.color).toBe("var(--vscode-button-foreground)");
});
});
14 changes: 14 additions & 0 deletions packages/extension/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
import fs from "node:fs";

// Alias the `vscode` module to a minimal stub so node-side modules that import it
// (file_watcher.ts, etc.) can be unit-tested without the VS Code host. Only kicks
Expand All@@ -8,4 +9,17 @@ export default defineConfig({
resolve: {
alias: { vscode: path.resolve(process.cwd(), "test/__mocks__/vscode.ts") },
},
plugins: [
// Match esbuild.config.mjs's `loader: {".svg": "text"}`: Vite's own default
// .svg handling returns a URL string, not raw markup, which would make
// icon.ts's canonical-file import silently no-op under test (a broken
// string assigned to innerHTML) while still passing under the real build.
{
name: "svg-as-raw-text",
transform(_code: string, id: string) {
if (!id.endsWith(".svg")) return undefined;
return `export default ${JSON.stringify(fs.readFileSync(id, "utf8"))};`;
},
},
],
});
Loading