c8bc4a95a9
Workspace groups: named, foldable, ordered sidebar sections kept per account (agenthub.workspace.groups); a workspace joins one from its menu, a drop on the header, a drag under a row, or Alt+Up/Down past a group's edge; headers drag to reorder; a folded header shows its most urgent state; New workspace in this group; fold/unfold all. Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the Changes panel shows a parent folder's repos only when Settings -> Git -> Changes panel turns it on (agenthub.changes.innerRepos, off by default). Cost: the hook writer adds a session's sub-agent transcripts to its tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the session's own model was never counted live (~8% of a week here); an imported session is re-raised on every pass, so a claude -p run first seen with an empty transcript no longer stays at $0 (~10%). A one-time raise-only catch-up (user_version 5) re-reads ~/.claude/projects. Also: omp install script may redirect to its exact install.redirect URL (#85, repo-links test exempts it), native paste in the terminal.
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
/**
|
||
* @file AppearanceSettings.tsx
|
||
* @description Settings → Appearance (`/settings/appearance`), grouped so
|
||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||
* `#terminal-font`, with a preview line), and — local mode only — whether a
|
||
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
|
||
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
|
||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||
* default) or Raw (`lib/markdownView.ts`, `#markdown-view`); then the
|
||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||
* **Run setup again** reopens the first-run wizard (`components/onboarding`).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||
import { PetSettings } from "./PetSettings";
|
||
import { ChatUiSettings } from "./ChatUiSettings";
|
||
import { Select } from "../Select";
|
||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||
import { confirmDialog } from "../../lib/confirm";
|
||
import { resetOnboarding } from "../../lib/userPrefs";
|
||
import { toast } from "../../lib/toast";
|
||
import {
|
||
TERMINAL_FONTS,
|
||
readTerminalFont,
|
||
writeTerminalFont,
|
||
type TerminalFont,
|
||
} from "../../lib/terminalFont";
|
||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
|
||
import { usePermissions } from "../AuthGate";
|
||
import {
|
||
TERMINAL_FONT_SIZES,
|
||
UI_FONT_SIZES,
|
||
readTerminalFontSize,
|
||
readUiFontSize,
|
||
writeTerminalFontSize,
|
||
writeUiFontSize,
|
||
type UiFontSize,
|
||
} from "../../lib/fontSize";
|
||
|
||
export const THEME_LABELS: Record<string, string> = {
|
||
darcula: "Darcula",
|
||
"apple-dark": "Apple Dark",
|
||
"apple-light": "Apple Light",
|
||
canvas: "Canvas",
|
||
"orca-dark": "Orca Dark",
|
||
"orca-light": "Orca Light",
|
||
};
|
||
|
||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||
{ mode: "dark", Icon: Moon },
|
||
{ mode: "light", Icon: Sun },
|
||
{ mode: "system", Icon: Monitor },
|
||
];
|
||
|
||
export function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||
return (
|
||
<div
|
||
aria-hidden
|
||
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
|
||
data-mode={mode}
|
||
data-theme={name === "default" ? undefined : name}
|
||
>
|
||
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="h-2 w-2 rounded-full bg-accent" />
|
||
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
|
||
</div>
|
||
<div className="font-mono text-[10px] leading-tight">
|
||
<span className="text-syntax-keyword">const</span>{" "}
|
||
<span className="text-syntax-function">x</span>{" "}
|
||
<span className="text-syntax-operator">=</span>{" "}
|
||
<span className="text-syntax-string">"ok"</span>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
|
||
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AppearanceSettings() {
|
||
const { t, i18n } = useTranslation("settings");
|
||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||
const [nativePaste, setNativePaste] = useState(readNativePaste);
|
||
const { local } = usePermissions();
|
||
const pickTermFont = (font: TerminalFont) => {
|
||
writeTerminalFont(font);
|
||
setTermFont(font);
|
||
};
|
||
|
||
const reset = async () => {
|
||
const ok = await confirmDialog({
|
||
title: t("appearance.resetTitle"),
|
||
message: t("appearance.resetBody"),
|
||
confirmLabel: t("appearance.reset"),
|
||
});
|
||
if (!ok) return;
|
||
setMode("dark");
|
||
setThemeFor("dark", "default");
|
||
setThemeFor("light", "default");
|
||
toast.success(t("appearance.resetDone"));
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
|
||
<SettingsRow
|
||
id="language"
|
||
label={t("language.label")}
|
||
description={t("language.description")}
|
||
control={
|
||
<SettingsSegmented
|
||
ariaLabel={t("language.label")}
|
||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||
onChange={(v) => void i18n.changeLanguage(v)}
|
||
options={[
|
||
{ value: "en" as const, label: t("language.names.en") },
|
||
{ value: "vi" as const, label: t("language.names.vi") },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
<SettingsRow
|
||
id="text-size"
|
||
label={t("textSize.label")}
|
||
description={t("textSize.description")}
|
||
control={
|
||
<SettingsSegmented
|
||
ariaLabel={t("textSize.label")}
|
||
value={String(uiSize)}
|
||
onChange={(v) => {
|
||
const size = Number(v) as UiFontSize;
|
||
writeUiFontSize(size);
|
||
setUiSize(size);
|
||
}}
|
||
options={UI_FONT_SIZES.map((size) => ({
|
||
value: String(size),
|
||
label: t(`textSize.names.${size}`),
|
||
}))}
|
||
/>
|
||
}
|
||
/>
|
||
<SettingsRow
|
||
id="terminal-font"
|
||
label={t("terminalFont.title")}
|
||
description={
|
||
termFont === "system"
|
||
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
|
||
: t("terminalFont.description")
|
||
}
|
||
control={
|
||
// A dropdown: five font names overflow a segmented row.
|
||
<Select<TerminalFont>
|
||
label={t("terminalFont.title")}
|
||
value={termFont}
|
||
onChange={pickTermFont}
|
||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||
value: f,
|
||
label: t(`terminalFont.names.${f}`),
|
||
}))}
|
||
/>
|
||
}
|
||
/>
|
||
<SettingsRow
|
||
label={t("terminalFont.sizeLabel")}
|
||
control={
|
||
<Select
|
||
label={t("terminalFont.sizeLabel")}
|
||
value={String(termSize)}
|
||
onChange={(v) => {
|
||
writeTerminalFontSize(Number(v));
|
||
setTermSize(Number(v));
|
||
}}
|
||
options={TERMINAL_FONT_SIZES.map((size) => ({
|
||
value: String(size),
|
||
label: `${size}px`,
|
||
}))}
|
||
/>
|
||
}
|
||
/>
|
||
<p
|
||
data-testid="terminal-font-preview"
|
||
aria-label={t("terminalFont.preview")}
|
||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
|
||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||
// In px like xterm's cells: the interface's text size does not scale it.
|
||
style={{
|
||
fontFamily: TERMINAL_FONTS[termFont],
|
||
fontSize: termSize,
|
||
fontVariantLigatures: "none",
|
||
}}
|
||
>
|
||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||
</p>
|
||
{local && (
|
||
<SettingsSwitchRow
|
||
id="native-paste"
|
||
label={t("nativePaste.label")}
|
||
description={t("nativePaste.description")}
|
||
checked={nativePaste}
|
||
onChange={(on) => {
|
||
writeNativePaste(on);
|
||
setNativePaste(on);
|
||
}}
|
||
/>
|
||
)}
|
||
</SettingsSection>
|
||
|
||
<SettingsSection
|
||
id="sessions-view"
|
||
title={t("sessionsView.title")}
|
||
description={t("sessionsView.description")}
|
||
>
|
||
<ChatUiSettings />
|
||
<SettingsSwitchRow
|
||
id="workspace-tabs"
|
||
label={t("workspaceTabs.label")}
|
||
description={t("workspaceTabs.labelDesc")}
|
||
checked={allTabs}
|
||
onChange={(on) => {
|
||
writeAllTabs(on);
|
||
setAllTabs(on);
|
||
}}
|
||
/>
|
||
<SettingsRow
|
||
id="markdown-view"
|
||
label={t("markdownView.label")}
|
||
description={t("markdownView.description")}
|
||
control={
|
||
<SettingsSegmented
|
||
ariaLabel={t("markdownView.label")}
|
||
value={mdPreview ? "preview" : "raw"}
|
||
onChange={(v) => {
|
||
writeMarkdownPreview(v === "preview");
|
||
setMdPreview(v === "preview");
|
||
}}
|
||
options={[
|
||
{ value: "preview" as const, label: t("markdownView.preview") },
|
||
{ value: "raw" as const, label: t("markdownView.raw") },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
</SettingsSection>
|
||
|
||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||
<SettingsRow
|
||
label={t("appearance.mode")}
|
||
control={
|
||
<SettingsSegmented
|
||
ariaLabel={t("appearance.mode")}
|
||
value={mode}
|
||
onChange={setMode}
|
||
options={MODES.map(({ mode: m, Icon }) => ({
|
||
value: m,
|
||
label: (
|
||
<>
|
||
<Icon className="w-3.5 h-3.5" />
|
||
{t(`appearance.modes.${m}`)}
|
||
</>
|
||
),
|
||
}))}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
{(["dark", "light"] as const).map((group) => (
|
||
<div key={group}>
|
||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||
{t(`appearance.${group}Themes`)}
|
||
{theme === group && (
|
||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||
)}
|
||
</p>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||
{THEMES[group].map((name) => {
|
||
const selected = themeFor[group] === name;
|
||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||
return (
|
||
<button
|
||
key={name}
|
||
type="button"
|
||
onClick={() => setThemeFor(group, name)}
|
||
aria-pressed={selected}
|
||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||
selected
|
||
? "border-accent ring-2 ring-accent/40"
|
||
: "border-border hover:border-border-light"
|
||
}`}
|
||
>
|
||
<Swatch mode={group} name={name} />
|
||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="flex justify-end border-t border-border pt-3">
|
||
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
|
||
<RotateCcw className="w-3.5 h-3.5" />
|
||
{t("appearance.reset")}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="pet" className="space-y-3 scroll-mt-24">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
|
||
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
|
||
</div>
|
||
<PetSettings />
|
||
</section>
|
||
|
||
<SettingsSection
|
||
id="setup"
|
||
title={t("onboarding:rerun.label")}
|
||
description={t("onboarding:rerun.description")}
|
||
>
|
||
<SettingsRow
|
||
label={t("onboarding:rerun.label")}
|
||
control={
|
||
<button
|
||
type="button"
|
||
onClick={resetOnboarding}
|
||
data-testid="rerun-setup"
|
||
className="btn-ghost border border-border text-xs"
|
||
>
|
||
{t("onboarding:rerun.button")}
|
||
</button>
|
||
}
|
||
/>
|
||
</SettingsSection>
|
||
</div>
|
||
);
|
||
}
|