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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Cross-platform development packaging for macOS arm64, Windows x64, and Linux x64.
- Projects, managed worktrees, file editing, Git changes, terminals, multi-tab pi Agent chat, settings, and support for user-installed standard Pi resources.

### Changed

- Refined the workbench with quieter neutral chrome, a narrower Chat reading column, accessible icon tooltips, and a System appearance option that follows the operating system theme.

### Removed

- **Breaking:** SpireCode no longer depends on or bundles packages from `github:klioen/pi-extensions`. Web access, subagents, memory, todo, plan, goal, and SDLC resources must now be installed and declared by users through standard Pi settings.
Expand Down
27 changes: 27 additions & 0 deletions docs/orca-inspired-p0/change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Orca-inspired P0 UI refinement

## Intent

Make SpireCode's shell quieter and more content-first while preserving its existing Project → Worktree → Chat/Editor/Changes product model and green brand identity. Improve theme behavior, chat readability, and discoverability of icon-only controls without adopting Orca's feature density or changing desktop security boundaries.

Risk classification: standard change because it crosses theme, workbench, chat, settings, and shared UI components and changes visible behavior, but it does not change IPC contracts, persisted data schemas, filesystem access, or command execution.

## Spec

- Use neutral light/dark chrome surfaces; reserve brand green for focus, primary action, running state, and the logo.
- Preserve semantic danger, warning, success, Git, Monaco, and terminal colors with accessible contrast.
- Reduce chat reading width and establish a compact prose/metadata/code type scale without adding user-facing appearance knobs.
- Restore `system` as a persisted theme preference in Settings. The top-bar theme button remains a two-state light/dark shortcut and never exposes a third computer icon.
- Follow live `prefers-color-scheme` changes only while the stored mode is `system`.
- Suppress transitions during theme swaps so surfaces change atomically.
- Add a reusable, keyboard-visible tooltip primitive and apply it to the primary icon-only workbench controls. Existing accessible names remain intact.
- Keep Project/Worktree breadcrumbs, panel arrangement, chat message chrome, and Renderer/Main security boundaries unchanged.

## Plan

1. Add regression tests for system theme persistence/resolution, two-state top-bar behavior under system mode, the Settings system option, and tooltip accessibility.
2. Implement system-aware theme state and localized Settings copy while keeping the top-bar shortcut explicit light/dark.
3. Add a zero-dependency tooltip primitive and use it for top-bar and right-panel icon actions.
4. Replace colored chrome tokens with neutral surfaces, introduce semantic interaction aliases, normalize focus/hover/selected/elevation, and tune chat width/type scale.
5. Run targeted tests, formatting, lint, typecheck, the full `pnpm check` gate, and inspect the final diff for scope and token completeness.
6. If rendered-app validation is available without disrupting the desktop, verify light, dark, system-follow, tooltip focus, and representative Chat/Editor/Changes screens.
36 changes: 36 additions & 0 deletions src/components/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Tooltip } from "./Tooltip";

describe("Tooltip", () => {
it("links an icon-only control to focus-triggered help text", () => {
render(
<Tooltip label="Open settings">
<button aria-label="Open settings">S</button>
</Tooltip>,
);

const button = screen.getByRole("button", { name: "Open settings" });
expect(screen.queryByRole("tooltip")).toBeNull();

fireEvent.focus(button);
const tooltip = screen.getByRole("tooltip", { name: "Open settings" });
expect(button).toHaveAttribute("aria-describedby", tooltip.id);
expect(tooltip.parentElement).toHaveClass("app-tooltip");
});

it("dismisses a keyboard-triggered tooltip with Escape", () => {
render(
<Tooltip label="Open settings">
<button aria-label="Open settings">S</button>
</Tooltip>,
);

const button = screen.getByRole("button", { name: "Open settings" });
fireEvent.focus(button);
expect(screen.getByRole("tooltip")).toBeInTheDocument();

fireEvent.keyDown(button, { key: "Escape" });
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
50 changes: 50 additions & 0 deletions src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { cloneElement, useId, useState, type ReactElement } from "react";

type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "start" | "center" | "end";
type TooltipChild = ReactElement<{ "aria-describedby"?: string }>;

export function Tooltip({
label,
side = "bottom",
align = "center",
children,
}: {
label: string;
side?: TooltipSide;
align?: TooltipAlign;
children: TooltipChild;
}) {
const id = useId();
const [open, setOpen] = useState(false);
const describedBy = [children.props["aria-describedby"], id]
.filter(Boolean)
.join(" ");
return (
<span
className="app-tooltip"
data-side={side}
data-align={align}
data-open={open || undefined}
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
onFocusCapture={() => setOpen(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
}}
onKeyDownCapture={(event) => {
if (event.key === "Escape") setOpen(false);
}}
>
{cloneElement(children, { "aria-describedby": describedBy })}
<span
id={id}
className="app-tooltip-content"
role="tooltip"
aria-hidden={!open}
>
{label}
</span>
</span>
);
}
39 changes: 21 additions & 18 deletions src/features/changes/ChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
RiTreeLine,
} from "@remixicon/react";
import { type DiffScope, type GitChange } from "../../bindings";
import { Tooltip } from "../../components/Tooltip";
import { formatNumber, useTranslation } from "../../i18n";
import { useTreeKeyboard } from "../../lib/useTreeKeyboard";
import { diffResourceId, useEditorStore } from "../editor/editorStore";
Expand Down Expand Up @@ -368,24 +369,26 @@ export function ChangesPanel({ worktreeId }: { worktreeId: string }) {
className="change-mode-toggle"
aria-label={t("changes.viewMode")}
>
<button
className={mode === "list" ? "active" : ""}
aria-label={t("changes.listView")}
aria-pressed={mode === "list"}
title={t("changes.listView")}
onClick={() => setMode("list")}
>
<RiFileListLine size={15} />
</button>
<button
className={mode === "tree" ? "active" : ""}
aria-label={t("changes.treeView")}
aria-pressed={mode === "tree"}
title={t("changes.treeView")}
onClick={() => setMode("tree")}
>
<RiTreeLine size={15} />
</button>
<Tooltip label={t("changes.listView")} side="left">
<button
className={mode === "list" ? "active" : ""}
aria-label={t("changes.listView")}
aria-pressed={mode === "list"}
onClick={() => setMode("list")}
>
<RiFileListLine size={15} />
</button>
</Tooltip>
<Tooltip label={t("changes.treeView")} side="left">
<button
className={mode === "tree" ? "active" : ""}
aria-label={t("changes.treeView")}
aria-pressed={mode === "tree"}
onClick={() => setMode("tree")}
>
<RiTreeLine size={15} />
</button>
</Tooltip>
</div>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/features/chat/chatLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ describe("chat timeline layout", () => {
});

it("uses the latest thread and composer geometry", () => {
expect(css).toMatch(/--chat-thread-width:\s*960px;/);
expect(css).toMatch(/--chat-thread-width:\s*800px;/);
expect(css).toMatch(
/\.chat-message-user\s*\{[^}]*max-width:\s*min\(720px, 86%\);[^}]*border:\s*0;[^}]*border-radius:\s*8px;/s,
/\.chat-message-user\s*\{[^}]*max-width:\s*min\(680px, 86%\);[^}]*border:\s*0;[^}]*border-radius:\s*8px;/s,
);
expect(css).toMatch(
/\.chat-thinking,\s*\.chat-process-group\s*\{[^}]*width:\s*min\(800px, 100%\);/s,
Expand Down
6 changes: 4 additions & 2 deletions src/features/editor/EditorPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { commands } from "../../bindings";
Expand Down Expand Up @@ -799,10 +800,11 @@ describe("Chat history popover", () => {
await user.click(screen.getByRole("button", { name: "New chat" }));

expect(await screen.findByText("chat body untitled")).toBeInTheDocument();
expect(screen.getByText("New chat").closest(".editor-tab")).not.toBeNull();
const tabs = within(screen.getByRole("tablist"));
expect(tabs.getByText("New chat").closest(".editor-tab")).not.toBeNull();

act(() => setLanguage("zh-CN"));
expect(screen.getByText("新建聊天").closest(".editor-tab")).not.toBeNull();
expect(tabs.getByText("新建聊天").closest(".editor-tab")).not.toBeNull();
});

it("opens a selected session and closes history", async () => {
Expand Down
52 changes: 28 additions & 24 deletions src/features/editor/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type FileContent,
type GitDiff,
} from "../../bindings";
import { Tooltip } from "../../components/Tooltip";
import { useTranslation } from "../../i18n";
import { commandError } from "../../lib/errors";
import { ResourceCache } from "../../lib/resourceCache";
Expand Down Expand Up @@ -756,30 +757,33 @@ export function EditorPane({ worktreeId }: { worktreeId: string }) {
})}
</div>
<div className="editor-resource-actions">
<button
ref={historyTriggerRef}
title={t("editor.chatHistory")}
aria-label={t("editor.chatHistory")}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyId : undefined}
onClick={() => setHistoryOpen((open) => !open)}
>
<RiChatHistoryLine size={16} />
</button>
<button
title={t("editor.newChat")}
aria-label={t("editor.newChat")}
onClick={() => void createChat()}
>
<RiChatNewLine size={16} />
</button>
<button
title={t("editor.newTerminal")}
aria-label={t("editor.newTerminal")}
onClick={() => void createTerminal()}
>
<RiTerminalBoxLine size={16} />
</button>
<Tooltip label={t("editor.chatHistory")}>
<button
ref={historyTriggerRef}
aria-label={t("editor.chatHistory")}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyId : undefined}
onClick={() => setHistoryOpen((open) => !open)}
>
<RiChatHistoryLine size={16} />
</button>
</Tooltip>
<Tooltip label={t("editor.newChat")}>
<button
aria-label={t("editor.newChat")}
onClick={() => void createChat()}
>
<RiChatNewLine size={16} />
</button>
</Tooltip>
<Tooltip label={t("editor.newTerminal")} align="end">
<button
aria-label={t("editor.newTerminal")}
onClick={() => void createTerminal()}
>
<RiTerminalBoxLine size={16} />
</button>
</Tooltip>
</div>
</div>
<div className="editor-content">
Expand Down
17 changes: 17 additions & 0 deletions src/features/settings/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,23 @@ beforeEach(() => {
});

describe("SettingsDialog", () => {
it("offers system, light, and dark appearance modes", () => {
render(
<SettingsDialog
worktreeId={null}
initialSection="general"
onClose={() => undefined}
/>,
);

const theme = screen.getByRole("combobox", { name: "Theme" });
expect(
Array.from((theme as HTMLSelectElement).options).map((option) =>
option.textContent?.trim(),
),
).toEqual(["System", "Light", "Dark"]);
});

it("shows only resolved user resources as a read-only list", async () => {
render(<SettingsDialog worktreeId="w1" onClose={() => undefined} />);

Expand Down
5 changes: 3 additions & 2 deletions src/features/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from "../../bindings";
import { commandError } from "../../lib/errors";
import { useDialogFocus } from "../../lib/useDialogFocus";
import { useThemeStore } from "../theme/themeStore";
import { useThemeStore, type ThemeMode } from "../theme/themeStore";
import { useSettingsStore } from "./settingsStore";
import { MemorySettings } from "./MemorySettings";
import { settingsApi } from "./settingsApi";
Expand Down Expand Up @@ -181,8 +181,9 @@ function GeneralSettings() {
<select
aria-label={t("settings.general.theme.label")}
value={mode}
onChange={(event) => setMode(event.target.value as "light" | "dark")}
onChange={(event) => setMode(event.target.value as ThemeMode)}
>
<option value="system">{t("settings.general.theme.system")}</option>
<option value="light">{t("settings.general.theme.light")}</option>
<option value="dark">{t("settings.general.theme.dark")}</option>
</select>
Expand Down
16 changes: 16 additions & 0 deletions src/features/theme/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ describe("ThemeToggle", () => {
expect(screen.queryByRole("button", { name: /system/i })).toBeNull();
});

it("uses the resolved system appearance without exposing a third mode", () => {
useThemeStore.setState({ mode: "system", resolved: "dark" });
render(<ThemeToggle />);

const button = screen.getByRole("button", {
name: "Theme: dark. Switch to light",
});
expect(screen.queryByRole("button", { name: /system/i })).toBeNull();

fireEvent.click(button);
expect(useThemeStore.getState()).toMatchObject({
mode: "light",
resolved: "light",
});
});

it("rerenders its accessible label when the language changes", () => {
render(<ThemeToggle />);

Expand Down
17 changes: 10 additions & 7 deletions src/features/theme/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { RiMoonLine, RiSunLine } from "@remixicon/react";
import { Tooltip } from "../../components/Tooltip";
import { useTranslation } from "../../i18n";
import { useThemeStore } from "./themeStore";

export function ThemeToggle() {
const { t } = useTranslation();
const mode = useThemeStore((state) => state.mode);
const resolved = useThemeStore((state) => state.resolved);
const cycle = useThemeStore((state) => state.cycle);
const Icon = mode === "light" ? RiSunLine : RiMoonLine;
const nextMode = mode === "light" ? "dark" : "light";
const Icon = resolved === "light" ? RiSunLine : RiMoonLine;
const nextMode = resolved === "light" ? "dark" : "light";
const label = t("theme.toggle", {
mode: t(`theme.${mode}`),
mode: t(`theme.${resolved}`),
nextMode: t(`theme.${nextMode}`),
});
return (
<button title={label} aria-label={label} onClick={cycle}>
<Icon size={17} />
</button>
<Tooltip label={label}>
<button aria-label={label} onClick={cycle}>
<Icon size={17} />
</button>
</Tooltip>
);
}
Loading
Loading