fix(memhub): replace native evolve time input

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kristinheandCursor committed 2026-11-04 22:31:00 +08:00
1 parent b0e429b9ae
commit a6d9894975
4 files changed
+530 -15

No files matched your search

+122 -4
View File
@@ -300,13 +300,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
const timePicker = screen.getByRole("button", {
name: "执行时间,03:00",
});
expect(timePicker.tagName).toBe("BUTTON");
expect(timePicker.textContent).toContain("03:00");
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: true,
time: "04:30",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
"04",
);
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
expect(setEvolveSchedule).not.toHaveBeenCalled();
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
time: "04:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
document.activeElement,
),
);
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
});
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const hours = within(picker).getByRole("listbox", {
name: "执行时间 HH",
});
const save = within(picker).getByRole("button", { name: "保存修改" });
expect(document.activeElement).toBe(hours);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(save);
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(hours);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("keeps the selected time open for retry when saving fails", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const minutes = within(picker).getByRole("listbox", {
name: "执行时间 MM",
});
await user.selectOptions(minutes, "30");
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
expect(
await screen.findByText("自动整合设置未能保存,请重试。"),
).toBeTruthy();
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
expect((minutes as HTMLSelectElement).value).toBe("30");
await waitFor(() =>
expect(document.activeElement).toBe(
within(picker).getByRole("button", { name: "保存修改" }),
),
);
});
it("keeps the time dialog and focus stable while saving is pending", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
expect(document.activeElement).toBe(picker);
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
expect(document.activeElement).toBe(picker);
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -322,7 +440,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
+6 -5
View File
@@ -29,6 +29,7 @@ import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
import { TimePicker } from "./TimePicker";
const MODE_COPY: Record<
MemoryPipelineMode,
@@ -332,8 +333,10 @@ export function MemoryModeSettings({
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
return true;
} catch {
setError("自动整合设置未能保存,请重试。");
return false;
} finally {
setBusy(false);
}
@@ -455,14 +458,12 @@ export function MemoryModeSettings({
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
<TimePicker
id={`${id}-evolve-time`}
type="time"
label={text("执行时间")}
value={schedule.time}
disabled={busy || maintenance}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
onChange={(time) => changeSchedule({ time })}
/>
</div>
) : null}
+265
View File
@@ -0,0 +1,265 @@
import { Check, ChevronDown, Clock3 } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
const HOURS = Array.from({ length: 24 }, (_, value) =>
value.toString().padStart(2, "0"),
);
const MINUTES = Array.from({ length: 60 }, (_, value) =>
value.toString().padStart(2, "0"),
);
function splitTime(value: string): [string, string] {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
return match ? [match[1], match[2]] : ["00", "00"];
}
export function TimePicker({
id,
label,
value,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: string;
disabled?: boolean;
onChange(value: string): Promise<boolean>;
}) {
const { locale, text } = useOnboardingTranslation();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [placement, setPlacement] = useState<"above" | "below">("above");
const [hour, setHour] = useState(() => splitTime(value)[0]);
const [minute, setMinute] = useState(() => splitTime(value)[1]);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const hourSelect = useRef<HTMLSelectElement>(null);
const minuteSelect = useRef<HTMLSelectElement>(null);
const restoreTriggerFocus = useRef(false);
const restoreSaveFocus = useRef(false);
const savingRef = useRef(false);
savingRef.current = saving;
const panelId = `${id}-picker`;
const close = useCallback((restoreFocus = true) => {
setOpen(false);
restoreTriggerFocus.current = restoreFocus;
}, []);
function show() {
const [nextHour, nextMinute] = splitTime(value);
setHour(nextHour);
setMinute(nextMinute);
setOpen(true);
}
useEffect(() => {
if (!open) return;
const closeOutside = (event: MouseEvent) => {
if (savingRef.current) return;
const target = event.target as Element | null;
const label = target?.closest("label");
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
if (!root.current?.contains(target)) close(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || savingRef.current) return;
event.preventDefault();
close();
};
document.addEventListener("mousedown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
hourSelect.current?.focus();
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
return () => {
document.removeEventListener("mousedown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [close, id, open]);
useEffect(() => {
if (disabled && open && !saving) close(false);
if (!disabled && !open && restoreTriggerFocus.current) {
restoreTriggerFocus.current = false;
trigger.current?.focus();
}
if (!disabled && open && restoreSaveFocus.current) {
restoreSaveFocus.current = false;
panel.current
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
?.focus();
}
if (saving && open) panel.current?.focus();
}, [close, disabled, open, saving]);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const anchor = trigger.current?.getBoundingClientRect();
const picker = panel.current?.getBoundingClientRect();
if (!anchor || !picker) return;
const scroller = trigger.current?.closest(
".app-content, .onboarding-frame",
);
const bounds = scroller?.getBoundingClientRect();
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
const bottomEdge =
(bounds
? Math.min(window.innerHeight, bounds.bottom)
: window.innerHeight) - 12;
const above = anchor.top - topEdge;
const below = bottomEdge - anchor.bottom;
setPlacement(
above >= picker.height || above >= below ? "above" : "below",
);
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
async function save() {
const next = `${hour}:${minute}`;
if (next === value) {
close();
return;
}
setSaving(true);
const saved = await onChange(next);
setSaving(false);
if (saved) close();
else restoreSaveFocus.current = true;
}
return (
<div className="r6-time-picker" ref={root}>
<button
ref={trigger}
id={id}
type="button"
className="r6-time-picker-trigger"
aria-label={
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
disabled={disabled}
onClick={() => (open ? close() : show())}
>
<span>{value}</span>
<span className="r6-time-picker-icons" aria-hidden="true">
<Clock3 size={15} />
<ChevronDown size={14} />
</span>
</button>
{open ? (
<section
ref={panel}
id={panelId}
className="r6-time-picker-panel"
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
data-placement={placement}
onKeyDown={(event) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"select:not(:disabled), button:not(:disabled)",
),
);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) {
event.preventDefault();
event.currentTarget.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<div className="r6-time-picker-columns">
<label className="r6-time-picker-column">
<span aria-hidden="true">HH</span>
<select
ref={hourSelect}
size={6}
aria-label={`${label} HH`}
value={hour}
disabled={saving}
onChange={(event) => setHour(event.target.value)}
>
{HOURS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<span className="r6-time-picker-separator" aria-hidden="true">
:
</span>
<label className="r6-time-picker-column">
<span aria-hidden="true">MM</span>
<select
ref={minuteSelect}
size={6}
aria-label={`${label} MM`}
value={minute}
disabled={saving}
onChange={(event) => setMinute(event.target.value)}
>
{MINUTES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<footer>
<R6Button disabled={saving} onClick={() => close()}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={saving}
data-time-picker-save
onClick={() => void save()}
>
<Check size={15} aria-hidden="true" />
{text("保存修改")}
</R6Button>
</footer>
</section>
) : null}
</div>
);
}
+137 -6
View File
@@ -295,7 +295,7 @@
padding-top: 16px;
border-top: 1px solid #e1e8ed;
}
.r6-settings .r6-evolve-time label {
.r6-settings .r6-evolve-time > div > label {
color: #304757;
font-size: 13px;
font-weight: 600;
@@ -304,20 +304,146 @@
.r6-evolve-time p {
margin-top: 1px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker {
position: relative;
width: 180px;
}
.r6-time-picker-trigger {
box-sizing: border-box;
width: 180px;
min-height: 44px;
padding: 0 10px;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px 0 14px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
font:
500 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
}
.r6-evolve-settings input[type="time"]:focus-visible {
.r6-time-picker-trigger:hover:not(:disabled) {
border-color: #9eafbb;
background: #f8fafb;
}
.r6-time-picker-trigger[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-time-picker-trigger:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-time-picker-trigger:disabled {
color: #71808b;
background: #edf0f3;
cursor: default;
}
.r6-time-picker-trigger:active:not(:disabled) {
transform: none;
}
.r6-time-picker-icons {
display: inline-flex;
align-items: center;
gap: 5px;
color: #607888;
}
.r6-time-picker-trigger[aria-expanded="true"]
.r6-time-picker-icons
svg:last-child {
transform: rotate(180deg);
}
.r6-time-picker-panel {
box-sizing: border-box;
position: absolute;
z-index: 1200;
right: 0;
bottom: calc(100% + 8px);
width: min(280px, calc(100vw - 32px));
padding: 14px;
border: 1px solid #c6d2da;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
}
.r6-time-picker-panel[data-placement="below"] {
top: calc(100% + 8px);
bottom: auto;
}
.r6-time-picker-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 8px;
}
.r6-time-picker-column {
display: grid;
gap: 6px;
color: #5e6e7a;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.r6-time-picker-columns select {
box-sizing: border-box;
width: 100%;
height: 174px;
padding: 4px;
border: 1px solid #d5dfe8;
border-radius: 7px;
color: #304757;
background: #f8fafb;
font:
500 14px / 24px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
scrollbar-width: thin;
scrollbar-color: #b6c3cc transparent;
}
.r6-time-picker-columns select:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 1px;
}
.r6-time-picker-columns option {
min-height: 28px;
padding: 5px 8px;
border-radius: 5px;
}
.r6-time-picker-columns option:checked {
color: #304757;
background: #dfe9ef;
}
.r6-time-picker-separator {
align-self: center;
margin-top: 22px;
color: #7892a6;
font-size: 18px;
font-weight: 600;
}
.r6-time-picker-panel footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e1e8ed;
flex-wrap: wrap;
}
.r6-time-picker-panel footer .r6-button {
min-width: 68px;
}
.r6-state-recovery {
display: flex;
align-items: center;
@@ -1059,9 +1185,14 @@
.r6-evolve-time {
gap: 8px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker,
.r6-time-picker-trigger {
width: min(220px, 100%);
}
.r6-time-picker-panel {
right: auto;
left: 0;
}
.r6-state-recovery {
align-items: flex-start;
flex-direction: column;