diff --git a/memhub/apps/web/src/MemoryModeCards.test.tsx b/memhub/apps/web/src/MemoryModeCards.test.tsx
new file mode 100644
index 0000000..a369d52
--- /dev/null
+++ b/memhub/apps/web/src/MemoryModeCards.test.tsx
@@ -0,0 +1,70 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import { LanguageProvider } from "./gui-language";
+import { MemoryModeCards } from "./MemoryModeCards";
+
+afterEach(cleanup);
+
+it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ const view = (value: "lite" | "pro" | "ultra") => (
+
+
+ 每天自动整合
+ 默认 03:00,可在设置中修改。
+
+ }
+ />
+
+ );
+ const { rerender } = render(view("pro"));
+
+ expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
+ expect(screen.getAllByRole("radio")).toHaveLength(3);
+ expect(screen.getByText("不调用生成模型")).toBeTruthy();
+ expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
+ expect(screen.getByText("增强关联召回")).toBeTruthy();
+ expect(screen.queryByText("每天自动整合")).toBeNull();
+
+ await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
+ expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
+
+ rerender(view("ultra"));
+ expect(screen.getByText("每天自动整合")).toBeTruthy();
+});
+
+it("preserves per-mode disabled behavior", () => {
+ render(
+
+ mode !== "lite"}
+ onChange={vi.fn()}
+ />
+ ,
+ );
+
+ expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
+ "disabled",
+ false,
+ );
+ expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
+ "disabled",
+ true,
+ );
+});
diff --git a/memhub/apps/web/src/MemoryModeCards.tsx b/memhub/apps/web/src/MemoryModeCards.tsx
index 5b5d55a..6e4d0d2 100644
--- a/memhub/apps/web/src/MemoryModeCards.tsx
+++ b/memhub/apps/web/src/MemoryModeCards.tsx
@@ -1,5 +1,5 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
-import { Check } from "lucide-react";
+import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -32,6 +32,7 @@ export function MemoryModeCards({
current,
currentBadge,
disabled = false,
+ layout = "grid",
onChange,
ultraDetail,
"aria-label": ariaLabel,
@@ -41,6 +42,7 @@ export function MemoryModeCards({
current?: MemoryPipelineMode;
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
+ layout?: "grid" | "rows";
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
"aria-label": string;
@@ -53,70 +55,77 @@ export function MemoryModeCards({
return (
- {MODES.map((mode) => {
- const selected = value === mode.id;
- const modeDisabled = unavailable(mode.id);
- const localizedTitle = text(MODE_COPY[mode.id].title);
- const title = localizedTitle.replace(
- /\s*[((](?:lite|pro|ultra)[))]\s*$/i,
- "",
- );
- const features = text(MODE_COPY[mode.id].description).split(/\s*·\s*/);
- return (
-
-
);
}
diff --git a/memhub/apps/web/src/OnboardingFlow.tsx b/memhub/apps/web/src/OnboardingFlow.tsx
index 63b379b..bb8e83c 100644
--- a/memhub/apps/web/src/OnboardingFlow.tsx
+++ b/memhub/apps/web/src/OnboardingFlow.tsx
@@ -1266,6 +1266,7 @@ export function OnboardingFlow({
{
modeChoiceTouched.current = true;
diff --git a/memhub/apps/web/src/memory-mode-cards.css b/memhub/apps/web/src/memory-mode-cards.css
index cf6cc20..4c4abf9 100644
--- a/memhub/apps/web/src/memory-mode-cards.css
+++ b/memhub/apps/web/src/memory-mode-cards.css
@@ -1,14 +1,26 @@
.memory-mode-cards {
+ position: relative;
+ flex: 1;
min-width: 0;
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 16px;
- align-items: start;
+ min-height: 420px;
+ display: flex;
+}
+
+.memory-mode-card-list {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
}
.memory-mode-card {
+ position: relative;
+ flex: 1 1 0;
min-width: 0;
- min-height: 286px;
+ min-height: 128px;
+ height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -43,10 +55,15 @@
}
.memory-mode-card-choice {
- min-height: 284px;
- padding: 20px;
- display: flex;
- flex-direction: column;
+ position: relative;
+ min-height: 0;
+ height: 100%;
+ padding: 18px 20px;
+ display: grid;
+ grid-template-columns: minmax(230px, 0.85fr) minmax(0, 1.45fr);
+ grid-template-rows: auto auto minmax(0, 1fr);
+ column-gap: 28px;
+ align-items: center;
cursor: pointer;
}
@@ -68,10 +85,13 @@
}
.memory-mode-title {
+ grid-column: 1;
+ grid-row: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
+ padding-right: 34px;
}
.memory-mode-index {
@@ -110,9 +130,11 @@
}
.memory-mode-radio {
+ position: absolute;
+ top: 18px;
+ right: 20px;
width: 21px;
height: 21px;
- margin-left: auto;
flex: 0 0 21px;
display: grid;
place-items: center;
@@ -133,8 +155,11 @@
}
.memory-mode-description {
- min-height: 50px;
- margin-top: 10px;
+ grid-column: 1;
+ grid-row: 2;
+ align-self: start;
+ min-height: 0;
+ margin-top: 6px;
color: #526674;
font-size: 13px;
line-height: 22px;
@@ -142,11 +167,16 @@
}
.memory-mode-features {
- margin-top: auto;
- padding: 16px 0 2px;
+ grid-column: 2;
+ grid-row: 1 / -1;
+ align-self: stretch;
+ margin-top: 0;
+ padding: 0 42px 0 28px;
display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-content: center;
gap: 8px;
- border-top: 1px solid #dbe4ea;
+ border-left: 1px solid #dbe4ea;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
@@ -168,14 +198,97 @@
}
.memory-mode-detail {
- margin-top: auto;
- padding: 14px 18px;
+ grid-column: 1;
+ grid-row: 3;
+ align-self: start;
+ margin-top: 6px;
+ padding: 7px 0 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
border-top: 1px solid #d4e0e7;
- background: #f1f6f8;
+ background: transparent;
color: #405867;
+ font-size: 11px;
+ line-height: 17px;
+ animation: memory-mode-detail-in 180ms ease-out both;
+}
+
+.memory-mode-detail > svg {
+ margin-top: 1px;
+ flex: 0 0 auto;
+}
+
+.memory-mode-detail > div {
+ min-width: 0;
+ flex: 1;
+}
+
+.memory-mode-cards--grid {
+ flex: none;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ align-items: start;
+}
+
+.memory-mode-cards--grid .memory-mode-card-list {
+ display: contents;
+}
+
+.memory-mode-cards--grid .memory-mode-card {
+ position: static;
+ flex: none;
+ min-height: 286px;
+ height: auto;
+}
+
+.memory-mode-cards--grid .memory-mode-card-choice {
+ position: static;
+ min-height: 284px;
+ height: auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+}
+
+.memory-mode-cards--grid .memory-mode-title {
+ width: 100%;
+ padding-right: 0;
+}
+
+.memory-mode-cards--grid .memory-mode-radio {
+ position: static;
+ margin-left: auto;
+}
+
+.memory-mode-cards--grid .memory-mode-description {
+ min-height: 50px;
+ margin-top: 10px;
+}
+
+.memory-mode-cards--grid .memory-mode-features {
+ width: 100%;
+ margin-top: auto;
+ padding: 16px 0 2px;
+ border-top: 1px solid #dbe4ea;
+ border-left: 0;
+}
+
+.memory-mode-cards--grid .memory-mode-detail {
+ width: calc(100% + 40px);
+ margin: auto -20px -20px;
+ padding: 14px 18px;
+ display: block;
+ border-radius: 0 0 15px 15px;
+ background: #f1f6f8;
font-size: 12px;
line-height: 20px;
- animation: memory-mode-detail-in 180ms ease-out both;
+}
+
+.memory-mode-cards--grid .memory-mode-detail > svg {
+ display: none;
}
@keyframes memory-mode-detail-in {
@@ -185,34 +298,131 @@
}
}
-@media (min-width: 681px) and (max-height: 959px) {
- .memory-mode-cards {
- gap: 14px;
+@media (min-width: 761px) and (max-height: 959px) {
+ .memory-mode-cards--rows {
+ min-height: 360px;
}
- .memory-mode-card {
- min-height: 194px;
+ .memory-mode-cards--rows .memory-mode-card-list {
+ gap: 12px;
}
- .memory-mode-card-choice {
- min-height: 194px;
- padding: 16px;
+ .memory-mode-cards--rows .memory-mode-card {
+ min-height: 112px;
}
- .memory-mode-title strong {
+ .memory-mode-cards--rows .memory-mode-card-choice {
+ min-height: 0;
+ padding: 13px 16px;
+ grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
+ column-gap: 22px;
+ }
+ .memory-mode-cards--rows .memory-mode-radio {
+ top: 13px;
+ right: 16px;
+ }
+ .memory-mode-cards--rows .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
- .memory-mode-description {
+ .memory-mode-cards--rows .memory-mode-description {
+ min-height: 0;
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 18px;
+ }
+ .memory-mode-cards--rows .memory-mode-features {
+ margin-top: 0;
+ padding: 0 36px 0 22px;
+ gap: 6px;
+ font-size: 11px;
+ line-height: 17px;
+ }
+ .memory-mode-cards--rows .memory-mode-detail {
+ margin-top: 5px;
+ padding: 5px 0 0;
+ gap: 6px;
+ font-size: 11px;
+ line-height: 16px;
+ }
+}
+
+@media (max-width: 760px) {
+ .memory-mode-cards--rows {
+ flex: none;
+ min-height: 0;
+ }
+ .memory-mode-cards--rows .memory-mode-card-list {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+ .memory-mode-cards--rows .memory-mode-card {
+ flex: none;
+ min-height: 0;
+ height: auto;
+ }
+ .memory-mode-cards--rows .memory-mode-card-choice {
+ height: auto;
+ min-height: 0;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ }
+ .memory-mode-cards--rows .memory-mode-radio {
+ position: static;
+ margin-left: auto;
+ }
+ .memory-mode-cards--rows .memory-mode-title {
+ width: 100%;
+ padding-right: 0;
+ }
+ .memory-mode-cards--rows .memory-mode-description {
+ min-height: 0;
+ margin-top: 8px;
+ }
+ .memory-mode-cards--rows .memory-mode-features {
+ width: 100%;
+ margin-top: 16px;
+ padding: 16px 0 0;
+ border-top: 1px solid #dbe4ea;
+ border-left: 0;
+ }
+ .memory-mode-cards--rows .memory-mode-detail {
+ width: calc(100% + 40px);
+ margin: 16px -20px -20px;
+ padding: 14px 20px 16px;
+ border-radius: 0 0 15px 15px;
+ background: #f1f6f8;
+ }
+}
+
+@media (min-width: 681px) and (max-height: 959px) {
+ .memory-mode-cards--grid {
+ gap: 14px;
+ }
+ .memory-mode-cards--grid .memory-mode-card {
+ min-height: 194px;
+ }
+ .memory-mode-cards--grid .memory-mode-card-choice {
+ min-height: 194px;
+ padding: 16px;
+ }
+ .memory-mode-cards--grid .memory-mode-title strong {
+ font-size: 17px;
+ line-height: 25px;
+ }
+ .memory-mode-cards--grid .memory-mode-description {
min-height: 36px;
margin-top: 8px;
font-size: 12px;
line-height: 20px;
}
- .memory-mode-features {
+ .memory-mode-cards--grid .memory-mode-features {
padding-top: 10px;
gap: 6px;
font-size: 11px;
line-height: 18px;
}
- .memory-mode-detail {
+ .memory-mode-cards--grid .memory-mode-detail {
+ width: calc(100% + 32px);
+ margin: auto -16px -16px;
padding: 10px 14px;
font-size: 11px;
line-height: 18px;
@@ -220,14 +430,14 @@
}
@media (max-width: 680px) {
- .memory-mode-cards {
+ .memory-mode-cards--grid {
grid-template-columns: 1fr;
}
- .memory-mode-card,
- .memory-mode-card-choice {
+ .memory-mode-cards--grid .memory-mode-card,
+ .memory-mode-cards--grid .memory-mode-card-choice {
min-height: 0;
}
- .memory-mode-description {
+ .memory-mode-cards--grid .memory-mode-description {
min-height: 0;
}
}
diff --git a/memhub/apps/web/src/onboarding-r6.css b/memhub/apps/web/src/onboarding-r6.css
index b777ef9..5dbbc33 100644
--- a/memhub/apps/web/src/onboarding-r6.css
+++ b/memhub/apps/web/src/onboarding-r6.css
@@ -634,6 +634,12 @@
font-size: 13px;
line-height: 20px;
}
+@media (min-width: 761px) {
+ .onboarding-r6-mode {
+ min-height: 100%;
+ gap: 24px;
+ }
+}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;