Compare commits

..
37 changed files with 199 additions and 843 deletions

No files matched your search

+1 -1
View File
@@ -105,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-04
Build 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
Build 43, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 44),GitHub 仓库为
(build 43),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+36 -82
View File
@@ -1786,7 +1786,6 @@ def create_app(
else None
)
app.state.live_sync_watch_lock = asyncio.Lock()
app.state.source_activation_tasks = {}
app.state.live_sync_supervisor.set_watched_sources(
watch_roots if watching and runtime_settings.live_sync_enabled else ()
)
@@ -2122,11 +2121,6 @@ def create_app(
app.state.memory_governance_store.wait_content_candidate_backfill,
5.0,
)
source_activation_tasks = tuple(app.state.source_activation_tasks.values())
for task in source_activation_tasks:
task.cancel()
if source_activation_tasks:
await asyncio.gather(*source_activation_tasks, return_exceptions=True)
if app.state.live_sync_watch is not None:
await app.state.live_sync_watch.shutdown()
await app.state.live_sync_supervisor.shutdown()
@@ -3269,69 +3263,6 @@ def create_app(
"""`POST /api/v1/import-jobs` on one user's lane (Hermes only for another user)."""
return await create_job_on(request.source_id, request.operation, request.user_id)
async def _activate_connected_sources(
request_id: str,
source_ids: tuple[str, ...],
*,
enable_sync: bool,
) -> None:
"""Apply one committed connection intent without holding its HTTP response."""
try:
for source_id in source_ids:
if enable_sync:
await _live_sync(app).initialize_source(
source_id,
baseline_missing_files=True,
)
else:
await _live_sync(app).stop_source(source_id)
if enable_sync:
for source_id in source_ids:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
except asyncio.CancelledError:
raise
except Exception as error:
diagnostic_event(
level="error",
component="source.connection",
event="source.connection.activation_failed",
outcome="failed",
**diagnostic_exception(error),
details={"request_id": request_id, "source_ids": list(source_ids)},
)
raise
def _schedule_source_activation(
request_id: str,
source_ids: tuple[str, ...],
*,
enable_sync: bool,
) -> None:
tasks = app.state.source_activation_tasks
existing = tasks.get(request_id)
if existing is not None and not existing.done():
return
task = watched_task(
asyncio.get_running_loop(),
lambda: _activate_connected_sources(
request_id,
source_ids,
enable_sync=enable_sync,
),
component="source.connection",
name=f"source-connection-{request_id}",
)
if task is None:
return
tasks[request_id] = task
def forget(completed: asyncio.Future[None]) -> None:
if tasks.get(request_id) is completed:
tasks.pop(request_id, None)
task.add_done_callback(forget)
@app.post(
"/api/v1/sources/connect",
response_model=ConnectSourcesResponse,
@@ -3421,21 +3352,44 @@ def create_app(
if grant is not None:
_supervisor(app).register_adapter(registration.create_adapter(grant))
_detection(app).pop(job.source_id, None)
if request.enable_sync:
await _prepare_source_resume(job.source_id)
else:
await _live_sync(app).stop_source(job.source_id)
configured.append(job.source_id)
if configured:
await asyncio.to_thread(
_config_store(app).set_sync_many,
{source_id: request.enable_sync for source_id in configured},
)
if request.enable_sync:
for job in result.jobs:
if job.source_id in configured and job.status is ImportStatus.PENDING:
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
_schedule_source_activation(
request.request_id,
tuple(configured),
enable_sync=request.enable_sync,
)
previous = {
source_id: _config_store(app).sync_enabled(source_id) for source_id in configured
}
try:
await asyncio.to_thread(
_config_store(app).set_sync_many,
{source_id: request.enable_sync for source_id in configured},
)
except BaseException:
for source_id in configured:
if request.enable_sync:
await _live_sync(app).stop_source(source_id)
elif previous[source_id]:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
try:
if request.enable_sync:
for source_id in configured:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
except BaseException:
await asyncio.to_thread(_config_store(app).set_sync_many, previous)
for source_id in configured:
if request.enable_sync:
await _live_sync(app).stop_source(source_id)
elif previous[source_id]:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
return ConnectSourcesResponse(**result.model_dump())
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
@@ -376,9 +376,8 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a
feature, the language never touches `vector_store`, so the collection
identity never depends on it.
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
"""
if language is not None:
config.language = language
@@ -197,82 +197,6 @@ def test_connection_retry_reuses_completed_job_and_checks_root_snapshot(tmp_path
asyncio.run(scenario())
def test_connection_returns_committed_receipt_before_live_activation_finishes(
tmp_path, monkeypatch
):
monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
async def scenario():
home = tmp_path / "home"
root = home / ".claude/projects"
root.mkdir(parents=True)
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=home,
api_token=SecretStr("test"),
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog=build_source_catalog(home=home, environment={}),
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer test"},
) as client,
):
found = await client.post("/api/v1/sources/claude-code/location")
started = asyncio.Event()
release = asyncio.Event()
original = app.state.live_sync_supervisor.initialize_source
async def blocked_initialize(source_id, *, baseline_missing_files):
started.set()
await release.wait()
return await original(
source_id,
baseline_missing_files=baseline_missing_files,
)
monkeypatch.setattr(
app.state.live_sync_supervisor,
"initialize_source",
blocked_initialize,
)
try:
response = await asyncio.wait_for(
client.post(
"/api/v1/sources/connect",
json={
"source_ids": ["claude-code"],
"request_id": "fast-receipt",
"source_roots": {
"claude-code": found.json()["root_id"],
},
"onboarding": True,
"full_rescan": True,
"enable_sync": True,
},
),
timeout=1,
)
assert response.status_code == 200
assert response.json()["request_id"] == "fast-receipt"
assert app.state.config_store.sync_enabled("claude-code")
await asyncio.wait_for(started.wait(), timeout=1)
assert not release.is_set()
finally:
release.set()
tasks = tuple(app.state.source_activation_tasks.values())
if tasks:
await asyncio.gather(*tasks)
asyncio.run(scenario())
@pytest.mark.parametrize(
("turn_count", "age_days", "limited"),
[
+9 -57
View File
@@ -72,23 +72,15 @@ it("localizes the whole home live without repeating reads or changing imported t
expect(summary).toHaveBeenCalledTimes(calls);
});
const {
summary,
projection,
processing,
sedimentation,
history,
liveSync,
evolve,
} = vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
evolve: vi.fn(),
}));
const { summary, projection, processing, sedimentation, history, liveSync } =
vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
@@ -113,7 +105,6 @@ vi.mock("./api", () => ({
getSdkRaw: vi.fn(),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getEvolveOverview: evolve,
getSedimentation: sedimentation,
getSources: vi.fn().mockResolvedValue([]),
}));
@@ -155,12 +146,6 @@ beforeEach(() => {
history
.mockReset()
.mockResolvedValue({ available: true, recording_error: false, items: [] });
evolve.mockReset().mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
imports.refresh = vi.fn();
summary.mockReset().mockResolvedValue({
memory_count: 601,
@@ -955,39 +940,6 @@ it.each([
},
);
it("keeps the home snapshot visible during Evolve and refreshes when it ends", async () => {
evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [
{
run_id: "run-1",
user_id: "local",
state: "running",
},
],
});
render(mountHome());
expect(await screen.findByText("601")).toBeTruthy();
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
const readsBeforeCompletion = projection.mock.calls.length;
act(() => {
queryClient.setQueryData(["evolve", "local"], {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
});
await waitFor(() =>
expect(projection.mock.calls.length).toBeGreaterThan(readsBeforeCompletion),
);
});
afterEach(() => {
cleanup();
resetClient();
-17
View File
@@ -41,7 +41,6 @@ import {
retryAutoReconcile,
useAutoConnect,
} from "./connection-reconcile";
import { localEvolveRunning, useEvolveState } from "./evolve-state";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
@@ -56,7 +55,6 @@ import {
sourceConnectionIncomplete,
useRecallHosts,
} from "./host-state";
import { useOnboardingTranslation } from "./onboarding-language";
import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { prefetchDataRuntime } from "./runtime-state";
@@ -211,10 +209,6 @@ export function ApplicationsHome({
active?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const { text } = useOnboardingTranslation();
const evolveState = useEvolveState(active);
const evolveRunning = localEvolveRunning(evolveState.data);
const evolveUncertain = evolveState.isError && evolveRunning;
const hostQuery = useRecallHosts({ poll: active });
const hostBridge = hostBridgeAvailable();
const hosts = hostQuery.data ?? [];
@@ -319,12 +313,6 @@ export function ApplicationsHome({
const [expanded, setExpanded] = useState(retained.expanded);
const [phase, setPhase] = useState("idle");
const [revision, setRevision] = useState(0);
const previousEvolveRunning = useRef(evolveRunning);
useEffect(() => {
const previous = previousEvolveRunning.current;
previousEvolveRunning.current = evolveRunning;
if (previous && !evolveRunning && active) setRevision((value) => value + 1);
}, [active, evolveRunning]);
const confirmedModeSeen = useRef(confirmedMode);
const expandButton = useRef<HTMLButtonElement>(null);
const backButton = useRef<HTMLButtonElement>(null);
@@ -965,11 +953,6 @@ export function ApplicationsHome({
<header>
<h2>{t("apps.overview")}</h2>
</header>
{evolveRunning ? (
<p className="r6-evolve-status" role="status">
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
</p>
) : null}
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t(viewModel.countLabel)}</p>
+7 -21
View File
@@ -46,7 +46,6 @@ export function defaultHostSourceIds(
export type ImportConnectionDraft = {
hostSourceIds: readonly string[];
};
export type ImportSubmissionState = "confirmed" | "checking" | "refused";
export interface FirstImportReviewProps {
sourceIds: readonly string[];
sources: SourceResponse[];
@@ -55,7 +54,6 @@ export interface FirstImportReviewProps {
acceptedJobIds?: readonly string[];
failedSources?: Record<string, string>;
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
importSubmissionState?: ImportSubmissionState;
busy?: boolean;
error?: string | null;
connectionNote?: string;
@@ -80,7 +78,6 @@ export function FirstImportReview({
acceptedJobIds,
failedSources = {},
sourceSnapshot,
importSubmissionState = "confirmed",
busy = false,
error,
connectionNote,
@@ -332,24 +329,13 @@ export function FirstImportReview({
>
<div className="first-import-r6-intro">
<h2>{text("正在导入你的会话")}</h2>
{importSubmissionState === "checking" ? (
<p className="first-import-r6-submission-status" role="status">
{t("onboardingImportChecking")}
</p>
) : (
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
)}
{ids.length > 3 && (
<p className="first-import-r6-scroll-hint">
{t("onboardingMoreImportApps", { count: ids.length - 3 })}
</p>
)}
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
</div>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
@@ -22,7 +22,6 @@ import {
setMemoryMode,
} from "./api";
import { MemoryModeSettings } from "./MemoryModeSettings";
import { resetClient } from "./query-client";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
@@ -82,7 +81,6 @@ async function chooseMode(
afterEach(() => {
cleanup();
resetClient();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
+18 -19
View File
@@ -1,4 +1,6 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
@@ -9,6 +11,7 @@ import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
@@ -22,7 +25,6 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -45,11 +47,7 @@ export function MemoryModeSettings({
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
const evolveState = useEvolveState();
const overview = evolveState.data ?? null;
const run =
overview?.running.find((candidate) => candidate.user_id === "local") ??
null;
const [overview, setOverview] = useState<EvolveOverview | null>(null);
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
@@ -58,6 +56,7 @@ export function MemoryModeSettings({
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
@@ -84,10 +83,11 @@ export function MemoryModeSettings({
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, processingResult] =
const [modeResult, scheduleResult, overviewResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
@@ -103,6 +103,10 @@ export function MemoryModeSettings({
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
@@ -112,11 +116,14 @@ export function MemoryModeSettings({
}, [refresh]);
useEffect(() => {
const running = maintenance || state?.graph_build.state === "running";
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, state?.graph_build.state]);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
@@ -195,21 +202,13 @@ export function MemoryModeSettings({
}
async function evolveNow() {
if (!state || !schedule) return;
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
commitEvolveRun(
receipt,
overview ?? {
mode: state.mode,
schedule,
users: {},
running: [],
},
);
setRun(receipt);
await refresh();
} catch (reason) {
const code = apiErrorCode(reason);
setError(
+1 -97
View File
@@ -12,7 +12,6 @@ import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemorySpace } from "./MemorySpace";
import { queryClient, resetClient } from "./query-client";
it("translates memory filters and detail affordances live without refetching", async () => {
setup();
@@ -80,7 +79,6 @@ const { reads } = vi.hoisted(() => ({
raw: vi.fn(),
rawGraph: vi.fn(),
rawSearch: vi.fn(),
evolve: vi.fn(),
},
}));
vi.mock("./api", async (importOriginal) => ({
@@ -92,7 +90,6 @@ vi.mock("./api", async (importOriginal) => ({
getSdkRaw: reads.raw,
getSdkRawGraph: reads.rawGraph,
searchSdkRaw: reads.rawSearch,
getEvolveOverview: reads.evolve,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
@@ -122,19 +119,13 @@ vi.mock("./MemoryAddComposer", () => ({
),
}));
vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: ({ graph }: { graph?: { nodes?: unknown[] } | null }) => (
<div
data-testid="graph-owner"
data-graph-nodes={graph?.nodes?.length ?? 0}
/>
),
MemoryGraph3D: () => <div data-testid="graph-owner" />,
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
}));
afterEach(() => {
cleanup();
resetClient();
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -168,94 +159,7 @@ it("translates the model-required prompt without configuring a model", async ()
localStorage.clear();
});
it("keeps the memory snapshot visible during Evolve and refreshes when it ends", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("ultra"));
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [
{
run_id: "run-1",
user_id: "local",
state: "running",
},
],
});
render(<MemorySpace simplified />);
expect((await screen.findAllByText("已保存的记忆")).length).toBeGreaterThan(
0,
);
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
const readsBeforeCompletion = reads.sdkLibrary.mock.calls.length;
act(() => {
queryClient.setQueryData(["evolve", "local"], {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
});
await waitFor(() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(
readsBeforeCompletion,
),
);
});
it("keeps the last successful graph when its Evolve refresh fails", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("ultra"));
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [{ run_id: "run-1", user_id: "local", state: "running" }],
});
const firstSource = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
sync_enabled: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[firstSource]} />);
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1"),
);
reads.sdkGraph.mockRejectedValueOnce(new Error("evolve write lock"));
view.rerender(
<MemorySpace
simplified
sources={[
firstSource,
{
...firstSource,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.sdkGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1");
});
function setup() {
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
const memories = [
{
memory_id: "m1",
+4 -26
View File
@@ -45,12 +45,10 @@ import {
setExtractionWindow,
updateSdkMemory,
} from "./api";
import { localEvolveRunning, useEvolveState } from "./evolve-state";
import { GraphWidthPrompt } from "./GraphWidthPrompt";
import { useGuiLanguage } from "./gui-language";
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
import { MemoryMap } from "./MemoryMap";
import { useOnboardingTranslation } from "./onboarding-language";
import { subscribePoll } from "./poll-scheduler";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
@@ -121,10 +119,6 @@ export function MemorySpace({
onFocusMemoryConsumed?(): void;
}) {
const { locale, t, tn } = useGuiLanguage();
const { text } = useOnboardingTranslation();
const evolveState = useEvolveState(active || preload);
const evolveRunning = localEvolveRunning(evolveState.data);
const evolveUncertain = evolveState.isError && evolveRunning;
const memoryText = (text: string) => {
const key = `memory:${text}`;
return isMessageKey(key) ? t(key) : text;
@@ -209,12 +203,6 @@ export function MemorySpace({
// Bumping this re-reads the page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
const previousEvolveRunning = useRef(evolveRunning);
useEffect(() => {
const previous = previousEvolveRunning.current;
previousEvolveRunning.current = evolveRunning;
if (previous && !evolveRunning) bumpSdkMap();
}, [bumpSdkMap, evolveRunning]);
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
@@ -269,8 +257,7 @@ export function MemorySpace({
]);
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkGraphRequestedScope &&
(sdkGraph.status === "ready" || evolveRunning)
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "ready"
? sdkGraph.value
: null;
const sdkGraphFailed =
@@ -523,11 +510,7 @@ export function MemorySpace({
}
} catch {
if (cancelled || request.signal.aborted) return;
setSdkGraph((current) => ({
scope,
status: "failed",
value: current?.scope === scope ? current.value : null,
}));
setSdkGraph({ scope, status: "failed", value: null });
} finally {
reading = false;
}
@@ -1204,11 +1187,6 @@ export function MemorySpace({
</div>
)}
</header>
{evolveRunning ? (
<p className="memory-evolve-status" role="status">
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
</p>
) : null}
{processing && (!simplified || managementOpen) ? (
<details className="memory-processing-details">
<summary>
@@ -1394,9 +1372,9 @@ export function MemorySpace({
source: sdkLibrarySource,
page: displayedSdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed && !evolveRunning,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed && !evolveRunning,
graphFailed: sdkGraphFailed,
graphLoaded: visibleGraphProgress?.loaded,
graphTotal: visibleGraphProgress?.total ?? undefined,
stale: false,
+30 -15
View File
@@ -467,10 +467,9 @@ describe("OnboardingFlow S1.3", () => {
name: "对话正在汇入,记忆从这里开始",
}),
).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
expect(
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
);
expect(
screen.queryByRole("heading", { name: "把熟悉的背景,带到这里" }),
).toBeNull();
@@ -553,11 +552,11 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() => {
expect(
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
).toBeTruthy();
});
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
),
);
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
});
@@ -2691,7 +2690,7 @@ describe("OnboardingFlow S1.3", () => {
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
});
it("keeps an unknown connection visible without reporting an error or duplicating the POST", async () => {
it("keeps an unknown connection visible and reconciles its receipt without a duplicate POST", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
vi.stubGlobal("crypto", webcrypto);
@@ -2707,7 +2706,6 @@ describe("OnboardingFlow S1.3", () => {
vi.mocked(api.getSourceConnection).mockRejectedValueOnce(
new Error("receipt offline"),
);
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
function Harness() {
const controller = useImportSources({ automaticSync: false });
return controller.loading ? (
@@ -2725,9 +2723,9 @@ describe("OnboardingFlow S1.3", () => {
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await screen.findByText("正在核对导入结果,已保存的选择不会丢失");
await screen.findByRole("alert");
expect(onFinish).not.toHaveBeenCalled();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
expect(completeOnboarding).not.toHaveBeenCalled();
expect(api.connectSources).toHaveBeenCalledOnce();
const requestId = vi.mocked(api.connectSources).mock.calls[0][2]?.requestId;
@@ -2740,9 +2738,26 @@ describe("OnboardingFlow S1.3", () => {
requestId,
}),
);
await user.click(screen.getByRole("button", { name: START }));
await waitFor(() =>
expect(api.getSourceConnection).toHaveBeenCalledTimes(2),
);
expect(api.getSourceConnection).toHaveBeenLastCalledWith(requestId);
expect(api.connectSources).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledWith(requestId);
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
vi.mocked(api.getSourceConnection).mockResolvedValueOnce({
request_id: requestId,
accepted_source_ids: ["claude"],
failed_sources: {},
jobs: [],
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledTimes(3);
expect(api.createImportJob).not.toHaveBeenCalled();
});
+7 -25
View File
@@ -41,7 +41,6 @@ import {
defaultHostSourceIds,
FirstImportReview,
type ImportConnectionDraft,
type ImportSubmissionState,
} from "./FirstImportReview";
import {
enableHostConnection,
@@ -135,8 +134,6 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [importSubmissionState, setImportSubmissionState] =
useState<ImportSubmissionState>("confirmed");
const resumedStep =
state.mode_intent &&
state.mode_intent !== "lite" &&
@@ -1061,7 +1058,6 @@ export function OnboardingFlow({
async function submitImport(approved: SourceResponse[]) {
sourceFlight.current = true;
setImportSubmissionState("checking");
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -1073,11 +1069,8 @@ export function OnboardingFlow({
try {
current = (await imports.refresh()).sources;
} catch {
if (alive.current) {
setStartingImport(null);
setImportSubmissionState("refused");
if (alive.current)
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
}
return;
}
if (!alive.current) return;
@@ -1100,7 +1093,6 @@ export function OnboardingFlow({
setItemErrors(result.failed_sources);
if (result.accepted_source_ids.length === 0) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
@@ -1109,25 +1101,16 @@ export function OnboardingFlow({
// Keep the accepted intent and job IDs while the user inspects early originals.
setAccepted(result);
setStartingImport(null);
setImportSubmissionState("confirmed");
setError(null);
} catch (reason) {
if (alive.current) {
const refused = isDefiniteAccessRefusal(reason);
if (refused) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
} else {
setImportSubmissionState("checking");
setError(
window.oneMemory
? null
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
}
if (refused) setStartingImport(null);
setError(
refused
? "本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。"
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
}
} finally {
connectSent.current = false;
@@ -1203,7 +1186,6 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
importSubmissionState={importSubmissionState}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
+2 -12
View File
@@ -19,23 +19,13 @@ describe("R6 shared controls", () => {
it("keeps its label and prevents duplicate activation while pending", () => {
const onClick = vi.fn();
const view = render(
<R6Button pending={false} onClick={onClick}>
保存修改
</R6Button>,
);
const button = screen.getByRole("button", { name: "保存修改" });
const reservedSpinner = button.querySelector(".r6-button-spinner");
expect(reservedSpinner).toBeTruthy();
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
view.rerender(
render(
<R6Button pending onClick={onClick}>
保存修改
</R6Button>,
);
const button = screen.getByRole("button", { name: "保存修改" });
expect(button.getAttribute("aria-busy")).toBe("true");
expect(button.querySelector(".r6-button-spinner")).toBe(reservedSpinner);
expect(reservedSpinner?.getAttribute("data-visible")).toBe("true");
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
expect(button.textContent).toContain("保存修改");
+4 -12
View File
@@ -13,32 +13,24 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
export function R6Button({
variant = "secondary",
pending,
pending = false,
disabled,
children,
className = "",
type = "button",
...props
}: ButtonProps) {
const supportsPending = pending !== undefined;
const busy = pending === true;
return (
<button
{...props}
type={type}
className={`r6-button ${className}`}
data-variant={variant}
disabled={disabled || busy}
aria-busy={busy || undefined}
disabled={disabled || pending}
aria-busy={pending || undefined}
>
<span className="r6-button-label">{children}</span>
{supportsPending ? (
<span
className="r6-button-spinner"
data-visible={busy || undefined}
aria-hidden="true"
/>
) : null}
{pending && <span className="r6-button-spinner" aria-hidden="true" />}
</button>
);
}
+2 -34
View File
@@ -160,7 +160,7 @@ it.each(["missing", "pending"] as const)(
},
);
it("centers one to three apps and keeps four or more in a scrolling row", () => {
it("centers one or two apps and keeps three or more in a scrolling row", () => {
function shown(ids: string[]) {
const view = render(
<R6ImportProgress
@@ -181,7 +181,7 @@ it("centers one to three apps and keeps four or more in a scrolling row", () =>
}
expect(shown(["cursor"])).toBe("1");
expect(shown(["cursor", "codex"])).toBe("2");
expect(shown(["cursor", "codex", "claude-code"])).toBe("3");
expect(shown(["cursor", "codex", "claude-code"])).toBe("many");
expect(shown(["cursor", "codex", "claude-code", "workbuddy"])).toBe("many");
const four = render(
<R6ImportProgress
@@ -200,38 +200,6 @@ it("centers one to three apps and keeps four or more in a scrolling row", () =>
.querySelector(".r6-import-progress")
?.getAttribute("data-count"),
).toBe("many");
expect(
four.container
.querySelector(".r6-import-progress")
?.getAttribute("tabindex"),
).toBe("0");
});
it("scrolls one card at a time from the keyboard", () => {
const ids = ["cursor", "codex", "claude-code", "workbuddy"];
render(
<R6ImportProgress
sourceIds={ids}
sources={ids.map((id) => ({
...source,
source_id: id,
display_name: id,
}))}
jobs={[]}
/>,
);
const progress = screen.getByRole("region", {
name: "本次应用导入进度",
});
Object.defineProperty(progress.firstElementChild, "offsetWidth", {
configurable: true,
value: 280,
});
progress.scrollBy = ((options: ScrollToOptions) => {
progress.scrollLeft += Number(options.left ?? 0);
}) as typeof progress.scrollBy;
fireEvent.keyDown(progress, { key: "ArrowRight" });
expect(progress.scrollLeft).toBe(296);
});
it("keeps the first app anchored across receipts unless the user scrolls", () => {
+4 -25
View File
@@ -70,39 +70,18 @@ export function R6ImportProgress({
});
const ids = [...new Set(sourceIds)];
const scrollable = ids.length > 3;
return (
<section
className="r6-import-progress"
aria-label={text("本次应用导入进度")}
aria-keyshortcuts={
scrollable ? "ArrowLeft ArrowRight Home End" : undefined
}
data-count={ids.length <= 3 ? String(ids.length) : "many"}
data-count={ids.length <= 2 ? String(ids.length) : "many"}
ref={scroller}
tabIndex={scrollable ? 0 : -1}
onPointerDown={markUserScrollIntent}
onTouchStart={markUserScrollIntent}
onWheel={markUserScrollIntent}
onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
return;
markUserScrollIntent();
const element = event.currentTarget;
const card = element.querySelector<HTMLElement>(
".r6-import-progress-card",
);
const step = (card?.offsetWidth ?? 0) + 16;
event.preventDefault();
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
element.scrollBy({
left: event.key === "ArrowRight" ? step : -step,
});
} else {
element.scrollTo({
left: event.key === "End" ? element.scrollWidth : 0,
});
}
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
markUserScrollIntent();
}}
onScroll={(event) => {
if (userScrollIntent.current)
@@ -154,7 +133,7 @@ export function R6ImportProgress({
<div className="r6-import-progress-orbit" aria-hidden="true">
<span />
<div className="r6-import-progress-icon">
<AppMark id={id} label={source?.display_name ?? id} size={40} />
<AppMark id={id} label={source?.display_name ?? id} size={30} />
</div>
</div>
<h2>{source?.display_name ?? id}</h2>
-11
View File
@@ -69,17 +69,6 @@
font-size: 18px;
font-weight: 500;
}
.r6-evolve-status {
width: fit-content;
margin: 12px 0 -4px;
padding: 5px 9px;
border: 1px solid #cbd8e0;
border-radius: 999px;
background: #edf3f6;
color: #4f6574;
font-size: 12px;
line-height: 20px;
}
.r6-home-metrics {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
-81
View File
@@ -1,81 +0,0 @@
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
import { afterEach, expect, it, vi } from "vitest";
import { getEvolveOverview } from "./api";
import {
commitEvolveRun,
localEvolveRunning,
refreshEvolveOverview,
} from "./evolve-state";
import { queryClient, resetClient } from "./query-client";
vi.mock("./api", () => ({ getEvolveOverview: vi.fn() }));
const receipt: EvolveRunReceipt = {
run_id: "run-1",
user_id: "local",
agent_id: "all-apps",
max_batches: null,
trigger: "manual",
state: "running",
started_at: "2026-11-05T14:00:00Z",
finished_at: null,
rounds: 0,
tasks_processed: 0,
batch_failures: 0,
cycle_status: null,
should_continue: null,
continuation_reason: null,
remaining_clusters: null,
remaining_actionable_clusters: null,
remaining_fresh_facts: null,
capped: false,
elapsed_ms: 0,
error_code: null,
failure_kind: null,
request_id: "request-1",
};
const overview: EvolveOverview = {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
};
afterEach(() => {
resetClient();
vi.clearAllMocks();
});
it("shares the latest server overview through the query cache", async () => {
vi.mocked(getEvolveOverview).mockResolvedValue(overview);
await expect(refreshEvolveOverview()).resolves.toEqual(overview);
expect(queryClient.getQueryData(["evolve", "local"])).toEqual(overview);
});
it("publishes an accepted local run immediately from a cold cache", () => {
commitEvolveRun(receipt, overview);
const current = queryClient.getQueryData<EvolveOverview>(["evolve", "local"]);
expect(current?.running).toEqual([receipt]);
expect(localEvolveRunning(current)).toBe(true);
});
it("does not let a pre-mutation overview overwrite the accepted run", async () => {
let resolveRead: ((value: EvolveOverview) => void) | undefined;
vi.mocked(getEvolveOverview).mockReturnValue(
new Promise((resolve) => {
resolveRead = resolve;
}),
);
const staleRead = refreshEvolveOverview();
commitEvolveRun(receipt, overview);
resolveRead?.(overview);
await expect(staleRead).resolves.toMatchObject({ running: [receipt] });
expect(
queryClient.getQueryData<EvolveOverview>(["evolve", "local"])?.running,
).toEqual([receipt]);
});
-76
View File
@@ -1,76 +0,0 @@
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
import { useQuery } from "@tanstack/react-query";
import * as api from "./api";
import { onClientReset, queryClient } from "./query-client";
const EVOLVE_KEY = ["evolve", "local"] as const;
queryClient.setQueryDefaults(EVOLVE_KEY, {
gcTime: Number.POSITIVE_INFINITY,
staleTime: 1_000,
});
let mutationGeneration = 0;
let readsStarted = 0;
let readsLanded = 0;
onClientReset(() => {
mutationGeneration = 0;
readsStarted = 0;
readsLanded = 0;
});
export async function refreshEvolveOverview() {
const generation = mutationGeneration;
const order = ++readsStarted;
const next = await api.getEvolveOverview();
const current = queryClient.getQueryData<EvolveOverview>(EVOLVE_KEY);
if (generation !== mutationGeneration || order < readsLanded)
return current ?? next;
readsLanded = order;
queryClient.setQueryData(EVOLVE_KEY, next);
return next;
}
/** Publish an accepted run before polling confirms it so every page agrees. */
export function commitEvolveRun(
receipt: EvolveRunReceipt,
fallback?: EvolveOverview,
) {
mutationGeneration++;
queryClient.setQueryData<EvolveOverview>(EVOLVE_KEY, (current) => {
const base = current ?? fallback;
return base
? {
...base,
running: [
...base.running.filter(
(running) => running.user_id !== receipt.user_id,
),
receipt,
],
}
: current;
});
void queryClient.invalidateQueries({ queryKey: EVOLVE_KEY });
}
export function useEvolveState(active = true) {
return useQuery(
{
queryKey: EVOLVE_KEY,
queryFn: refreshEvolveOverview,
enabled: active,
refetchInterval: (query) =>
query.state.data?.running.length ? 1_500 : 10_000,
},
queryClient,
);
}
export function useEvolveOverview(active = true) {
return useEvolveState(active).data;
}
export function localEvolveRunning(overview?: EvolveOverview | null) {
return overview?.running.some((run) => run.user_id === "local") ?? false;
}
+65 -93
View File
@@ -13,7 +13,6 @@
flex-direction: column;
}
.first-import-r6-intro {
position: relative;
margin: 0 0 8px;
}
.first-import-r6-intro h2 {
@@ -45,104 +44,97 @@
margin-inline: auto;
}
.first-import-r6-main > .r6-import-progress[data-count="1"] {
grid-auto-columns: min(320px, 100%);
justify-content: center;
width: min(680px, 100%);
grid-auto-columns: 100%;
}
.first-import-r6-main > .r6-import-progress[data-count="2"] {
grid-auto-columns: min(320px, calc((100% - 16px) / 2));
justify-content: center;
}
.first-import-r6-main > .r6-import-progress[data-count="3"] {
grid-auto-columns: calc((100% - 32px) / 3);
justify-content: center;
overflow-x: hidden;
}
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: column;
grid-auto-columns: calc((100% - 32px) / 3);
justify-content: start;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-color: rgb(102 128 145 / 50%) transparent;
}
.first-import-r6-main > .r6-import-progress:focus-visible {
outline: 2px solid #557c93;
outline-offset: 3px;
width: min(720px, 100%);
grid-auto-columns: calc((100% - 16px) / 2);
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
min-height: 128px;
padding: 16px 20px;
min-height: 112px;
padding: 12px 18px;
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
grid-template-columns: 82px minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: center;
column-gap: 18px;
column-gap: 16px;
text-align: left;
scroll-snap-align: start;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
width: 86px;
height: 86px;
width: 76px;
height: 76px;
grid-column: 1;
grid-row: 1 / 4;
margin: 0;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-icon {
width: 54px;
height: 54px;
border-radius: 14px;
}
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
grid-column: 2;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
align-self: end;
}
.first-import-r6-intro .first-import-r6-submission-status {
min-height: 26px;
display: flex;
align-items: center;
gap: 7px;
color: #41697d;
font-weight: 500;
white-space: nowrap;
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: row;
grid-auto-columns: unset;
overflow-x: hidden;
}
.first-import-r6-submission-status::before {
content: "";
flex: 0 0 auto;
width: 12px;
height: 12px;
border: 1.5px solid rgb(88 124 144 / 28%);
border-top-color: #587c90;
border-radius: 50%;
animation: first-import-r6-checking 1.2s linear infinite;
.first-import-r6-main
> .r6-import-progress[data-count="many"]:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.first-import-r6-intro:has(.first-import-r6-submission-status) {
display: flex;
align-items: center;
gap: 12px;
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4)):not(
:has(> :nth-child(5))
) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.first-import-r6-intro:has(.first-import-r6-submission-status) h2 {
flex: 0 0 auto;
margin: 0;
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(5)):not(
:has(> :nth-child(6))
) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.first-import-r6-intro .first-import-r6-scroll-hint {
position: absolute;
right: 0;
bottom: 0;
color: #6b7e8b;
font-size: 11px;
line-height: 20px;
white-space: nowrap;
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(6)):not(
:has(> :nth-child(7))
) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.first-import-r6-intro:has(.first-import-r6-scroll-hint) {
padding-right: 190px;
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(7)) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@keyframes first-import-r6-checking {
to {
transform: rotate(360deg);
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-card {
min-height: 112px;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 1px;
padding: 8px;
text-align: center;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-orbit {
width: 54px;
height: 54px;
grid-column: 1;
grid-row: auto;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
:is(h2, p, small) {
grid-column: 1;
align-self: auto;
margin-top: 1px;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-icon {
width: 36px;
height: 36px;
}
.first-import-r6-feed {
flex: 0 0 auto;
@@ -520,22 +512,6 @@ dialog.first-import-r6-original[open] {
gap: 18px;
}
}
@media (min-width: 761px) and (max-height: 859px) {
.onboarding-r6:has(.first-import-r6-layout)
> :is(.onboarding-r6-titlebar, .onboarding-r6-local-footer) {
display: none;
}
.onboarding-r6:has(.first-import-r6-layout) > .onboarding-r6-stage {
min-height: 0;
height: 100dvh;
padding: 10px 24px 2px;
}
.onboarding-r6:has(.first-import-r6-layout)
> .onboarding-r6-stage
> .onboarding-r6-shell {
height: 100%;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-body:has(> .first-import-r6-layout) {
overflow-y: auto;
@@ -654,10 +630,6 @@ dialog.first-import-r6-original[open] {
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.first-import-r6-submission-status::before {
animation: none;
border-color: #587c90;
}
.first-import-r6-selection,
.first-import-r6-selection input::after {
transition: none;
+3 -9
View File
@@ -159,7 +159,7 @@ button.r6-button {
min-height: 44px;
height: 44px;
padding: 0 20px;
gap: 8px;
gap: 0;
border: 1px solid var(--r6-control-edge);
border-radius: 8px;
background: rgb(255 255 255 / 20%);
@@ -244,20 +244,14 @@ button.r6-button[data-variant="quiet"]:disabled {
min-width: 0;
}
.r6-button-spinner {
position: static;
flex: 0 0 auto;
position: absolute;
right: 5px;
width: 12px;
height: 12px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
visibility: hidden;
animation: r6-spin 1.8s linear infinite;
animation-play-state: paused;
}
.r6-button-spinner[data-visible="true"] {
visibility: visible;
animation-play-state: running;
}
.r6-stepper {
+1 -2
View File
@@ -15,8 +15,7 @@
safe center falls back to the start if the pair would overflow a narrow column.
Three or more keep the existing row and scroll horizontally. */
.r6-import-progress[data-count="1"],
.r6-import-progress[data-count="2"],
.r6-import-progress[data-count="3"] {
.r6-import-progress[data-count="2"] {
justify-content: safe center;
overflow-x: hidden;
}
-11
View File
@@ -33,17 +33,6 @@
font-size: 13px;
color: #637786;
}
.r6-memory .memory-evolve-status {
width: fit-content;
margin: -12px 0 20px;
padding: 5px 9px;
border: 1px solid #cbd8e0;
border-radius: 999px;
background: #edf3f6;
color: #4f6574;
font-size: 12px;
line-height: 20px;
}
.r6-memory .memory-pending-add {
display: flex;
align-items: center;
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 44)的发行目标如下:
MemHub `v1.0.6`(build 43)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 44,
"build_number": 43,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
@@ -1063,9 +1063,6 @@ const baseEnglishMessages = {
"少一些重复交代,多一些理解。": "Less repetition. More understanding.",
onboardingMoreApps: "Scroll for more · {count} apps remaining",
"onboardingMoreApps.one": "Scroll for more · {count} app remaining",
onboardingMoreImportApps: "Scroll horizontally · {count} apps remaining",
onboardingImportChecking:
"Checking the import result; your selection is kept",
onboardingAllApps: "All {count} apps shown",
默认位置: "Default location",
自定义位置: "Custom location",
@@ -83,10 +83,6 @@ export const onboardingSpanishMessages = {
onboardingMoreApps: "Desplázate para ver más · Quedan {count} aplicaciones",
"onboardingMoreApps.one":
"Desplázate para ver más · Queda {count} aplicación",
onboardingMoreImportApps:
"Desplázate horizontalmente · Quedan {count} aplicaciones",
onboardingImportChecking:
"Comprobando el resultado de la importación; tu selección se conserva",
onboardingAllApps: "Se muestran las {count} aplicaciones",
默认位置: "Ubicación predeterminada",
自定义位置: "Ubicación personalizada",
@@ -397,8 +393,6 @@ export const onboardingChineseMessages = {
onboardingImportHistory: "导入 {name} 历史",
onboardingMoreApps: "向下查看更多 · 还有 {count} 个应用",
"onboardingMoreApps.one": "向下查看更多 · 还有 {count} 个应用",
onboardingMoreImportApps: "横向滑动查看更多 · 还有 {count} 个应用",
onboardingImportChecking: "正在核对导入结果,已保存的选择不会丢失",
onboardingAllApps: "已显示全部 {count} 个应用",
onboardingImportRefused: "尚未获准导入({reason})。请核对来源后重试。",
onboardingConfirmHistory: "确认这是 {name} 的历史位置",
@@ -1255,9 +1255,6 @@ export const germanMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "Weniger Wiederholung. Mehr Verständnis.",
onboardingMoreApps: "Für mehr scrollen · {count} Apps verbleibend",
"onboardingMoreApps.one": "Für mehr scrollen · {count} App verbleibend",
onboardingMoreImportApps: "Horizontal scrollen · Noch {count} Apps",
onboardingImportChecking:
"Importergebnis wird geprüft; Ihre Auswahl bleibt erhalten",
onboardingAllApps: "Alle {count} Apps angezeigt",
默认位置: "Standardort",
自定义位置: "Eigener Ort",
@@ -1271,10 +1271,6 @@ export const frenchMessages: Record<MessageKey, string> = {
"Faites défiler pour voir plus · {count} applications restantes",
"onboardingMoreApps.one":
"Faites défiler pour voir plus · {count} application restante",
onboardingMoreImportApps:
"Faites défiler horizontalement · encore {count} applications",
onboardingImportChecking:
"Vérification du résultat de l’importation ; votre sélection est conservée",
onboardingAllApps: "Les {count} applications sont affichées",
默认位置: "Emplacement par défaut",
自定义位置: "Emplacement personnalisé",
@@ -1239,9 +1239,6 @@ export const indonesianMessages: Record<MessageKey, string> = {
onboardingMoreApps: "Gulir untuk melihat lainnya · {count} aplikasi lagi",
"onboardingMoreApps.one":
"Gulir untuk melihat lainnya · {count} aplikasi lagi",
onboardingMoreImportApps: "Gulir horizontal · {count} aplikasi lagi",
onboardingImportChecking:
"Memeriksa hasil impor; pilihan Anda tetap tersimpan",
onboardingAllApps: "Semua {count} aplikasi ditampilkan",
默认位置: "Lokasi default",
自定义位置: "Lokasi kustom",
@@ -1234,9 +1234,6 @@ export const japaneseMessages: Record<MessageKey, string> = {
onboardingMoreApps: "スクロールしてさらに表示 · 残り {count} 個のアプリ",
"onboardingMoreApps.one":
"スクロールしてさらに表示 · 残り {count} 個のアプリ",
onboardingMoreImportApps: "横にスクロール · 残り {count} 個のアプリ",
onboardingImportChecking:
"インポート結果を確認しています。選択内容は保持されています",
onboardingAllApps: "全 {count} 個のアプリを表示中",
默认位置: "既定の場所",
自定义位置: "カスタムの場所",
@@ -1210,9 +1210,6 @@ export const koreanMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "반복은 줄이고, 이해는 깊게.",
onboardingMoreApps: "스크롤하여 더 보기 · 앱 {count}개 남음",
"onboardingMoreApps.one": "스크롤하여 더 보기 · 앱 {count}개 남음",
onboardingMoreImportApps: "가로로 스크롤 · 앱 {count}개 남음",
onboardingImportChecking:
"가져오기 결과를 확인하는 중이며 선택 항목은 유지됩니다",
onboardingAllApps: "앱 {count}개 모두 표시됨",
默认位置: "기본 위치",
自定义位置: "사용자 지정 위치",
@@ -1258,9 +1258,6 @@ export const portugueseMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "Menos repetição. Mais compreensão.",
onboardingMoreApps: "Role para ver mais · Apps restantes: {count}",
"onboardingMoreApps.one": "Role para ver mais · {count} app restante",
onboardingMoreImportApps: "Role horizontalmente · {count} apps restantes",
onboardingImportChecking:
"Verificando o resultado da importação; sua seleção foi mantida",
onboardingAllApps: "Todos os apps exibidos ({count})",
默认位置: "Local padrão",
自定义位置: "Local personalizado",
@@ -1244,9 +1244,6 @@ export const russianMessages: Record<MessageKey, string> = {
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
"onboardingMoreApps.one":
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
onboardingMoreImportApps:
"Прокрутите по горизонтали · осталось приложений: {count}",
onboardingImportChecking: "Проверяем результат импорта; ваш выбор сохранён",
onboardingAllApps: "Показаны все приложения: {count}",
默认位置: "Расположение по умолчанию",
自定义位置: "Своё расположение",
@@ -1133,8 +1133,6 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "少一些重複交代,多一些理解。",
onboardingMoreApps: "向下捲動檢視更多 · 還有 {count} 個 App",
"onboardingMoreApps.one": "向下捲動檢視更多 · 還有 {count} 個 App",
onboardingMoreImportApps: "橫向捲動查看更多 · 還有 {count} 個 App",
onboardingImportChecking: "正在確認匯入結果,已保留你的選擇",
onboardingAllApps: "已顯示全部 {count} 個 App",
默认位置: "預設位置",
自定义位置: "自訂位置",