Files
AgentHub/client/src/components/settings/AppearanceSettings.tsx
T
nntrivi2001 f6f1bf4a76 feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default,
  saved per account): the session view's tab bar holds every workspace's
  live sessions, a combo may mix them, and focusing a pane selects its
  workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once
  under `all` in agenthub.workspace.paneLayout.
- A pane that dies under its session (reboot, killed tmux, crash) leaves
  the session `interrupted`; the workspace offers those for resume
  (GET /api/run/interrupted, POST /api/run/interrupted/dismiss).
- Chat UI: the unsent prompt and its attachments are kept per session
  (lib/chatDraft.ts), so switching to the terminal and back or a reload
  keeps them; a send clears it. Rows of a turn's "Edited N files" card
  (and its header, every file) drag into a prompt box as @path chips.
- Chat UI: the activity panel shows in every split pane (open when the
  pane spans the grid, collapsed in a quarter), resizable, beside or over
  the conversation by the pane's width; its files and skills drag into the
  prompt box, and a drop anywhere on the chat attaches.
- Chat UI: a screenshot the session read outside the workspace previews
  (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text
  files are served back as text/plain; long prompts and answers are no
  longer cut at 10 KB (tool output still is); a new live session shows an
  empty chat until its first hook instead of "Session not found".
- Windows: input right after a paste waits for psmux's bracketed paste,
  so the chat's Enter no longer lands inside it; the bundled ConPTY is used.
- Changes panel: a Workspace context fold (instructions, files, folders).

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00

271 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the per-user
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`), the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), 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";
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 [allTabs, setAllTabs] = useState(readAllTabs);
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">
<ChatUiSettings />
<SettingsSection
id="workspace-tabs"
title={t("workspaceTabs.title")}
description={t("workspaceTabs.description")}
>
<SettingsSwitchRow
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsRow
label={t("language.label")}
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") },
]}
/>
}
/>
</SettingsSection>
<SettingsSection
id="terminal-font"
title={t("terminalFont.title")}
description={t("terminalFont.description")}
>
<SettingsRow
label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
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}`),
}))}
/>
}
/>
<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-[13px] text-fg-primary"
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
</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>
);
}