diff --git a/memhub/apps/web/src/ApplicationsHome.test.tsx b/memhub/apps/web/src/ApplicationsHome.test.tsx index ab73fbd..2a369ea 100644 --- a/memhub/apps/web/src/ApplicationsHome.test.tsx +++ b/memhub/apps/web/src/ApplicationsHome.test.tsx @@ -72,15 +72,23 @@ it("localizes the whole home live without repeating reads or changing imported t expect(summary).toHaveBeenCalledTimes(calls); }); -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(), - })); +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(), +})); vi.mock("./call-history-client", async (original) => ({ ...(await original()), getCallHistory: history, @@ -105,6 +113,7 @@ vi.mock("./api", () => ({ getSdkRaw: vi.fn(), getLiveSyncHealth: liveSync, getMemoryProcessing: processing, + getEvolveOverview: evolve, getSedimentation: sedimentation, getSources: vi.fn().mockResolvedValue([]), })); @@ -146,6 +155,12 @@ 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, @@ -940,6 +955,39 @@ 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(); diff --git a/memhub/apps/web/src/ApplicationsHome.tsx b/memhub/apps/web/src/ApplicationsHome.tsx index ea0b035..4fca577 100644 --- a/memhub/apps/web/src/ApplicationsHome.tsx +++ b/memhub/apps/web/src/ApplicationsHome.tsx @@ -41,6 +41,7 @@ import { retryAutoReconcile, useAutoConnect, } from "./connection-reconcile"; +import { localEvolveRunning, useEvolveState } from "./evolve-state"; import { useGuiLanguage } from "./gui-language"; import { HomeCardBoundary } from "./HomeCardBoundary"; import { @@ -55,6 +56,7 @@ 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"; @@ -209,6 +211,10 @@ 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 ?? []; @@ -313,6 +319,12 @@ 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(null); const backButton = useRef(null); @@ -953,6 +965,11 @@ export function ApplicationsHome({

{t("apps.overview")}

+ {evolveRunning ? ( +

+ {text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")} +

+ ) : null}

{t(viewModel.countLabel)}

diff --git a/memhub/apps/web/src/MemoryModeSettings.test.tsx b/memhub/apps/web/src/MemoryModeSettings.test.tsx index 492ca1a..57c80a7 100644 --- a/memhub/apps/web/src/MemoryModeSettings.test.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.test.tsx @@ -22,6 +22,7 @@ import { setMemoryMode, } from "./api"; import { MemoryModeSettings } from "./MemoryModeSettings"; +import { resetClient } from "./query-client"; vi.mock("./api", () => ({ getMemoryMode: vi.fn(), @@ -81,6 +82,7 @@ async function chooseMode( afterEach(() => { cleanup(); + resetClient(); vi.clearAllMocks(); vi.mocked(apiErrorCode).mockReturnValue(null); vi.mocked(memoryModePlanUnsupported).mockReturnValue(false); diff --git a/memhub/apps/web/src/MemoryModeSettings.tsx b/memhub/apps/web/src/MemoryModeSettings.tsx index aad2bae..45ef0e1 100644 --- a/memhub/apps/web/src/MemoryModeSettings.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.tsx @@ -1,6 +1,4 @@ import type { - EvolveOverview, - EvolveRunReceipt, EvolveSchedule, MemoryModeChange, MemoryModePlan, @@ -11,7 +9,6 @@ import { legacyMemoryModePlan } from "@one-memory/operations"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { apiErrorCode, - getEvolveOverview, getEvolveSchedule, getMemoryMode, getMemoryProcessing, @@ -25,6 +22,7 @@ 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"; @@ -47,7 +45,11 @@ export function MemoryModeSettings({ const id = useId(); const [state, setState] = useState(null); const [schedule, setScheduleState] = useState(null); - const [overview, setOverview] = useState(null); + const evolveState = useEvolveState(); + const overview = evolveState.data ?? null; + const run = + overview?.running.find((candidate) => candidate.user_id === "local") ?? + null; const [pendingMode, setPendingMode] = useState( null, ); @@ -56,7 +58,6 @@ export function MemoryModeSettings({ const [maintenance, setMaintenance] = useState(false); const [error, setError] = useState(""); const [readFailed, setReadFailed] = useState(false); - const [run, setRun] = useState(null); const refreshVersion = useRef(0); const dialogOpener = useRef(null); const focusEvolveWhenReady = useRef(false); @@ -83,11 +84,10 @@ export function MemoryModeSettings({ const refresh = useCallback(async () => { const version = ++refreshVersion.current; const modeTicket = issueConfirmedModeTicket("settings"); - const [modeResult, scheduleResult, overviewResult, processingResult] = + const [modeResult, scheduleResult, processingResult] = await Promise.allSettled([ getMemoryMode(), getEvolveSchedule(), - getEvolveOverview(), getMemoryProcessing(), ]); if (version !== refreshVersion.current) return; @@ -103,10 +103,6 @@ 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]); @@ -116,14 +112,11 @@ export function MemoryModeSettings({ }, [refresh]); useEffect(() => { - const running = - maintenance || - state?.graph_build.state === "running" || - run?.state === "running"; + const running = maintenance || state?.graph_build.state === "running"; if (!running) return; const timer = window.setInterval(() => void refresh(), 1500); return () => window.clearInterval(timer); - }, [maintenance, refresh, run?.state, state?.graph_build.state]); + }, [maintenance, refresh, state?.graph_build.state]); const lifecycleBusy = maintenance || @@ -202,13 +195,21 @@ export function MemoryModeSettings({ } async function evolveNow() { + if (!state || !schedule) return; refreshVersion.current++; setBusy(true); setError(""); try { const receipt = await startEvolve(); - setRun(receipt); - await refresh(); + commitEvolveRun( + receipt, + overview ?? { + mode: state.mode, + schedule, + users: {}, + running: [], + }, + ); } catch (reason) { const code = apiErrorCode(reason); setError( diff --git a/memhub/apps/web/src/MemorySpace.r6.test.tsx b/memhub/apps/web/src/MemorySpace.r6.test.tsx index dc9f07f..a27affa 100644 --- a/memhub/apps/web/src/MemorySpace.r6.test.tsx +++ b/memhub/apps/web/src/MemorySpace.r6.test.tsx @@ -12,6 +12,7 @@ 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(); @@ -79,6 +80,7 @@ const { reads } = vi.hoisted(() => ({ raw: vi.fn(), rawGraph: vi.fn(), rawSearch: vi.fn(), + evolve: vi.fn(), }, })); vi.mock("./api", async (importOriginal) => ({ @@ -90,6 +92,7 @@ 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", () => ({ @@ -119,13 +122,19 @@ vi.mock("./MemoryAddComposer", () => ({ ), })); vi.mock("./MemoryGraph3D", () => ({ - MemoryGraph3D: () =>
, + MemoryGraph3D: ({ graph }: { graph?: { nodes?: unknown[] } | null }) => ( +
+ ), })); vi.mock("./RawConversationGraph3D", () => ({ RawConversationGraph3D: () =>
, })); afterEach(() => { cleanup(); + resetClient(); vi.useRealTimers(); vi.clearAllMocks(); }); @@ -159,7 +168,94 @@ 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(); + + 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(); + + await waitFor(() => + expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1"), + ); + reads.sdkGraph.mockRejectedValueOnce(new Error("evolve write lock")); + + view.rerender( + , + ); + + 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", diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx index 2743a35..799cf23 100644 --- a/memhub/apps/web/src/MemorySpace.tsx +++ b/memhub/apps/web/src/MemorySpace.tsx @@ -45,10 +45,12 @@ 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"; @@ -119,6 +121,10 @@ 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; @@ -203,6 +209,12 @@ 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; @@ -257,7 +269,8 @@ export function MemorySpace({ ]); const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope; const visibleSdkGraph = - sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "ready" + sdkGraph?.scope === sdkGraphRequestedScope && + (sdkGraph.status === "ready" || evolveRunning) ? sdkGraph.value : null; const sdkGraphFailed = @@ -510,7 +523,11 @@ export function MemorySpace({ } } catch { if (cancelled || request.signal.aborted) return; - setSdkGraph({ scope, status: "failed", value: null }); + setSdkGraph((current) => ({ + scope, + status: "failed", + value: current?.scope === scope ? current.value : null, + })); } finally { reading = false; } @@ -1187,6 +1204,11 @@ export function MemorySpace({
)} + {evolveRunning ? ( +

+ {text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")} +

+ ) : null} {processing && (!simplified || managementOpen) ? (
@@ -1372,9 +1394,9 @@ export function MemorySpace({ source: sdkLibrarySource, page: displayedSdkPage, loading: sdkLibrary.loading, - failed: sdkLibrary.failed, + failed: sdkLibrary.failed && !evolveRunning, graphLoading: !sdkGraphSettled, - graphFailed: sdkGraphFailed, + graphFailed: sdkGraphFailed && !evolveRunning, graphLoaded: visibleGraphProgress?.loaded, graphTotal: visibleGraphProgress?.total ?? undefined, stale: false, diff --git a/memhub/apps/web/src/R6Controls.test.tsx b/memhub/apps/web/src/R6Controls.test.tsx index 12cfea5..14d7039 100644 --- a/memhub/apps/web/src/R6Controls.test.tsx +++ b/memhub/apps/web/src/R6Controls.test.tsx @@ -19,13 +19,23 @@ describe("R6 shared controls", () => { it("keeps its label and prevents duplicate activation while pending", () => { const onClick = vi.fn(); - render( - + const view = render( + 保存修改 , ); const button = screen.getByRole("button", { name: "保存修改" }); + const reservedSpinner = button.querySelector(".r6-button-spinner"); + expect(reservedSpinner).toBeTruthy(); + expect(reservedSpinner?.getAttribute("data-visible")).toBeNull(); + view.rerender( + + 保存修改 + , + ); 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("保存修改"); diff --git a/memhub/apps/web/src/R6Controls.tsx b/memhub/apps/web/src/R6Controls.tsx index afee6d7..223720c 100644 --- a/memhub/apps/web/src/R6Controls.tsx +++ b/memhub/apps/web/src/R6Controls.tsx @@ -13,24 +13,32 @@ type ButtonProps = ButtonHTMLAttributes & { export function R6Button({ variant = "secondary", - pending = false, + pending, disabled, children, className = "", type = "button", ...props }: ButtonProps) { + const supportsPending = pending !== undefined; + const busy = pending === true; return ( ); } diff --git a/memhub/apps/web/src/applications-home.css b/memhub/apps/web/src/applications-home.css index c0ba34f..6c717b8 100644 --- a/memhub/apps/web/src/applications-home.css +++ b/memhub/apps/web/src/applications-home.css @@ -69,6 +69,17 @@ 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; diff --git a/memhub/apps/web/src/evolve-state.test.ts b/memhub/apps/web/src/evolve-state.test.ts new file mode 100644 index 0000000..d03db6a --- /dev/null +++ b/memhub/apps/web/src/evolve-state.test.ts @@ -0,0 +1,81 @@ +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(["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(["evolve", "local"])?.running, + ).toEqual([receipt]); +}); diff --git a/memhub/apps/web/src/evolve-state.ts b/memhub/apps/web/src/evolve-state.ts new file mode 100644 index 0000000..3156815 --- /dev/null +++ b/memhub/apps/web/src/evolve-state.ts @@ -0,0 +1,76 @@ +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(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(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; +} diff --git a/memhub/apps/web/src/r6-controls.css b/memhub/apps/web/src/r6-controls.css index e8c8a85..e914d36 100644 --- a/memhub/apps/web/src/r6-controls.css +++ b/memhub/apps/web/src/r6-controls.css @@ -159,7 +159,7 @@ button.r6-button { min-height: 44px; height: 44px; padding: 0 20px; - gap: 0; + gap: 8px; border: 1px solid var(--r6-control-edge); border-radius: 8px; background: rgb(255 255 255 / 20%); @@ -244,14 +244,20 @@ button.r6-button[data-variant="quiet"]:disabled { min-width: 0; } .r6-button-spinner { - position: absolute; - right: 5px; + position: static; + flex: 0 0 auto; 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 { diff --git a/memhub/apps/web/src/r6-memory.css b/memhub/apps/web/src/r6-memory.css index 24bd5d9..075d4fc 100644 --- a/memhub/apps/web/src/r6-memory.css +++ b/memhub/apps/web/src/r6-memory.css @@ -33,6 +33,17 @@ 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;