Merge pull request #198 from Tencent-Hunyuan/fix/memory-detail-layout

fix(memhub): 稳定记忆页并优化设置交互
This commit is contained in:
feitianxiang authored and GitHub committed 2026-10-28 00:54:42 +08:00
commit f7961f340a
20 files changed
+760 -280

No files matched your search

@@ -28,11 +28,15 @@ describe("ConnectionFallback", () => {
} as unknown as Window["oneMemory"];
render(
<LanguageProvider>
<ConnectionFallback />
<ConnectionFallback defaultExpanded />
</LanguageProvider>,
);
const user = userEvent.setup();
await user.click(await screen.findByText("Conectar otras herramientas"));
expect(
(await screen.findByText("Conectar otras herramientas")).closest(
"details",
),
).toHaveProperty("open", true);
await user.type(
screen.getByLabelText("Nombre del Harness"),
"Harness 原名",
@@ -59,8 +63,11 @@ describe("ConnectionFallback", () => {
createCustomHarnessSkill,
copyText,
} as unknown as Window["oneMemory"];
render(<ConnectionFallback />);
await user.click(screen.getByText("连接其他工具"));
render(<ConnectionFallback defaultExpanded />);
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
"open",
true,
);
const generate = screen.getByRole("button", { name: "生成配对 Skill" });
expect(generate).toHaveProperty("disabled", true);
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
@@ -90,8 +97,7 @@ describe("ConnectionFallback", () => {
createCustomHarnessSkill,
copyText: vi.fn().mockRejectedValue(new Error()),
} as unknown as Window["oneMemory"];
render(<ConnectionFallback />);
await user.click(screen.getByText("连接其他工具"));
render(<ConnectionFallback defaultExpanded />);
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
await user.click(screen.getByRole("button", { name: "生成配对 Skill" }));
await user.click(await screen.findByRole("button", { name: "重试" }));
@@ -103,6 +109,10 @@ describe("ConnectionFallback", () => {
});
it("does not invent runtime paths in the browser", () => {
render(<ConnectionFallback />);
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
"open",
false,
);
expect(screen.queryByLabelText("配对 Skill")).toBeNull();
expect(screen.getByText("本机配对仅在桌面 App 中可用。")).toBeTruthy();
});
+11 -2
View File
@@ -13,8 +13,13 @@ type MaterialBridge = {
getConnectionMaterial(): Promise<ConnectionMaterial>;
};
export function ConnectionFallback() {
export function ConnectionFallback({
defaultExpanded = false,
}: {
defaultExpanded?: boolean;
} = {}) {
const { locale, t } = useGuiLanguage();
const [expanded, setExpanded] = useState(defaultExpanded);
const [displayName, setDisplayName] = useState("");
const [material, setMaterial] = useState<Material | null>(null);
const [loading, setLoading] = useState(false);
@@ -42,7 +47,11 @@ export function ConnectionFallback() {
}
return (
<details className="connection-fallback">
<details
className="connection-fallback"
open={expanded}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>{t("connectionFallback.title")}</summary>
{materialBridge?.getConnectionMaterial ? (
<ConnectionMaterialFallback
+36 -7
View File
@@ -15,7 +15,7 @@ afterEach(() => {
it.each(["zh-CN", "en", "es"] as const)(
"hides the compact label while preserving its accessible name in %s",
(locale) => {
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
render(
<LanguageProvider>
@@ -28,6 +28,7 @@ it.each(["zh-CN", "en", "es"] as const)(
expect(label.hidden).toBe(true);
expect(label.htmlFor).toBe(selector.id);
expect(screen.getByRole("region", { name })).toBeTruthy();
await userEvent.click(selector);
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
@@ -60,18 +61,19 @@ it.each(["zh-CN", "en", "es"] as const)(
it.each(["system", "zh-CN", "en", "es"] as const)(
"keeps one stable settings row for the %s preference",
(preference) => {
async (preference) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox") as HTMLSelectElement;
const selector = screen.getByRole("combobox") as HTMLButtonElement;
const row = selector.closest(".r6-language-setting-row");
expect(selector.value).toBe(preference);
expect(row).toBeTruthy();
expect(row?.querySelector("label")?.hidden).toBe(false);
await userEvent.click(selector);
expect(
screen
.getByRole("option", {
@@ -102,9 +104,10 @@ it("offers an accessible selector, persists and updates its label", async () =>
const selector = screen.getByRole("combobox", {
name: translate("zh-CN", "languageLabel"),
});
expect(screen.getAllByRole("option")).toHaveLength(4);
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
await userEvent.selectOptions(selector, "es");
await userEvent.click(selector);
expect(screen.getAllByRole("option")).toHaveLength(4);
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
expect(
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
@@ -112,6 +115,31 @@ it("offers an accessible selector, persists and updates its label", async () =>
expect(document.documentElement.lang).toBe("es");
});
it("supports keyboard selection without using the system select menu", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "system");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox");
selector.focus();
await userEvent.keyboard("{Enter}");
expect(selector.getAttribute("aria-expanded")).toBe("true");
await userEvent.tab();
expect(selector.getAttribute("aria-expanded")).toBe("false");
selector.focus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Escape}");
expect(selector.getAttribute("aria-expanded")).toBe("false");
await userEvent.keyboard(" ");
expect(selector.getAttribute("aria-expanded")).toBe("true");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
expect(selector.getAttribute("aria-expanded")).toBe("false");
});
it("announces storage errors without replacing the confirmed choice", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
@@ -122,11 +150,12 @@ it("announces storage errors without replacing the confirmed choice", async () =
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("denied");
});
await userEvent.selectOptions(screen.getByRole("combobox"), "es");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
expect(screen.getByRole("alert").textContent).toBe(
translate("zh-CN", "languageSaveError"),
);
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
expect((screen.getByRole("combobox") as HTMLButtonElement).value).toBe(
"zh-CN",
);
});
+27 -17
View File
@@ -1,11 +1,30 @@
import { isLanguagePreference } from "@one-memory/i18n";
import { useId, useState } from "react";
import { useGuiLanguage } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
const id = useId();
const { preference, setPreference, t, error } = useGuiLanguage();
const [pending, setPending] = useState(false);
const options = [
{ value: "system", label: t("languageSystem") },
{
value: "zh-CN",
label: compact ? "简体中文" : t("languageChinese"),
lang: "zh-CN",
},
{
value: "en",
label: compact ? "English" : t("languageEnglish"),
lang: "en",
},
{
value: "es",
label: compact ? "Español" : t("languageSpanish"),
lang: "es",
},
] as const;
return (
<section
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
@@ -26,14 +45,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
{t(compact ? "languageTitle" : "languageLabel")}
</label>
<select
<OptionMenu
id={`${id}-select`}
aria-label={compact ? t("languageTitle") : undefined}
aria-describedby={compact ? undefined : `${id}-description`}
ariaLabel={compact ? t("languageTitle") : undefined}
labelledBy={compact ? undefined : `${id}-label`}
describedBy={compact ? undefined : `${id}-description`}
value={preference}
disabled={pending}
onChange={async (event) => {
const value = event.currentTarget.value;
options={options}
className="r6-language-option-menu"
onChange={async (value) => {
if (!isLanguagePreference(value)) return;
setPending(true);
try {
@@ -42,18 +63,7 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
setPending(false);
}
}}
>
<option value="system">{t("languageSystem")}</option>
<option value="zh-CN" lang="zh-CN">
{compact ? "简体中文" : t("languageChinese")}
</option>
<option value="en" lang="en">
{compact ? "English" : t("languageEnglish")}
</option>
<option value="es" lang="es">
{compact ? "Español" : t("languageSpanish")}
</option>
</select>
/>
</div>
{!compact && (
<p id={`${id}-description`} className="r6-language-setting-description">
+91
View File
@@ -212,6 +212,7 @@ it("searches only when 搜索 is pressed, not on every keystroke", async () => {
}}
/>,
);
expect(screen.getByRole("combobox", { name: "来源" })).toBeTruthy();
await userEvent.type(
screen.getByRole("textbox", { name: "筛选地图记忆" }),
"版本号",
@@ -265,6 +266,29 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
});
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
const { container } = render(
<Host simplified sources={[importedSources[0]]} />,
);
const heading = screen
.getByRole("heading", { name: "记忆详情" })
.closest(".map-inspector-heading");
const body = container.querySelector<HTMLElement>(".map-inspector-body");
expect(heading?.nextElementSibling).toBe(body);
if (!body) throw new Error("Memory detail scroll body is missing");
body.scrollTop = 180;
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第二条/ },
),
);
expect(body.scrollTop).toBe(0);
body.scrollTop = 120;
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
expect(body.scrollTop).toBe(0);
});
it("OBS-12: the graph legend stays with the graph instead of sitting under a stage without one", () => {
// While the first page is still being read there is no graph yet: the
// legend used to sit there anyway, describing nothing.
@@ -546,6 +570,73 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
).toBe(true);
});
it("keeps pagination and current detail stable while the next page is loading", async () => {
const onPage = vi.fn();
const page = {
items: memories,
total: 202,
offset: 0,
limit: 100,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const libraryView = { ...pagedView(page), onPage };
const view = render(<Host simplified libraryView={libraryView} />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
list.scrollTop = 160;
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
await userEvent.click(
within(pager).getByRole("button", { name: "下一页记忆" }),
);
expect(onPage).toHaveBeenCalledWith(100);
view.rerender(
<Host simplified libraryView={{ ...libraryView, loading: true }} />,
);
expect(
screen
.getByRole("navigation", { name: "地图记忆分页" })
.getAttribute("aria-busy"),
).toBe("true");
expect(
(
screen.getByRole("button", {
name: "下一页记忆",
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(list.scrollTop).toBe(160);
const nextItems = memories.map((memory, index) => ({
...memory,
memory_id: `next-${index}`,
content: `下一页第 ${index + 1} 条`,
}));
view.rerender(
<Host
simplified
items={nextItems}
libraryView={{
...libraryView,
page: {
...page,
items: nextItems,
offset: 100,
source_ids_by_memory: {
"next-0": ["codex"],
"next-1": ["codex"],
},
},
loading: false,
}}
/>,
);
expect(list.scrollTop).toBe(0);
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
);
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
const items = ["有来源", "无来源"].map((content, i) => ({
...memories[0],
+157 -129
View File
@@ -25,6 +25,7 @@ import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -220,6 +221,8 @@ export function MemoryMap(props: Props) {
const compact = useMediaPreference("(max-width: 1100px)");
const composing = useRef(false);
const pane = useRef<HTMLElement>(null);
const memoryList = useRef<HTMLUListElement>(null);
const inspectorBody = useRef<HTMLDivElement>(null);
const openedDefault = useRef(false);
const scrollSelection = useRef(false);
const controlledSource = library
@@ -367,6 +370,11 @@ export function MemoryMap(props: Props) {
const activeId = active.some((memory) => memory.memory_id === requestedId)
? requestedId
: null;
const inspectorIdentity = activeId
? `memory:${activeId}`
: selectedSource
? `source:${selectedSource.source_id}`
: (visibleGroupNode?.node_id ?? "empty");
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
@@ -427,6 +435,19 @@ export function MemoryMap(props: Props) {
container.scrollTop += itemBounds.bottom - containerBounds.bottom;
}
}, [activeId]);
useLayoutEffect(() => {
// Detail content owns its own scrollport. A newly selected memory must
// start at its heading instead of inheriting the previous inspector view.
if (inspectorIdentity === "empty") return;
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
}, [inspectorIdentity]);
const settledPageOffset = library?.page?.offset;
useLayoutEffect(() => {
// A settled page is a new reading surface, not a continuation of the
// previous page's scroll position.
if (settledPageOffset === undefined) return;
if (memoryList.current) memoryList.current.scrollTop = 0;
}, [settledPageOffset]);
const clear = useCallback(() => {
setSelectedId(null);
@@ -586,6 +607,7 @@ export function MemoryMap(props: Props) {
<label htmlFor="selected-source-filter">{text("来源")}</label>
<OptionMenu
id="selected-source-filter"
ariaLabel={text("来源")}
className="om-option-menu--plain"
value={controlledSource}
options={[
@@ -781,6 +803,7 @@ export function MemoryMap(props: Props) {
</p>
)}
<ul
ref={memoryList}
className="map-memory-list"
aria-label={text("地图记忆列表")}
id="memory-pane-memories"
@@ -874,18 +897,21 @@ export function MemoryMap(props: Props) {
</p>
)}
{library?.page &&
!library.loading &&
(!library.loading || library.page.total > 0) &&
!library.failed &&
!library.stale && (
<nav aria-label={text("地图记忆分页")} className="map-pagination">
<nav
aria-label={text("地图记忆分页")}
aria-busy={library.loading || undefined}
className="map-pagination"
>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={library.page.offset === 0}
disabled={library.loading || library.page.offset === 0}
onClick={() => {
clear();
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset - library.page.limit);
@@ -914,10 +940,10 @@ export function MemoryMap(props: Props) {
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.loading ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
clear();
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset + library.page.limit);
@@ -1117,142 +1143,144 @@ export function MemoryMap(props: Props) {
</button>
)}
</header>
{selectedSource ? (
inspectorOpen &&
!overlaysSuspended && (
<SourceImportReceipt
key={selectedSource.source_id}
source={selectedSource}
/>
)
) : props.simplified && inspectorDetail ? (
<div
aria-busy={
refreshing ||
inspectorDetail.memory.memory_id !== activeId ||
undefined
}
onClickCapture={(event) => {
// React portal events follow this owner too, including editor saves.
if (
refreshing &&
(event.target as Element).closest("button, summary")
) {
event.preventDefault();
event.stopPropagation();
<div className="map-inspector-body" ref={inspectorBody}>
{selectedSource ? (
inspectorOpen &&
!overlaysSuspended && (
<SourceImportReceipt
key={selectedSource.source_id}
source={selectedSource}
/>
)
) : props.simplified && inspectorDetail ? (
<div
aria-busy={
refreshing ||
inspectorDetail.memory.memory_id !== activeId ||
undefined
}
}}
onSubmitCapture={(event) => {
if (refreshing) {
event.preventDefault();
event.stopPropagation();
}
}}
>
{/* The pane used to keep the previously read memory on screen
onClickCapture={(event) => {
// React portal events follow this owner too, including editor saves.
if (
refreshing &&
(event.target as Element).closest("button, summary")
) {
event.preventDefault();
event.stopPropagation();
}
}}
onSubmitCapture={(event) => {
if (refreshing) {
event.preventDefault();
event.stopPropagation();
}
}}
>
{/* The pane used to keep the previously read memory on screen
until the new one arrived, which read as "nothing happened"
for two or three seconds. A read is not a value: show that a
read is happening instead. */}
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
<div className="map-detail-loading">
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
<div className="map-detail-loading">
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
sources={sources}
conversationAt={
library?.page?.source_time_by_memory?.[
inspectorDetail.memory.memory_id
] ?? null
}
/>
)}
</div>
) : inspectorDetail || visibleGroupNode ? (
<NodeDetail
key={
inspectorDetail?.memory.memory_id ||
visibleGroupNode?.node_id ||
"empty"
}
compact
node={visibleGroupNode}
graph={projection}
detail={inspectorDetail}
draft={props.draft}
busy={busy}
matterLabel={label}
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
requestIds={props.requestIds}
onClose={closeInspector}
/>
) : (
<div
className={`map-inspector-empty${activeId ? " map-inspector-loading" : ""}`}
>
{activeId ? (
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
sources={sources}
conversationAt={
library?.page?.source_time_by_memory?.[
inspectorDetail.memory.memory_id
] ?? null
}
/>
)}
</div>
) : inspectorDetail || visibleGroupNode ? (
<NodeDetail
key={
inspectorDetail?.memory.memory_id ||
visibleGroupNode?.node_id ||
"empty"
}
compact
node={visibleGroupNode}
graph={projection}
detail={inspectorDetail}
draft={props.draft}
busy={busy}
matterLabel={label}
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
requestIds={props.requestIds}
onClose={closeInspector}
/>
) : (
<div
className={`map-inspector-empty${activeId ? " map-inspector-loading" : ""}`}
>
{activeId ? (
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
) : (
<FileText size={28} strokeWidth={1} />
)}
{props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed" ? (
<>
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
</button>
</>
) : props.simplified && !activeId && filtered.length ? (
// Nothing is selected, but this conversation has already
// settled: naming what came out of it beats a placeholder that
// asks for the settle that already happened. Only the
// single-column surface needs it — the three-column one already
// lists the same memories in the pane beside this one.
<>
) : (
<FileText size={28} strokeWidth={1} />
)}
{props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed" ? (
<>
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
</button>
</>
) : props.simplified && !activeId && filtered.length ? (
// Nothing is selected, but this conversation has already
// settled: naming what came out of it beats a placeholder that
// asks for the settle that already happened. Only the
// single-column surface needs it — the three-column one already
// lists the same memories in the pane beside this one.
<>
<p>
{text(
"已沉淀 {count} 条记忆",
{ count: plain(filtered.length) },
filtered.length,
)}
</p>
<ul
className="map-settled-list"
aria-label={text("已沉淀的记忆")}
>
{filtered.map((m) => (
<li key={m.memory_id}>{m.content}</li>
))}
</ul>
</>
) : (
<p>
{text(
"已沉淀 {count} 条记忆",
{ count: plain(filtered.length) },
filtered.length,
)}
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
</p>
<ul
className="map-settled-list"
aria-label={text("已沉淀的记忆")}
>
{filtered.map((m) => (
<li key={m.memory_id}>{m.content}</li>
))}
</ul>
</>
) : (
<p>
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
</p>
)}
</div>
)}
)}
</div>
)}
</div>
</MemoryEvidencePanel>
</section>
);
+2 -1
View File
@@ -201,8 +201,9 @@ it("keeps an added memory visible while it waits for model processing", async ()
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
expect(
screen.getByText(/原话已保存,正在等待整理:项目周会统一安排在周三上午。/),
screen.getByText(/正在等待整理:项目周会统一安排在周三上午。/),
).toBeTruthy();
expect(screen.queryByText(/原话已保存/)).toBeNull();
expect(screen.getByRole("button", { name: "配置整理模型" })).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+1 -1
View File
@@ -953,7 +953,7 @@ export function MemorySpace({
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:原话已保存,正在等待整理:")}
{t("memory:正在等待整理:")}
{pendingAdd.text}
</span>
{pendingAdd.state === "waiting_for_model" ? (
@@ -117,7 +117,8 @@ it("changes the first-screen language without losing source choices or starting
),
);
await user.click(screen.getByLabelText("导入 Cursor 历史"));
await user.selectOptions(selector, "es");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "Español" }));
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
selector,
);
@@ -125,7 +126,8 @@ it("changes the first-screen language without losing source choices or starting
screen.getByLabelText("Importar el historial de Cursor"),
).toHaveProperty("checked", false);
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
await user.selectOptions(selector, "zh-CN");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
"checked",
true,
+105 -14
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
disabled?: boolean;
lang?: string;
}
/** Replaces a native select so the open list follows the product field, not the OS menu. */
@@ -20,11 +21,50 @@ export function OptionMenu(props: {
onChange: (value: string) => void;
}) {
const { t } = useGuiLanguage();
const generatedId = useId();
const [open, setOpen] = useState(false);
const selectedIndex = props.options.findIndex(
(item) => item.value === props.value,
);
const firstEnabledIndex = props.options.findIndex((item) => !item.disabled);
const preferredIndex =
selectedIndex >= 0 && !props.options[selectedIndex]?.disabled
? selectedIndex
: firstEnabledIndex;
const [activeIndex, setActiveIndex] = useState(preferredIndex);
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const selected = props.options.find((item) => item.value === props.value);
const visibleLabel = selected?.label ?? t("wb.select");
const controlId = props.id ?? `${generatedId}-control`;
const panelId = `${controlId}-options`;
function enabledIndex(from: number, direction: 1 | -1) {
if (firstEnabledIndex < 0) return -1;
let next = from;
for (let checked = 0; checked < props.options.length; checked += 1) {
next =
next < 0
? direction === 1
? 0
: props.options.length - 1
: (next + direction + props.options.length) % props.options.length;
if (!props.options[next]?.disabled) return next;
}
return -1;
}
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
useEffect(() => {
if (!open) return;
setActiveIndex(preferredIndex);
const close = (event: MouseEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
@@ -37,47 +77,98 @@ export function OptionMenu(props: {
document.removeEventListener("mousedown", close);
document.removeEventListener("keydown", onKey);
};
}, [open]);
}, [open, preferredIndex]);
return (
<div
className={["om-option-menu", props.className].filter(Boolean).join(" ")}
ref={root}
>
<button
ref={button}
type="button"
id={props.id}
id={controlId}
value={props.value}
className="om-option-menu-button"
role="combobox"
aria-labelledby={props.labelledBy}
aria-label={props.ariaLabel}
aria-label={
props.ariaLabel ?? (props.labelledBy ? undefined : t("wb.select"))
}
aria-describedby={props.describedBy}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={panelId}
aria-activedescendant={
open && activeIndex >= 0
? `${panelId}-option-${activeIndex}`
: undefined
}
disabled={props.disabled}
onClick={() => setOpen((current) => !current)}
onBlur={(event) => {
if (!root.current?.contains(event.relatedTarget as Node | null))
setOpen(false);
}}
onClick={() => {
if (preferredIndex < 0) return;
setActiveIndex(preferredIndex);
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "Enter") {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
if (!open) {
if (preferredIndex >= 0) {
setActiveIndex(preferredIndex);
setOpen(true);
}
} else {
setActiveIndex((current) => enabledIndex(current, 1));
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
if (preferredIndex >= 0) {
setActiveIndex(preferredIndex);
setOpen(true);
}
} else {
setActiveIndex((current) => enabledIndex(current, -1));
}
} else if (event.key === "Home" && open) {
event.preventDefault();
setActiveIndex(enabledIndex(-1, 1));
} else if (event.key === "End" && open) {
event.preventDefault();
setActiveIndex(enabledIndex(0, -1));
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (open) choose(activeIndex);
else if (preferredIndex >= 0) {
setActiveIndex(preferredIndex);
setOpen(true);
}
}
}}
>
{selected?.label ?? t("wb.select")}
{visibleLabel}
</button>
{open && (
<div className="om-option-menu-panel" role="listbox">
{props.options.map((item) => (
<div id={panelId} className="om-option-menu-panel" role="listbox">
{props.options.map((item, index) => (
<button
key={item.value || item.label}
id={`${panelId}-option-${index}`}
type="button"
role="option"
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
props.onChange(item.value);
setOpen(false);
tabIndex={-1}
onMouseEnter={() => {
if (!item.disabled) setActiveIndex(index);
}}
onClick={() => choose(index)}
>
{item.label}
</button>
@@ -99,7 +99,7 @@ it("keeps selection status outside decorative fades and readable when disabled",
).toMatch(/opacity:\s*1/);
});
it("keeps the native selector keyboard-focusable and switches both languages", async () => {
it("keeps the product language menu keyboard-focusable and switches both languages", async () => {
render(
<LanguageProvider>
<R6ColdStartFrame
@@ -116,17 +116,19 @@ it("keeps the native selector keyboard-focusable and switches both languages", a
const selector = screen.getByRole("combobox");
await user.tab();
expect(document.activeElement).toBe(selector);
await user.selectOptions(selector, "es");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "Español" }));
expect(document.documentElement.lang).toBe("es");
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
await user.selectOptions(selector, "zh-CN");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(document.documentElement.lang).toBe("zh-CN");
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
});
it.each([1, 2, 3] as const)(
"offers a compact language selector on step %s",
(step) => {
async (step) => {
render(
<LanguageProvider>
<R6ColdStartFrame
@@ -141,6 +143,7 @@ it.each([1, 2, 3] as const)(
);
const selector = screen.getByRole("combobox", { name: "界面语言" });
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
await userEvent.click(selector);
expect(
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
).toBe("zh-CN");
+9 -2
View File
@@ -27,7 +27,8 @@ it("changes settings copy live without rereading profiles or changing permission
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.selectOptions(screen.getByRole("combobox"), "zh-CN");
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Chino simplificado" }));
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
@@ -66,7 +67,9 @@ vi.mock("./ModelSettings", async () => {
};
});
vi.mock("./ConnectionFallback", () => ({
ConnectionFallback: () => null,
ConnectionFallback: ({ defaultExpanded }: { defaultExpanded?: boolean }) => (
<p data-testid="connection-default">{String(defaultExpanded)}</p>
),
}));
vi.mock("./runtime-state", () => ({
refreshLoginPreference: vi.fn(),
@@ -95,7 +98,11 @@ it("shows the help and diagnostics group by default and hides it when opted out"
const user = userEvent.setup();
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.click(screen.getByRole("combobox"));
expect(screen.getAllByRole("option")).toHaveLength(4);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
expect(screen.getByTestId("connection-default").textContent).toBe("true");
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
+1 -1
View File
@@ -401,7 +401,7 @@ export function R6Settings({
className="r6-settings-connections"
>
<h2>{text("连接其他应用")}</h2>
<ConnectionFallback />
<ConnectionFallback defaultExpanded />
</section>
{showDiagnostics && (
<section
+8 -4
View File
@@ -14,16 +14,20 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
import { Answer, NodeDetail } from "./Workbench";
it("localiza la selección vacía del menú auxiliar", () => {
it("localiza y mantiene cerrado el menú auxiliar sin opciones", async () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<OptionMenu value="" options={[]} onChange={vi.fn()} />
</LanguageProvider>,
);
expect(
screen.getByRole("button", { name: "Selecciona una opción" }),
).toBeTruthy();
const menu = screen.getByRole("combobox", {
name: "Selecciona una opción",
});
menu.focus();
await userEvent.keyboard("{Enter}{ArrowDown}");
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(menu.getAttribute("aria-activedescendant")).toBeNull();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
+12 -2
View File
@@ -1086,8 +1086,9 @@ button.map-tool {
border-radius: 6px;
}
.map-inspector {
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 24px;
background: var(--glass-drawer);
border-left: 1px solid var(--bd);
@@ -1095,10 +1096,19 @@ button.map-tool {
}
.map-inspector-heading {
display: flex;
flex: none;
justify-content: space-between;
align-items: center;
margin-bottom: 22px;
}
.map-inspector-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.map-inspector-heading h2 {
font-size: 13px;
font-weight: 600;
+14 -3
View File
@@ -55,18 +55,29 @@
gap: 6px 10px;
font-size: 12px;
}
.onboarding-r6-titlebar select {
.onboarding-r6-titlebar .r6-language-option-menu {
width: 144px;
max-width: 100%;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button {
height: 32px;
min-width: 0;
max-width: 100%;
min-height: 32px;
padding: 4px 8px;
padding: 4px 32px 4px 10px;
border: 1px solid #9badbd;
border-radius: 8px;
color: inherit;
background: #f7fafc;
font: inherit;
}
.onboarding-r6-titlebar select:focus-visible {
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
right: 11px;
}
.onboarding-r6-titlebar
.r6-language-option-menu
.om-option-menu-button:focus-visible {
outline: 2px solid #356d99;
outline-offset: 2px;
}
+249 -72
View File
@@ -1,8 +1,8 @@
/* R6 source 139:5728 / 6765 / 7176 / 7585. Overrides stay inside settings. */
.r6-settings {
box-sizing: border-box;
padding: 26px 40px 46px;
color: #262a2e;
padding: 30px 40px 48px;
color: #304757;
min-width: 0;
width: 100%;
flex: 1;
@@ -10,161 +10,230 @@
letter-spacing: 0;
}
.r6-settings > h1 {
height: 44px;
height: auto;
font:
500 28px / 44px "Noto Sans SC",
600 26px / 36px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 28px;
margin: 0 0 22px;
}
.r6-settings .r6-language-setting {
margin-bottom: 24px;
position: relative;
z-index: 2;
margin-bottom: 20px;
padding: 20px 24px;
border: 1px solid #dce4eb;
border-radius: 16px;
background: #fff;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: rgb(255 255 255 / 78%);
}
.r6-language-setting-title {
margin: 0 0 18px;
font-size: 18px;
line-height: 28px;
margin: 0 0 14px;
font-size: 16px;
font-weight: 600;
line-height: 26px;
}
.r6-settings .r6-language-setting-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
grid-template-columns: 104px minmax(0, 1fr);
align-items: center;
gap: 16px;
min-height: 44px;
}
.r6-settings .r6-language-setting-row label {
min-width: 0;
font-size: 14px;
line-height: 24px;
color: #4f6373;
font-size: 13px;
font-weight: 500;
line-height: 22px;
white-space: nowrap;
}
.r6-settings .r6-language-setting select {
box-sizing: border-box;
.r6-settings .r6-language-option-menu {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid #dce4eb;
border-radius: 8px;
color: inherit;
background: #fff;
font: inherit;
}
.r6-settings .r6-language-setting select:focus-visible {
outline: 2px solid #225f9d;
.r6-settings .r6-language-option-menu .om-option-menu-button {
height: 44px;
min-height: 44px;
padding: 0 42px 0 14px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
font-size: 13px;
line-height: 22px;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-settings .r6-language-option-menu .om-option-menu-panel {
top: calc(100% + 6px);
padding: 6px;
border-color: #c6d2da;
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
min-height: 38px;
padding: 8px 10px;
color: #304757;
font-size: 13px;
line-height: 22px;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"]:active:not(:disabled) {
color: #304757;
background: #d4e2ea;
}
.r6-settings .r6-language-option-menu .om-option-menu-button:focus-visible {
outline: 2px solid #557c93;
outline-offset: 2px;
}
.r6-language-setting-description {
margin: 16px 0 0;
margin: 12px 0 0 120px;
color: #5e6e7a;
font-size: 13px;
line-height: 22px;
line-height: 21px;
overflow-wrap: anywhere;
}
.r6-settings .r6-settings-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 24px;
align-items: start;
grid-template-columns: 190px minmax(0, 1fr);
gap: 0;
align-items: stretch;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: #fff;
}
.r6-settings .r6-settings-layout > nav {
box-sizing: border-box;
min-height: 710px;
padding: 13px 11px;
background: rgb(255 255 255 / 27.45%);
border: 1px solid white;
border-radius: 16px;
box-shadow: inset 0 1px white;
backdrop-filter: blur(72px);
min-height: 560px;
padding: 16px 12px;
background: #f3f7f9;
border: 0;
border-right: 1px solid #d5dfe8;
border-radius: 11px 0 0 11px;
box-shadow: none;
backdrop-filter: none;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
gap: 4px;
}
.r6-settings .r6-settings-layout > nav > button {
box-sizing: border-box;
padding: 8px 16px;
padding: 8px 12px;
height: auto;
min-height: 46px;
min-height: 42px;
text-align: left;
border: 0;
border-radius: 8px;
background: transparent;
color: #262a2e;
color: #405665;
font:
400 16px / 28px "Noto Sans SC",
400 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
box-shadow: none;
transform: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
color 140ms ease-out,
background-color 140ms ease-out;
}
.r6-settings .r6-settings-layout > nav > button[aria-current="page"] {
background: #e1eaf0;
color: #304757;
background: #dfe9ef;
font-weight: 500;
}
.r6-settings .r6-settings-layout > nav > button:hover:not(:disabled) {
background: rgb(225 234 240 / 65%);
color: #304757;
background: #e9f0f4;
transform: none;
}
.r6-settings .r6-settings-layout > nav > button:active:not(:disabled) {
color: #304757;
background: #d4e2ea;
}
.r6-settings .r6-settings-content {
box-sizing: border-box;
min-width: 0;
min-height: 710px;
padding: 25px 31px;
min-height: 560px;
padding: 28px 34px;
background: #fff;
border: 1px solid #dce4eb;
border-radius: 16px;
border: 0;
border-radius: 0 11px 11px 0;
overflow-wrap: anywhere;
}
.r6-settings .r6-settings-content > section[hidden] {
display: none;
}
.r6-settings .r6-settings-content > section > h2 {
min-height: 36px;
min-height: 32px;
font:
500 22px / 36px "Noto Sans SC",
600 20px / 32px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0;
text-wrap: balance;
}
.r6-settings .r6-setting-row {
box-sizing: border-box;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 30px 0 25px;
gap: 32px;
padding: 24px 0;
border-bottom: 1px solid #d8e2ea;
min-height: 132px;
min-height: 108px;
}
.r6-settings .r6-setting-row > div {
flex: 1;
min-width: 0;
max-width: 68ch;
}
.r6-settings .r6-setting-row > button {
margin-top: 12px;
margin-top: 0;
flex-shrink: 0;
}
.r6-settings .r6-setting-row h3 {
font:
400 18px / 28px "Noto Sans SC",
500 16px / 26px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 10px;
margin: 0 0 6px;
}
.r6-settings .r6-setting-row p {
font-size: 14px;
line-height: 26px;
color: #63707b;
font-size: 13px;
line-height: 23px;
color: #5e6e7a;
margin: 0;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.r6-settings .r6-setting-row > span {
font-size: 13px;
@@ -205,6 +274,97 @@
font-size: 14px;
color: #4f6373;
}
.r6-settings .connection-fallback {
margin: 22px 0 0;
padding: 0;
border: 0;
}
.r6-settings .connection-fallback summary {
width: fit-content;
min-height: 36px;
padding: 6px 8px;
margin-left: -8px;
border-radius: 6px;
color: #405665;
font-size: 14px;
font-weight: 500;
line-height: 24px;
transition:
color 140ms ease-out,
background-color 140ms ease-out;
}
.r6-settings .connection-fallback summary:hover {
color: #304757;
background: #e9f0f4;
}
.r6-settings .connection-fallback summary:active {
color: #304757;
background: #d4e2ea;
}
.r6-settings .connection-fallback .connection-boundary {
max-width: 68ch;
margin: 16px 0 20px;
color: #5e6e7a;
font-size: 13px;
line-height: 23px;
text-wrap: pretty;
}
.r6-settings .connection-fallback .custom-harness-name {
max-width: 560px;
gap: 8px;
margin: 0 0 14px;
color: #4f6373;
font-size: 13px;
font-weight: 500;
}
.r6-settings .connection-fallback .custom-harness-name input {
box-sizing: border-box;
width: 100%;
min-height: 44px;
padding: 10px 13px;
border: 1px solid #c6d2da;
border-radius: 8px;
background: #fff;
color: #304757;
font-size: 14px;
}
.r6-settings .connection-fallback .custom-harness-name input:hover {
border-color: #aabcc8;
}
.r6-settings .connection-fallback .custom-harness-name input:focus-visible {
outline: 2px solid #557c93;
outline-offset: 2px;
}
.r6-settings .connection-fallback > button:not(.quiet) {
min-height: 42px;
padding: 0 18px;
border: 1px solid #5b6f7e;
border-radius: 8px;
background: #4f6373;
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease-out,
transform 90ms ease-out;
}
.r6-settings .connection-fallback > button:not(.quiet):hover:not(:disabled) {
border-color: #5b6f7e;
background: #435966;
color: #fff;
}
.r6-settings .connection-fallback > button:not(.quiet):active:not(:disabled) {
background: #3d4f5d;
color: #fff;
transform: scale(0.985);
}
.r6-settings .connection-fallback > button:not(.quiet):disabled {
border-color: #c6d2da;
background: #dbe4e9;
color: #647783;
cursor: default;
}
.r6-settings .r6-settings-error {
color: #953f3f;
font-size: 14px;
@@ -222,7 +382,7 @@
line-height: 24px;
}
.r6-settings .r6-model-overview {
padding: 32px 0 24px;
padding: 26px 0 20px;
border-bottom: 0;
}
.r6-settings .r6-model-caption {
@@ -233,7 +393,7 @@
}
.r6-settings .r6-current-model {
font:
400 24px / 40px "Noto Sans SC",
500 22px / 34px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 7px;
@@ -363,23 +523,26 @@
}
.r6-settings .r6-settings-help .r6-setting-row {
display: flex;
min-height: 132px;
padding: 30px 0 25px;
min-height: 108px;
padding: 24px 0;
align-items: center;
gap: 32px;
}
.r6-settings .r6-settings-help .r6-setting-row h3 {
font-size: 18px;
margin-bottom: 10px;
font-size: 16px;
margin-bottom: 6px;
}
.r6-settings .r6-settings-help .r6-setting-row p {
font-size: 14px;
font-size: 13px;
}
.r6-settings .r6-settings-help .r6-setting-row > button {
.r6-settings .r6-settings-help .r6-setting-row > button:not(.r6-switch) {
min-width: 132px;
margin-top: 0;
margin-left: auto;
}
.r6-settings .r6-settings-help .r6-setting-row > .r6-switch {
margin-left: auto;
}
.r6-settings .r6-settings-help .r6-settings-about {
min-height: 0;
padding-bottom: 24px;
@@ -534,10 +697,9 @@
}
.r6-settings .r6-settings-layout {
grid-template-columns: 176px minmax(0, 1fr);
gap: 20px;
}
.r6-settings .r6-settings-content {
padding: 25px 23px;
padding: 26px 28px;
}
.r6-settings .r6-setting-row {
flex-wrap: wrap;
@@ -559,6 +721,9 @@
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
border-right: 0;
border-bottom: 1px solid #d5dfe8;
border-radius: 11px 11px 0 0;
}
.r6-settings .r6-settings-layout > nav > button {
font-size: 14px;
@@ -566,6 +731,18 @@
.r6-settings .model-form {
grid-template-columns: minmax(0, 1fr);
}
.r6-settings .r6-settings-content {
min-height: 0;
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.r6-language-setting-description {
margin-left: 0;
}
}
@media (prefers-reduced-transparency: reduce) {
.r6-settings .r6-settings-layout > nav {
+7 -8
View File
@@ -219,12 +219,14 @@
}
.r6-shell .r6-memory .map-inspector {
padding: 14px 22px 24px;
background: rgb(255 255 255 / 30%);
background: #f2f6f8;
border: 0;
border-radius: 0;
overflow-y: auto;
overflow-anchor: none;
overflow: hidden;
min-height: 0;
isolation: isolate;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.r6-shell .r6-memory .map-inspector-heading {
padding: 0;
@@ -498,11 +500,8 @@
width: min(418px, calc(100vw - 32px));
}
.r6-shell .r6-memory .map-inspector-heading {
position: sticky;
top: -14px;
z-index: 1;
background: #eff5f8;
padding: 10px 0;
position: static;
padding: 0;
}
.selected-editor {
padding-left: 0;
+1 -2
View File
@@ -1809,8 +1809,7 @@ const baseEnglishMessages = {
"{count} memories created{filter} · Showing {shown}",
"memory:({filter})": " ({filter})",
"memory:第 {page} 页": "Page {page}",
"memory:原话已保存,正在等待整理:":
"Original text saved, awaiting processing: ",
"memory:正在等待整理:": "Awaiting processing: ",
"memory:记忆未在生成": "Memories are not being created",
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
"Memories are not being created: {reason} ({count} pending)",
+2 -3
View File
@@ -370,7 +370,7 @@ export const memoryChineseMessages = {
"已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
"memory:({filter})": "({filter})",
"memory:第 {page} 页": "第 {page} 页",
"memory:原话已保存,正在等待整理:": "原话已保存,正在等待整理:",
"memory:正在等待整理:": "正在等待整理:",
"memory:记忆未在生成": "记忆未在生成",
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
"还没有开始生成记忆:{reason}({count} 条待处理)",
@@ -786,8 +786,7 @@ export const memorySpanishMessages: Record<
"{count} memoria guardada{filter} · {shown} visibles",
"memory:({filter})": " ({filter})",
"memory:第 {page} 页": "Página {page}",
"memory:原话已保存,正在等待整理:":
"Texto guardado; pendiente de procesar: ",
"memory:正在等待整理:": "Pendiente de procesar: ",
"memory:记忆未在生成": "Generación de memorias detenida",
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
"Aún no se generan memorias: {reason} ({count} pendientes)",