f6f1bf4a76
- 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).
271 lines
9.8 KiB
TypeScript
271 lines
9.8 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|