diff --git a/apps/backend/internal/office/retention/health.go b/apps/backend/internal/office/retention/health.go index 67fd613f5d..cfeff6d217 100644 --- a/apps/backend/internal/office/retention/health.go +++ b/apps/backend/internal/office/retention/health.go @@ -11,7 +11,7 @@ import ( ) const ( - fixURL = "/settings/system/data-storage" + fixURL = "/settings/system/storage?tab=office-retention" fixLabel = "Review retention settings" ) diff --git a/apps/backend/internal/office/retention/health_test.go b/apps/backend/internal/office/retention/health_test.go index a768495a26..c35818e09d 100644 --- a/apps/backend/internal/office/retention/health_test.go +++ b/apps/backend/internal/office/retention/health_test.go @@ -58,6 +58,13 @@ func TestChecker_NameAndCategory(t *testing.T) { } } +func TestChecker_IssueFixURLOpensOfficeRetentionTab(t *testing.T) { + got := issue("test", "test", "test") + if got.FixURL != "/settings/system/storage?tab=office-retention" { + t.Fatalf("FixURL = %q, want Office retention tab", got.FixURL) + } +} + func TestChecker_FreshInstallNoSweepNoIssues(t *testing.T) { checker, sweeper, _ := newTestChecker(t) ctx := context.Background() diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 6df9c00856..3916c4c631 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -62,7 +62,6 @@ lib/state/ │ ├── gitlab/ # GitLab MRs, watches, MR automation options │ └── ui/ # preview, connection, active state, sidebar views ├── hydration/ # SSR merge strategies - hooks/domains/{kanban,session,workspace,settings,comments,github,gitlab}/ # Domain-organized hooks lib/api/domains/ # API clients ├── kanban-api, session-api, workspace-api, settings-api, process-api @@ -147,6 +146,7 @@ surface. floating **Save changes** control, navigation guard, and discard flow own persistence. Do not add page-local Save/Cancel controls. Contributor `save` callbacks must reject on failure so the coordinator can report an error; `discard` must restore the contributor's authoritative baseline. +- **Settings tabs:** use `components/settings/settings-tabs.tsx` in `SettingsPageHeader`; preserve drafts with validated URL `tab` state, map discovery fragments to the owning tab, and use 44px controls on phones and coarse pointers. - **Dialog Enter-to-confirm:** the base `@kandev/ui` `DialogContent` / `AlertDialogContent` activate the dialog's semantic action on plain Enter (`packages/ui/src/lib/dialog-default-action.ts`), so per-dialog "submit on Enter" input handlers are unnecessary — let the base own it. diff --git a/apps/web/components/settings/settings-tabs.test.tsx b/apps/web/components/settings/settings-tabs.test.tsx new file mode 100644 index 0000000000..74422f71a2 --- /dev/null +++ b/apps/web/components/settings/settings-tabs.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + SettingsTabs, + SettingsTabsList, + SettingsTabsPanel, + type SettingsTabOption, +} from "./settings-tabs"; + +const databaseTabLabel = "Database"; +const logsTabLabel = "Logs"; +const databasePanelTestId = "database-panel"; +const logsPanelTestId = "logs-panel"; + +const tabs: SettingsTabOption[] = [ + { id: "database", label: databaseTabLabel }, + { id: "logs", label: logsTabLabel }, +]; + +afterEach(cleanup); + +function ExampleTabs() { + const [value, setValue] = useState("database"); + return ( + + + + Database content + + + Logs content + + + ); +} + +describe("SettingsTabs", () => { + it("connects header triggers to panels and uses manual activation", () => { + render(); + + const database = screen.getByRole("tab", { name: databaseTabLabel }); + const logs = screen.getByRole("tab", { name: logsTabLabel }); + expect(database.getAttribute("aria-controls")).toBeTruthy(); + expect(logs.getAttribute("aria-controls")).toBeTruthy(); + expect(database.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId(databasePanelTestId).getAttribute("data-state")).toBe("active"); + expect(screen.getByTestId(logsPanelTestId).getAttribute("data-state")).toBe("inactive"); + expect(screen.getByTestId(logsPanelTestId).textContent).toBe(""); + + fireEvent.keyDown(database, { key: "ArrowRight" }); + expect(database.getAttribute("aria-selected")).toBe("true"); + fireEvent.keyDown(logs, { key: "Enter" }); + expect(logs.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId(logsPanelTestId).getAttribute("data-state")).toBe("active"); + expect(screen.getByTestId(logsPanelTestId).textContent).toContain("Logs content"); + }); + + it("retains visited panel content when selection changes", () => { + render(); + fireEvent.keyDown(screen.getByRole("tab", { name: logsTabLabel }), { key: "Enter" }); + expect(screen.getByTestId(logsPanelTestId).textContent).toContain("Logs content"); + fireEvent.keyDown(screen.getByRole("tab", { name: databaseTabLabel }), { key: "Enter" }); + expect(screen.getByTestId(logsPanelTestId).getAttribute("aria-hidden")).toBe("true"); + expect(screen.getByTestId(logsPanelTestId).textContent).toContain("Logs content"); + expect(screen.getByTestId(databasePanelTestId).getAttribute("aria-hidden")).toBe("false"); + }); +}); diff --git a/apps/web/components/settings/settings-tabs.tsx b/apps/web/components/settings/settings-tabs.tsx new file mode 100644 index 0000000000..c0d1406c65 --- /dev/null +++ b/apps/web/components/settings/settings-tabs.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; +import { cn } from "@/lib/utils"; +import { emitSettingsTargetRequest, settingsTargetFromHash } from "@/lib/settings-discovery/target"; + +export type SettingsTabOption = { + id: string; + label: ReactNode; +}; + +type SettingsTabsContextValue = { + tabs: readonly SettingsTabOption[]; + value: string; + visitedTabs: ReadonlySet; +}; + +const SettingsTabsContext = createContext(null); + +export function SettingsTabs({ + tabs, + value, + onValueChange, + children, +}: { + tabs: readonly SettingsTabOption[]; + value: string; + onValueChange: (value: string) => void; + children: ReactNode; +}) { + const [visitedTabs, setVisitedTabs] = useState>(() => new Set([value])); + + useEffect(() => { + setVisitedTabs((current) => { + if (current.has(value)) return current; + return new Set([...current, value]); + }); + }, [value]); + + useEffect(() => { + const targetId = settingsTargetFromHash(window.location.hash); + if (targetId) emitSettingsTargetRequest(targetId); + }, [value]); + + return ( + + + {children} + + + ); +} + +export function SettingsTabsList({ + ariaLabel, + className, +}: { + ariaLabel: string; + className?: string; +}) { + const context = useContext(SettingsTabsContext); + // i18n-exempt: programmer error for an invalid component composition. + if (!context) throw new Error("SettingsTabsList must be used inside SettingsTabs"); + return ( + + {context.tabs.map((tab) => ( + + {tab.label} + + ))} + + ); +} + +export function SettingsTabsPanel({ + value, + children, + className, + testId, +}: { + value: string; + children: ReactNode; + className?: string; + testId?: string; +}) { + const context = useContext(SettingsTabsContext); + // i18n-exempt: programmer error for an invalid component composition. + if (!context) throw new Error("SettingsTabsPanel must be used inside SettingsTabs"); + const active = context.value === value; + const visited = context.visitedTabs.has(value); + return ( + + {visited ? children : null} + + ); +} diff --git a/apps/web/components/settings/settings-typography.tsx b/apps/web/components/settings/settings-typography.tsx index 857fb4dcb5..05bb3ae659 100644 --- a/apps/web/components/settings/settings-typography.tsx +++ b/apps/web/components/settings/settings-typography.tsx @@ -67,6 +67,7 @@ export function SettingsField({ export type SettingsPageHeaderProps = { title: ReactNode; description?: ReactNode; + tabs?: ReactNode; actions?: ReactNode; titleTestId?: string; className?: string; @@ -75,6 +76,7 @@ export type SettingsPageHeaderProps = { export function SettingsPageHeader({ title, description, + tabs, actions, titleTestId, className, @@ -83,13 +85,18 @@ export function SettingsPageHeader({
-
+

{title}

{description &&

{description}

}
- {actions &&
{actions}
} + {(tabs || actions) && ( +
+ {tabs} + {actions} +
+ )}
); } diff --git a/apps/web/components/settings/system/data-logs-settings.tsx b/apps/web/components/settings/system/data-logs-settings.tsx index bd8049b170..ca7b0ff22d 100644 --- a/apps/web/components/settings/system/data-logs-settings.tsx +++ b/apps/web/components/settings/system/data-logs-settings.tsx @@ -1,16 +1,25 @@ "use client"; +import { useEffect, useState } from "react"; import { Separator } from "@kandev/ui/separator"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { SettingsTarget } from "@/components/settings/settings-target"; +import { + SettingsTabs, + SettingsTabsList, + SettingsTabsPanel, + type SettingsTabOption, +} from "@/components/settings/settings-tabs"; import { BackupsTable } from "@/components/settings/system/backups-table"; import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card"; import { LogViewer } from "@/components/settings/system/log-viewer"; -import { ToolPayloadRetentionCard } from "./tool-payload-retention-card"; -import { RetentionSettingsCard } from "@/components/settings/system/retention-settings-card"; -import { BACKUP_SQL_COMMAND } from "@/components/settings/system/system-route-shell"; +import { useSettingsTab } from "@/hooks/domains/settings/use-settings-tab"; +import { useRouter } from "@/lib/routing/client-router"; +import { settingsTargetFromHash } from "@/lib/settings-discovery/target"; import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system"; +import { ToolPayloadRetentionCard } from "./tool-payload-retention-card"; +import { BACKUP_SQL_COMMAND, SystemRouteShell } from "./system-route-shell"; function SectionHeading({ title, description }: { title: string; description?: string }) { return ( @@ -23,8 +32,14 @@ function SectionHeading({ title, description }: { title: string; description?: s ); } -/** Data & Logs contains database status, backups, and diagnostic logs. */ -export function DataLogsSettings() { +const DATA_LOGS_TARGET_TO_TAB = { + [SYSTEM_SETTINGS_TARGETS.database]: "database", + [SYSTEM_SETTINGS_TARGETS.toolPayloadRetention]: "database", + [SYSTEM_SETTINGS_TARGETS.backups]: "database", + [SYSTEM_SETTINGS_TARGETS.logs]: "logs", +} as const; + +function DatabasePanel() { const { t } = useTranslation(); const backupDirectory = useAppStore((s) => s.system.database?.backup_directory); return ( @@ -37,14 +52,6 @@ export function DataLogsSettings() { - - - - - @@ -63,14 +70,70 @@ export function DataLogsSettings() { /> - - - - -
); } + +function LogsPanel() { + const { t } = useTranslation(); + return ( + + + + + ); +} + +export function DataLogsSettings() { + const { t } = useTranslation(); + const router = useRouter(); + const [locationHash, setLocationHash] = useState(() => + typeof window === "undefined" ? "" : window.location.hash, + ); + const tabs: SettingsTabOption[] = [ + { id: "database", label: t("system:navDatabase") }, + { id: "logs", label: t("system:navLogs") }, + ]; + const { value, selectTab } = useSettingsTab({ + tabs: tabs.map((tab) => tab.id), + defaultTab: "database", + targetToTab: DATA_LOGS_TARGET_TO_TAB, + }); + + useEffect(() => { + const updateHash = () => setLocationHash(window.location.hash); + window.addEventListener("hashchange", updateHash); + window.addEventListener("popstate", updateHash); + return () => { + window.removeEventListener("hashchange", updateHash); + window.removeEventListener("popstate", updateHash); + }; + }, []); + + useEffect(() => { + const targetId = settingsTargetFromHash(locationHash); + if (targetId !== SYSTEM_SETTINGS_TARGETS.retention) return; + const params = new URLSearchParams(window.location.search); + params.set("tab", "office-retention"); + router.replace(`/settings/system/storage?${params.toString()}${locationHash}`, { + scroll: false, + }); + }, [locationHash, router]); + + return ( + + } + > + + + + + + + + + ); +} diff --git a/apps/web/components/settings/system/retention-settings-card.test.tsx b/apps/web/components/settings/system/retention-settings-card.test.tsx index b3bb2a6a1b..99d1dc185f 100644 --- a/apps/web/components/settings/system/retention-settings-card.test.tsx +++ b/apps/web/components/settings/system/retention-settings-card.test.tsx @@ -9,6 +9,8 @@ const saveRetentionSettingsMock = vi.fn(); let saveContributor: SettingsSaveContributor | null = null; let currentRole: "admin" | "member" | undefined = "admin"; const ENABLED_TOGGLE_TEST_ID = "retention-enabled"; +const BATCH_LIMIT_TEST_ID = "retention-batch-limit"; +const EXPECTED_SAVE_CONTRIBUTOR_ERROR = "expected save contributor"; vi.mock("@/lib/api/domains/system-api", () => ({ fetchRetentionStatus: (...args: unknown[]) => fetchRetentionStatusMock(...args), @@ -74,6 +76,37 @@ function renderCard() { ); } +async function correctInvalidBatchLimit() { + const batchLimit = screen.getByTestId(BATCH_LIMIT_TEST_ID); + + fireEvent.change(batchLimit, { target: { value: "0" } }); + + await waitFor(() => { + expect(screen.getByTestId("retention-advanced-settings").getAttribute("open")).toBe(""); + expect(batchLimit).toHaveProperty("disabled", false); + expect(batchLimit.getAttribute("aria-invalid")).toBe("true"); + expect(screen.getByTestId(`${BATCH_LIMIT_TEST_ID}-error`)).toBeTruthy(); + expect(saveContributor?.canSave).toBe(false); + }); + if (!saveContributor) throw new Error(EXPECTED_SAVE_CONTRIBUTOR_ERROR); + + await act(async () => { + await expect(saveContributor?.save(saveContributor.revision)).rejects.toThrow( + "Fix the highlighted retention fields before saving.", + ); + }); + expect(saveRetentionSettingsMock).not.toHaveBeenCalled(); + + fireEvent.change(batchLimit, { target: { value: "5000" } }); + + await waitFor(() => { + expect(batchLimit).toHaveProperty("disabled", false); + expect(batchLimit).toHaveProperty("value", "5000"); + expect(batchLimit.getAttribute("aria-invalid")).toBeNull(); + expect(saveContributor?.canSave).toBe(true); + }); +} + beforeEach(() => { fetchRetentionStatusMock.mockReset(); saveRetentionSettingsMock.mockReset(); @@ -113,7 +146,7 @@ describe("RetentionSettingsCard", () => { renderCard(); await screen.findByTestId(ENABLED_TOGGLE_TEST_ID); fireEvent.click(screen.getByTestId(ENABLED_TOGGLE_TEST_ID)); - if (!saveContributor) throw new Error("expected save contributor"); + if (!saveContributor) throw new Error(EXPECTED_SAVE_CONTRIBUTOR_ERROR); saveRetentionSettingsMock.mockRejectedValueOnce(new Error("offline")); await act(async () => { @@ -124,6 +157,12 @@ describe("RetentionSettingsCard", () => { await screen.findByTestId("retention-save-error"); }); + it("opens Advanced settings and annotates an invalid field before saving", async () => { + renderCard(); + await screen.findByTestId(ENABLED_TOGGLE_TEST_ID); + await correctInvalidBatchLimit(); + }); + it("renders the last sweep outcome, backlog flag, and retained counts", async () => { fetchRetentionStatusMock.mockResolvedValue( statusOf({ @@ -181,7 +220,7 @@ describe("RetentionSettingsCard save/reload consistency", () => { fireEvent.click(toggle); expect(saveRetentionSettingsMock).not.toHaveBeenCalled(); expect(saveContributor?.isDirty).toBe(true); - if (!saveContributor) throw new Error("expected save contributor"); + if (!saveContributor) throw new Error(EXPECTED_SAVE_CONTRIBUTOR_ERROR); saveRetentionSettingsMock.mockResolvedValueOnce(defaultSettings({ enabled: false })); fetchRetentionStatusMock.mockResolvedValueOnce( @@ -200,7 +239,7 @@ describe("RetentionSettingsCard save/reload consistency", () => { renderCard(); await screen.findByTestId(ENABLED_TOGGLE_TEST_ID); fireEvent.click(screen.getByTestId(ENABLED_TOGGLE_TEST_ID)); - if (!saveContributor) throw new Error("expected save contributor"); + if (!saveContributor) throw new Error(EXPECTED_SAVE_CONTRIBUTOR_ERROR); saveRetentionSettingsMock.mockResolvedValueOnce(defaultSettings({ enabled: false })); fetchRetentionStatusMock.mockRejectedValueOnce(new Error("offline")); diff --git a/apps/web/components/settings/system/retention-settings-card.tsx b/apps/web/components/settings/system/retention-settings-card.tsx index c08b97fb35..bcc5c23de0 100644 --- a/apps/web/components/settings/system/retention-settings-card.tsx +++ b/apps/web/components/settings/system/retention-settings-card.tsx @@ -18,23 +18,57 @@ import { import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useRetentionSettings } from "@/hooks/domains/system/use-retention-settings"; -import { formatDateTime } from "@/lib/i18n/formats"; import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system"; -import type { - RetentionSettings, - RetentionStatus, - RetentionTableCensus, - RetentionTableSweepResult, - RetentionSweptTableResult, - RetentionUnknownStatusCount, -} from "@/lib/types/system"; +import type { RetentionSettings } from "@/lib/types/system"; +import { StorageSettingHelp } from "./storage/storage-setting-help"; +import { RetentionStatusCard } from "./retention-status-card"; + +type Translate = ReturnType["t"]; + +function getRetentionInvalidFieldMessage(t: Translate): string { + return t("system:retentionInvalidField"); +} function serialize(settings: RetentionSettings | null): string { return settings ? JSON.stringify(settings) : "loading"; } -function formatUnknownStatuses(unknown: RetentionUnknownStatusCount[]): string { - return unknown.map((u) => `${u.status} (${u.count})`).join(", "); +type RetentionFieldId = + | "retention-sweep-interval" + | "retention-batch-limit" + | "retention-routine-runs-window-days" + | "retention-routine-runs-floor-per-owner" + | "retention-runs-window-days" + | "retention-runs-floor-per-owner" + | "retention-routine-runs-warn-rows" + | "retention-runs-warn-rows" + | "retention-run-events-warn-rows"; + +function isOutsideRange(value: number, min: number, max?: number): boolean { + return !Number.isFinite(value) || value < min || (max !== undefined && value > max); +} + +function invalidRetentionFields(settings: RetentionSettings): Set { + const invalid = new Set(); + const addIfInvalid = (field: RetentionFieldId, value: number, min: number, max?: number) => { + if (isOutsideRange(value, min, max)) invalid.add(field); + }; + + addIfInvalid("retention-sweep-interval", settings.sweep_interval_hours, 1, 168); + addIfInvalid("retention-batch-limit", settings.batch_limit, 100, 100000); + addIfInvalid("retention-routine-runs-window-days", settings.routine_runs.window_days, 1, 3650); + addIfInvalid( + "retention-routine-runs-floor-per-owner", + settings.routine_runs.floor_per_owner, + 0, + 10000, + ); + addIfInvalid("retention-runs-window-days", settings.runs.window_days, 1, 3650); + addIfInvalid("retention-runs-floor-per-owner", settings.runs.floor_per_owner, 0, 10000); + addIfInvalid("retention-routine-runs-warn-rows", settings.routine_runs.warn_rows, 0); + addIfInvalid("retention-runs-warn-rows", settings.runs.warn_rows, 0); + addIfInvalid("retention-run-events-warn-rows", settings.run_events.warn_rows, 0); + return invalid; } function NumberField({ @@ -44,6 +78,8 @@ function NumberField({ min, max, disabled, + invalid, + invalidMessage, onChange, testId, }: { @@ -53,12 +89,17 @@ function NumberField({ min: number; max?: number; disabled?: boolean; + invalid?: boolean; + invalidMessage?: string; onChange: (value: number) => void; testId: string; }) { return (
- {label} +
+ {label} + {help} +
onChange(Number(event.target.value))} - className={settingsControlClassName("h-11")} + className={settingsControlClassName()} + aria-invalid={invalid || undefined} + aria-describedby={invalid ? `${testId}-error` : undefined} data-testid={testId} /> - {help} + {invalid && ( +

+ {invalidMessage} +

+ )}
); } @@ -80,6 +131,7 @@ function useRetentionDraft(remote: ReturnType, isAd const [draft, setDraft] = useState(null); const previousSaved = useRef(null); const saved = remote.status?.settings ?? null; + const invalidFields = draft ? invalidRetentionFields(draft) : new Set(); useEffect(() => { if (!saved) return; @@ -93,17 +145,24 @@ function useRetentionDraft(remote: ReturnType, isAd const isDirty = Boolean(draft && saved && serialize(draft) !== serialize(saved)); const canEdit = isAdmin && !remote.isLoading && Boolean(saved); - const invalidReason = !isAdmin ? t("system:retentionAdminOnly") : undefined; + const canSave = canEdit && invalidFields.size === 0; + let invalidReason: string | undefined; + if (!isAdmin) { + invalidReason = t("system:retentionAdminOnly"); + } else if (invalidFields.size > 0) { + invalidReason = t("system:retentionInvalidFields"); + } useSettingsSaveContributor({ id: "system:retention", order: 25, revision: serialize(draft), isDirty, - canSave: canEdit, + canSave, invalidReason, save: async () => { if (!draft) return; + if (invalidFields.size > 0) throw new Error(t("system:retentionInvalidFields")); await remote.save(draft); }, discard: () => { @@ -111,7 +170,7 @@ function useRetentionDraft(remote: ReturnType, isAd }, }); - return { draft, setDraft, saved, canEdit }; + return { draft, setDraft, saved, canEdit, canSave, invalidFields }; } function RetentionEnabledRow({ @@ -149,10 +208,12 @@ function RetentionEnabledRow({ function RetentionScheduleFields({ settings, disabled, + invalidFields, onChange, }: { settings: RetentionSettings; disabled: boolean; + invalidFields: ReadonlySet; onChange: (settings: RetentionSettings) => void; }) { const { t } = useTranslation(); @@ -165,6 +226,8 @@ function RetentionScheduleFields({ min={1} max={168} disabled={disabled} + invalid={invalidFields.has("retention-sweep-interval")} + invalidMessage={getRetentionInvalidFieldMessage(t)} onChange={(sweep_interval_hours) => onChange({ ...settings, sweep_interval_hours })} testId="retention-sweep-interval" /> @@ -175,6 +238,8 @@ function RetentionScheduleFields({ min={100} max={100000} disabled={disabled} + invalid={invalidFields.has("retention-batch-limit")} + invalidMessage={getRetentionInvalidFieldMessage(t)} onChange={(batch_limit) => onChange({ ...settings, batch_limit })} testId="retention-batch-limit" /> @@ -197,7 +262,7 @@ function TableSection({

{title}

{description}

-
{children}
+
{children}
); } @@ -210,6 +275,7 @@ function WindowedTableSection({ description, settings, disabled, + invalidFields, onChange, }: { tableKey: WindowedTableKey; @@ -217,6 +283,7 @@ function WindowedTableSection({ description: string; settings: RetentionSettings; disabled: boolean; + invalidFields: ReadonlySet; onChange: (settings: RetentionSettings) => void; }) { const { t } = useTranslation(); @@ -231,58 +298,29 @@ function WindowedTableSection({ min={1} max={3650} disabled={disabled} + invalid={invalidFields.has(`${testPrefix}-window-days` as RetentionFieldId)} + invalidMessage={getRetentionInvalidFieldMessage(t)} onChange={(window_days) => onChange({ ...settings, [tableKey]: { ...table, window_days } })} testId={`${testPrefix}-window-days`} /> onChange({ ...settings, [tableKey]: { ...table, floor_per_owner } }) } testId={`${testPrefix}-floor-per-owner`} /> - onChange({ ...settings, [tableKey]: { ...table, warn_rows } })} - testId={`${testPrefix}-warn-rows`} - /> - - ); -} - -function RunEventsSection({ - settings, - disabled, - onChange, -}: { - settings: RetentionSettings; - disabled: boolean; - onChange: (settings: RetentionSettings) => void; -}) { - const { t } = useTranslation(); - return ( - - onChange({ ...settings, run_events: { warn_rows } })} - testId="retention-run-events-warn-rows" - /> ); } @@ -290,10 +328,12 @@ function RunEventsSection({ function RoutineRunsAndRunsSections({ settings, disabled, + invalidFields, onChange, }: { settings: RetentionSettings; disabled: boolean; + invalidFields: ReadonlySet; onChange: (settings: RetentionSettings) => void; }) { const { t } = useTranslation(); @@ -301,32 +341,151 @@ function RoutineRunsAndRunsSections({ <> - ); } +function WarningThresholdFields({ + settings, + disabled, + invalidFields, + onChange, +}: { + settings: RetentionSettings; + disabled: boolean; + invalidFields: ReadonlySet; + onChange: (settings: RetentionSettings) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t("system:retentionWarningThresholdsTitle")}

+

+ {t("system:retentionWarningThresholdsDescription")} +

+
+
+ + onChange({ ...settings, routine_runs: { ...settings.routine_runs, warn_rows } }) + } + testId="retention-routine-runs-warn-rows" + /> + onChange({ ...settings, runs: { ...settings.runs, warn_rows } })} + testId="retention-runs-warn-rows" + /> + onChange({ ...settings, run_events: { warn_rows } })} + testId="retention-run-events-warn-rows" + /> +
+
+ ); +} + +function AdvancedRetentionSettings({ + settings, + disabled, + invalidFields, + onChange, +}: { + settings: RetentionSettings; + disabled: boolean; + invalidFields: ReadonlySet; + onChange: (settings: RetentionSettings) => void; +}) { + const { t } = useTranslation(); + const advancedFieldIds: RetentionFieldId[] = [ + "retention-sweep-interval", + "retention-batch-limit", + "retention-routine-runs-warn-rows", + "retention-runs-warn-rows", + "retention-run-events-warn-rows", + ]; + const hasInvalidAdvancedField = advancedFieldIds.some((field) => invalidFields.has(field)); + const [open, setOpen] = useState(false); + useEffect(() => { + if (hasInvalidAdvancedField) setOpen(true); + }, [hasInvalidAdvancedField]); + return ( +
{ + if (!hasInvalidAdvancedField) setOpen(event.currentTarget.open); + }} + > + + {t("system:retentionAdvancedSettingsTitle")} + +

+ {t("system:retentionAdvancedSettingsDescription")} +

+ + +
+ ); +} + function RetentionPolicyCard({ draft, canEdit, + invalidFields, onChange, }: { draft: RetentionSettings; canEdit: boolean; + invalidFields: ReadonlySet; onChange: (settings: RetentionSettings) => void; }) { const { t } = useTranslation(); @@ -343,8 +502,21 @@ function RetentionPolicyCard({ /> - - + + +

+ {t("system:retentionActiveWorkProtection")} +

{!canEdit && (

{t("system:retentionAdminOnly")}

)} @@ -353,160 +525,6 @@ function RetentionPolicyCard({ ); } -function SweptTableRow({ label, result }: { label: string; result: RetentionSweptTableResult }) { - const { t } = useTranslation(); - return ( -
- {label} - - {t("system:retentionDeletedLabel")}: {result.deleted} - - {result.previewed && ( - - {t("system:retentionWouldDeleteLabel")}: {result.would_delete} - - )} - {result.backlog && ( - - {t("system:retentionBacklogLabel")} - - )} - {result.error && ( - - {t("system:retentionTableErrorLabel")}: {result.error} - - )} -
- ); -} - -function SatelliteTableRow({ - label, - result, -}: { - label: string; - result: RetentionTableSweepResult; -}) { - const { t } = useTranslation(); - return ( -
- {label} - - {t("system:retentionDeletedLabel")}: {result.deleted} - - {result.error && ( - - {t("system:retentionTableErrorLabel")}: {result.error} - - )} -
- ); -} - -function LastSweepSection({ status }: { status: RetentionStatus }) { - const { t } = useTranslation(); - const lastSweep = status.last_sweep; - if (!lastSweep) { - return ( -

- {t("system:retentionNeverSweptMessage")} -

- ); - } - return ( -
-

- {t("system:retentionSweepStartedAtLabel")}: {formatDateTime(lastSweep.started_at)} - {" · "} - {t("system:retentionSweepFinishedAtLabel")}: {formatDateTime(lastSweep.finished_at)} -

- - - - - -
- ); -} - -function RetainedCountRow({ label, census }: { label: string; census: RetentionTableCensus }) { - const { t } = useTranslation(); - if (census.state === "not_computed") { - return ( -
- {label} - {t("system:retentionCensusNotComputed")} -
- ); - } - return ( -
- {label} - {census.retained_count} - - {t("system:retentionCensusAsOfLabel")}: {formatDateTime(census.as_of)} - - {census.state === "stale" && ( - {t("system:retentionCensusStale")} - )} - {census.unknown_statuses && census.unknown_statuses.length > 0 && ( - - {t("system:retentionUnknownStatusesLabel")}:{" "} - {formatUnknownStatuses(census.unknown_statuses)} - - )} - {census.top_routine_id && ( - - {t("system:retentionTopRoutineShareLabel")}:{" "} - {Math.round((census.top_routine_share ?? 0) * 100)}% ({census.top_routine_id}) - - )} -
- ); -} - -function RetentionStatusCard({ status }: { status: RetentionStatus | null }) { - const { t } = useTranslation(); - if (!status) return null; - return ( - - - - -
-

{t("system:retentionRetainedCountsTitle")}

- - - -
- {status.skip_count > 0 && ( -

- {t("system:retentionSkipCountLabel")}: {status.skip_count} - {status.last_skip_at && ( - <> - {" · "} - {t("system:retentionLastSkipAtLabel")}: {formatDateTime(status.last_skip_at)} - - )} -

- )} -
-
- ); -} - function RetentionSettingsLoading() { const { t } = useTranslation(); return ( @@ -534,7 +552,7 @@ function RetentionSettingsLoadError({ error }: { error: string }) { export function RetentionSettingsCard() { const remote = useRetentionSettings(); const isAdmin = useIsAdmin(); - const { draft, setDraft, canEdit } = useRetentionDraft(remote, isAdmin); + const { draft, setDraft, canEdit, invalidFields } = useRetentionDraft(remote, isAdmin); const { t } = useTranslation(); if (remote.isLoading && !remote.status) return ; @@ -542,8 +560,15 @@ export function RetentionSettingsCard() { return (
- {draft && } + {draft && ( + + )} {remote.saveError && ( diff --git a/apps/web/components/settings/system/retention-status-card.tsx b/apps/web/components/settings/system/retention-status-card.tsx new file mode 100644 index 0000000000..c7d7c79610 --- /dev/null +++ b/apps/web/components/settings/system/retention-status-card.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { CardContent } from "@kandev/ui/card"; +import { useTranslation } from "react-i18next"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/settings-card-header"; +import { formatDateTime } from "@/lib/i18n/formats"; +import { buildRetentionStatusViewModel } from "@/lib/utils/retention-status"; +import type { + RetentionStatus, + RetentionTableCensus, + RetentionTableSweepResult, + RetentionSweptTableResult, + RetentionUnknownStatusCount, +} from "@/lib/types/system"; + +function formatUnknownStatuses(unknown: RetentionUnknownStatusCount[]): string { + return unknown.map((item) => `${item.status} (${item.count})`).join(", "); +} + +type RetentionSweepEntry = { + id: string; + label: string; + result: RetentionTableSweepResult; + preview?: RetentionSweptTableResult; +}; + +function getRetentionSweepEntries( + lastSweep: NonNullable, + labels: { + routineHistory: string; + agentRunHistory: string; + runEvents: string; + providerAttempts: string; + runSkillRecords: string; + }, +): RetentionSweepEntry[] { + return [ + { + id: "office_routine_runs", + label: labels.routineHistory, + result: lastSweep.office_routine_runs, + preview: lastSweep.office_routine_runs, + }, + { + id: "runs", + label: labels.agentRunHistory, + result: lastSweep.runs, + preview: lastSweep.runs, + }, + { id: "run_events", label: labels.runEvents, result: lastSweep.run_events }, + { + id: "office_run_route_attempts", + label: labels.providerAttempts, + result: lastSweep.route_attempts, + }, + { + id: "office_run_skills", + label: labels.runSkillRecords, + result: lastSweep.run_skills, + }, + ]; +} + +function SweepTableRow({ + label, + targetId, + result, + preview, +}: { + label: string; + targetId: string; + result: RetentionTableSweepResult; + preview?: RetentionSweptTableResult; +}) { + const { t } = useTranslation(); + return ( +
+ {label} + + {t("system:retentionDeletedLabel")}: {result.deleted} + + {preview?.previewed && ( + + {t("system:retentionWouldDeleteLabel")}: {preview.would_delete} + + )} + {result.backlog && ( + {t("system:retentionBacklogLabel")} + )} + {result.error && ( + + {t("system:retentionTableErrorLabel")}: {result.error} + + )} +
+ ); +} + +function LastSweepDetails({ status }: { status: RetentionStatus }) { + const { t } = useTranslation(); + const lastSweep = status.last_sweep; + if (!lastSweep) return null; + const entries = getRetentionSweepEntries(lastSweep, { + routineHistory: t("system:retentionRoutineHistoryLabel"), + agentRunHistory: t("system:retentionAgentRunHistoryLabel"), + runEvents: t("system:retentionRunEventsLabel"), + providerAttempts: t("system:retentionProviderAttemptsLabel"), + runSkillRecords: t("system:retentionRunSkillRecordsLabel"), + }); + return ( +
+

+ {t("system:retentionSweepStartedAtLabel")}: {formatDateTime(lastSweep.started_at)} + {" · "} + {t("system:retentionSweepFinishedAtLabel")}: {formatDateTime(lastSweep.finished_at)} +

+ {entries.map((entry) => ( + + ))} +
+ ); +} + +function RetainedCountRow({ + label, + targetId, + census, + overThreshold, +}: { + label: string; + targetId: string; + census: RetentionTableCensus; + overThreshold: boolean; +}) { + const { t } = useTranslation(); + let count: string; + if (census.state === "not_computed") count = t("system:retentionCensusUnavailable"); + else if (census.retained_count === 0) count = t("system:retentionCensusZero"); + else count = t("system:retentionRecords", { count: census.retained_count }); + return ( +
+ {label} + {count} + {census.state !== "not_computed" && ( + + {t("system:retentionCensusAsOfLabel")}: {formatDateTime(census.as_of)} + + )} + {census.state === "stale" && ( + {t("system:retentionCensusStale")} + )} + {census.unknown_statuses && census.unknown_statuses.length > 0 && ( + + {t("system:retentionUnknownStatusesLabel")}:{" "} + {formatUnknownStatuses(census.unknown_statuses)} + + )} + {census.top_routine_id && ( + + {t("system:retentionTopRoutineShareLabel")}:{" "} + {Math.round((census.top_routine_share ?? 0) * 100)}% ({census.top_routine_id}) + + )} + {overThreshold && ( + {t("system:retentionAboveWarningThreshold")} + )} +
+ ); +} + +function RetentionSweepIssues({ status }: { status: RetentionStatus }) { + const { t } = useTranslation(); + const lastSweep = status.last_sweep; + if (!lastSweep) return null; + const entries = getRetentionSweepEntries(lastSweep, { + routineHistory: t("system:retentionRoutineHistoryLabel"), + agentRunHistory: t("system:retentionAgentRunHistoryLabel"), + runEvents: t("system:retentionRunEventsLabel"), + providerAttempts: t("system:retentionProviderAttemptsLabel"), + runSkillRecords: t("system:retentionRunSkillRecordsLabel"), + }); + return ( +
+ {entries + .filter(({ result }) => result.backlog || result.error) + .map(({ id, label, result }) => ( +

+ + {label}: {result.error || t("system:retentionBacklogLabel")} + +

+ ))} +
+ ); +} + +function CleanupDetails({ status }: { status: RetentionStatus }) { + const { t } = useTranslation(); + return ( +
+ + {t("system:retentionCleanupDetailsTitle")} + +
+ + {status.skip_count > 0 && ( +

+ {t("system:retentionSkipCountLabel")}: {status.skip_count} + {status.last_skip_at && ( + <> + {" · "} + {t("system:retentionLastSkipAtLabel")}: {formatDateTime(status.last_skip_at)} + + )} +

+ )} +
+
+ ); +} + +export function RetentionStatusCard({ status }: { status: RetentionStatus | null }) { + const { t } = useTranslation(); + if (!status) return null; + const model = buildRetentionStatusViewModel(status); + const labels = { + office_routine_runs: t("system:retentionRoutineHistoryLabel"), + runs: t("system:retentionAgentRunHistoryLabel"), + run_events: t("system:retentionRunEventsLabel"), + } as const; + const outcomeLabel = { + none: t("system:retentionNeverSweptMessage"), + success: t("system:retentionOutcomeCompleted"), + preview: t("system:retentionOutcomePreview"), + mixed: t("system:retentionOutcomeMixed"), + error: t("system:retentionOutcomeError"), + }[model.sweep.outcome]; + return ( + + + +
+
+

+ {t("system:retentionAutomaticCleanupLabel")} +

+

+ {status.settings.enabled + ? t("system:retentionEnabledValue") + : t("system:retentionDisabledValue")} +

+
+
+

{t("system:retentionLastCleanupLabel")}

+ {status.last_sweep ? ( +

+ {outcomeLabel} · {formatDateTime(status.last_sweep.finished_at)} +

+ ) : ( +

+ {outcomeLabel} +

+ )} +
+
+ {model.sweep.deletionTotal > 0 && ( +

+ {t("system:retentionDeletionTotal", { count: model.sweep.deletionTotal })} +

+ )} + {model.sweep.hasError && ( +

+ {t("system:retentionOutcomeError")} +

+ )} + {model.sweep.hasBacklog && ( +

+ {t("system:retentionBacklogNotice")} +

+ )} + +
+

{t("system:retentionRetainedCountsTitle")}

+
+ {model.counts.map((count) => ( + + ))} +
+

+ {t("system:retentionStoredCountsDescription")} +

+ {model.commonAsOf && ( +

+ {t("system:retentionUpdatedLabel")}: {formatDateTime(model.commonAsOf)} +

+ )} +
+ +
+
+ ); +} diff --git a/apps/web/components/settings/system/storage-settings.tsx b/apps/web/components/settings/system/storage-settings.tsx new file mode 100644 index 0000000000..9e2a5fb187 --- /dev/null +++ b/apps/web/components/settings/system/storage-settings.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { + SettingsTabs, + SettingsTabsList, + SettingsTabsPanel, + type SettingsTabOption, +} from "@/components/settings/settings-tabs"; +import { useSettingsTab } from "@/hooks/domains/settings/use-settings-tab"; +import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system"; +import { RetentionSettingsCard } from "./retention-settings-card"; +import { StorageMaintenanceSettings } from "./storage/storage-maintenance-settings"; +import { SystemRouteShell } from "./system-route-shell"; + +const STORAGE_TARGET_TO_TAB = { + [SYSTEM_SETTINGS_TARGETS.storageActions]: "host", + [SYSTEM_SETTINGS_TARGETS.storageSchedule]: "host", + [SYSTEM_SETTINGS_TARGETS.storageWorkspaces]: "host", + [SYSTEM_SETTINGS_TARGETS.storageGoCache]: "host", + [SYSTEM_SETTINGS_TARGETS.storageDocker]: "host", + [SYSTEM_SETTINGS_TARGETS.storageQuarantine]: "host", + [SYSTEM_SETTINGS_TARGETS.retention]: "office-retention", +} as const; + +export function StorageSettings() { + const { t } = useTranslation(); + const tabs: SettingsTabOption[] = [ + { id: "host", label: t("system:storageHostTab") }, + { id: "office-retention", label: t("system:officeRetentionTab") }, + ]; + const { value, selectTab } = useSettingsTab({ + tabs: tabs.map((tab) => tab.id), + defaultTab: "host", + targetToTab: STORAGE_TARGET_TO_TAB, + }); + + return ( + + } + > + + + + + + + + + ); +} diff --git a/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx b/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx index 9d17ecc6b9..32b488be6e 100644 --- a/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx +++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx @@ -448,7 +448,7 @@ describe("StorageMaintenanceSettings pending policy", () => { fireEvent.click(screen.getByTestId("storage-resource-temporary-artifacts-trigger")); fireEvent.click(screen.getByTestId("storage-temporary-artifacts-clean")); - expect(screen.getByText("Clean stale Kandev artifacts?")).toBeTruthy(); + expect(screen.getByText("Clean inactive Kandev temporary files?")).toBeTruthy(); fireEvent.click(screen.getByTestId("storage-temporary-artifacts-confirm")); await waitFor(() => expect(currentController.runNow).toHaveBeenCalledWith(["temporary_artifacts"]), diff --git a/apps/web/components/settings/system/storage/storage-policy-card.test.tsx b/apps/web/components/settings/system/storage/storage-policy-card.test.tsx index bed6227b89..b413386256 100644 --- a/apps/web/components/settings/system/storage/storage-policy-card.test.tsx +++ b/apps/web/components/settings/system/storage/storage-policy-card.test.tsx @@ -176,7 +176,9 @@ describe("Temporary artifact policy", () => { temporary_artifacts: { enabled: true }, }); expect( - screen.getAllByText("Registered inactive artifacts only. Minimum age: 24 hours.").length, + screen.getAllByText( + "Registered diagnostic bundles and utility working folders. Inactive for at least 24 hours.", + ).length, ).toBeGreaterThan(0); }); @@ -450,7 +452,7 @@ describe("StoragePolicyCard interactions", () => { "Go build cache", "Docker cleanup", "Quarantine safety", - "Temporary artifacts", + "Temporary Kandev files", ]) { expect(screen.getByText(heading)).toBeTruthy(); } diff --git a/apps/web/components/settings/system/storage/storage-policy-card.tsx b/apps/web/components/settings/system/storage/storage-policy-card.tsx index 531bf115c2..445285c482 100644 --- a/apps/web/components/settings/system/storage/storage-policy-card.tsx +++ b/apps/web/components/settings/system/storage/storage-policy-card.tsx @@ -546,9 +546,8 @@ export function TemporaryArtifactsSection({ } />
-

{t("system:storageTemporaryArtifactsPolicyAge")}

+

{t("system:storageTemporaryArtifactsPolicySummary")}

{t("system:storageTemporaryArtifactsPolicyQuarantine")}

-

{t("system:storageTemporaryArtifactsPolicyShared")}

{onCleanTemporaryArtifacts && ( (null); + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + window.requestAnimationFrame(() => triggerRef.current?.focus()); + } + }; + const button = ( + + ); + const trigger = usesTouchDrawer ? ( + {button} + ) : ( + + + {button} + {children} + + + ); return ( - - - - - {children} - + + {trigger} + + + {label} + + {t("system:storageMoreInformationAbout", { label })} + + +
+ {children} +
+ + + + + +
+
); } diff --git a/apps/web/components/settings/system/system-page-shell.tsx b/apps/web/components/settings/system/system-page-shell.tsx index 28330ba4a0..87cb149c12 100644 --- a/apps/web/components/settings/system/system-page-shell.tsx +++ b/apps/web/components/settings/system/system-page-shell.tsx @@ -5,16 +5,24 @@ import { SettingsPageHeader } from "@/components/settings/settings-typography"; type SystemPageShellProps = { title: string; description?: string; + tabs?: ReactNode; actions?: ReactNode; children: ReactNode; }; -export function SystemPageShell({ title, description, actions, children }: SystemPageShellProps) { +export function SystemPageShell({ + title, + description, + tabs, + actions, + children, +}: SystemPageShellProps) { return (
{actions}
} titleTestId="system-page-title" /> diff --git a/apps/web/components/settings/system/system-route-copy.test.ts b/apps/web/components/settings/system/system-route-copy.test.ts index bc7610c296..f4d95fffa4 100644 --- a/apps/web/components/settings/system/system-route-copy.test.ts +++ b/apps/web/components/settings/system/system-route-copy.test.ts @@ -148,10 +148,10 @@ describe("Data & Logs composition", () => { it("keeps database, backups, and logs without the Storage section", () => { render(createElement(DataLogsSettings)); - expect(screen.getByText(t("system:navDatabase"))).toBeTruthy(); - expect(screen.getByText(t("system:navRetention"))).toBeTruthy(); - expect(screen.getByText(t("system:navBackups"))).toBeTruthy(); - expect(screen.getByText(t("system:navLogs"))).toBeTruthy(); + expect(screen.getAllByText(t("system:navDatabase")).length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(t("system:navRetention"))).toBeNull(); + expect(screen.getByRole("heading", { name: t("system:navBackups") })).toBeTruthy(); + expect(screen.getByRole("tab", { name: t("system:navLogs") })).toBeTruthy(); expect(screen.queryByText(t("system:storageTitle"))).toBeNull(); }); diff --git a/apps/web/components/settings/system/system-route-shell.tsx b/apps/web/components/settings/system/system-route-shell.tsx index dfd9cc25e6..645f62f2fb 100644 --- a/apps/web/components/settings/system/system-route-shell.tsx +++ b/apps/web/components/settings/system/system-route-shell.tsx @@ -23,17 +23,23 @@ export function SystemRouteShell({ titleKey, descriptionKey, descriptionValues, + tabs, children, }: { titleKey: string; descriptionKey: string; /** Identifiers the description interpolates, so they survive translation. */ descriptionValues?: Record; + tabs?: ReactNode; children: ReactNode; }) { const { t } = useTranslation(); return ( - + {children} ); diff --git a/apps/web/components/settings/system/tool-payload-retention-card.tsx b/apps/web/components/settings/system/tool-payload-retention-card.tsx index b9cd007fef..36bb8cc913 100644 --- a/apps/web/components/settings/system/tool-payload-retention-card.tsx +++ b/apps/web/components/settings/system/tool-payload-retention-card.tsx @@ -211,7 +211,7 @@ export function ToolPayloadRetentionCard() { { expect(path.isAbsolute(backupDirectory!)).toBe(true); expect(backupDirectory).toBe(path.resolve(path.dirname(database.path!), "backups")); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect( testPage.getByText(`VACUUM INTO snapshots stored under ${backupDirectory}.`), ).toBeVisible(); @@ -46,7 +46,7 @@ test.describe("System Backups page", () => { test("explains each backup row action on hover", async ({ testPage }) => { test.setTimeout(60_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await testPage.getByTestId("system-backups-create").click(); await expect(testPage.getByTestId("system-backups-table")).toBeVisible({ timeout: 15_000 }); @@ -72,7 +72,7 @@ test.describe("System Backups page", () => { }) => { test.setTimeout(60_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs"); await expect(testPage.getByTestId("system-backups-card")).toBeVisible(); diff --git a/apps/web/e2e/tests/system/database-page.spec.ts b/apps/web/e2e/tests/system/database-page.spec.ts index 4f3687aeb5..e1dd6b6d31 100644 --- a/apps/web/e2e/tests/system/database-page.spec.ts +++ b/apps/web/e2e/tests/system/database-page.spec.ts @@ -4,7 +4,7 @@ test.describe("System Database page", () => { test("renders database stats and exposes maintenance buttons", async ({ testPage }) => { test.setTimeout(60_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs"); await expect(testPage.getByTestId("system-database-card")).toBeVisible(); @@ -28,7 +28,7 @@ test.describe("System Database page", () => { }) => { test.setTimeout(60_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-database-card")).toBeVisible(); const vacuumButton = testPage.getByTestId("system-vacuum-button"); @@ -52,13 +52,13 @@ test.describe("System Database page", () => { }); test("WAL row exposes an info tooltip trigger", async ({ testPage }) => { - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-database-card")).toBeVisible(); await expect(testPage.getByTestId("system-db-wal-info")).toBeVisible(); }); test("clicking Factory Reset opens the confirmation modal", async ({ testPage }) => { - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-database-card")).toBeVisible(); await testPage.getByTestId("system-factory-reset-button").click(); diff --git a/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts b/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts index 45e6df1615..6db6f9a06e 100644 --- a/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts +++ b/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts @@ -4,7 +4,7 @@ test.describe("Factory Reset dialog", () => { test("confirm button only enables on the literal 'RESET' token; cancel closes the modal", async ({ testPage, }) => { - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-database-card")).toBeVisible(); await testPage.getByTestId("system-factory-reset-button").click(); diff --git a/apps/web/e2e/tests/system/logs-page.spec.ts b/apps/web/e2e/tests/system/logs-page.spec.ts index bb835f838c..fb14ce0572 100644 --- a/apps/web/e2e/tests/system/logs-page.spec.ts +++ b/apps/web/e2e/tests/system/logs-page.spec.ts @@ -8,7 +8,7 @@ test.describe("System Logs page", () => { prCapture, }) => { test.setTimeout(45_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=logs"); await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs"); await expect( @@ -45,7 +45,7 @@ test.describe("System Logs page", () => { prCapture, }) => { await testPage.setViewportSize({ width: 1440, height: 900 }); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=logs"); await testPage.getByTestId("customize-diagnostic-bundle").click(); const dialog = testPage.getByTestId("diagnostic-bundle-dialog"); await expect(dialog).toBeVisible(); @@ -63,7 +63,7 @@ test.describe("System Logs page", () => { testPage, }) => { await testPage.setViewportSize({ width: 1440, height: 900 }); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=logs"); const noMessages = testPage.getByText( "Standard bundles do not include chat transcripts, session messages, or agent messages.", diff --git a/apps/web/e2e/tests/system/mobile-database-page.spec.ts b/apps/web/e2e/tests/system/mobile-database-page.spec.ts index 9fb37e6f8e..9b0f8e12b3 100644 --- a/apps/web/e2e/tests/system/mobile-database-page.spec.ts +++ b/apps/web/e2e/tests/system/mobile-database-page.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "../../fixtures/test-base"; test.describe("Mobile System Database page", () => { test("renders database stats and maintenance controls", async ({ testPage }) => { - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs"); await expect(testPage.getByTestId("system-database-card")).toBeVisible(); diff --git a/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts b/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts index cfa8c758a8..0c8f69064b 100644 --- a/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts +++ b/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts @@ -6,7 +6,7 @@ test.describe("System Logs mobile", () => { prCapture, }) => { await testPage.setViewportSize({ width: 390, height: 844 }); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=logs"); const action = testPage.getByTestId("customize-diagnostic-bundle"); await expect(action).toBeVisible(); @@ -29,7 +29,7 @@ test.describe("System Logs mobile", () => { testPage, }) => { await testPage.setViewportSize({ width: 390, height: 844 }); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=logs"); const customize = testPage.getByTestId("customize-diagnostic-bundle"); await expect(customize).toBeVisible(); diff --git a/apps/web/e2e/tests/system/mobile-retention-settings.spec.ts b/apps/web/e2e/tests/system/mobile-retention-settings.spec.ts new file mode 100644 index 0000000000..b90edbf0e9 --- /dev/null +++ b/apps/web/e2e/tests/system/mobile-retention-settings.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Office retention settings on phones", () => { + test("keeps policy fields in one column with touch-sized controls", async ({ testPage }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + await testPage.goto("/settings/system/storage?tab=office-retention"); + + await expect(testPage.getByTestId("retention-policy-card")).toBeVisible(); + const visibleFields = [ + "retention-routine-runs-window-days", + "retention-routine-runs-floor-per-owner", + "retention-runs-window-days", + "retention-runs-floor-per-owner", + ]; + for (const id of visibleFields) { + const field = testPage.getByTestId(id); + const box = await field.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height, id).toBeGreaterThanOrEqual(44); + } + + const advanced = testPage.getByTestId("retention-advanced-settings"); + await expect(advanced).not.toHaveAttribute("open"); + await advanced.locator("summary").tap(); + await expect(advanced).toHaveAttribute("open"); + for (const id of [ + "retention-sweep-interval", + "retention-batch-limit", + "retention-run-events-warn-rows", + ]) { + const field = testPage.getByTestId(id); + const box = await field.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height, id).toBeGreaterThanOrEqual(44); + } + await advanced.locator("summary").tap(); + await expect(advanced).not.toHaveAttribute("open"); + + await expect + .poll(() => + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ) + .toBe(true); + }); +}); diff --git a/apps/web/e2e/tests/system/mobile-settings-header-tabs.spec.ts b/apps/web/e2e/tests/system/mobile-settings-header-tabs.spec.ts new file mode 100644 index 0000000000..b90875baef --- /dev/null +++ b/apps/web/e2e/tests/system/mobile-settings-header-tabs.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Settings header tabs on phones", () => { + for (const width of [390, 767, 768, 1024]) { + test(`keeps touch tabs inside their track at ${width}px`, async ({ testPage }, testInfo) => { + await testPage.setViewportSize({ width, height: 844 }); + await testPage.goto("/settings/system/data-storage?tab=logs"); + + const tabList = testPage.getByRole("tablist", { name: "Data & Logs" }); + await expect(tabList).toBeVisible(); + const trackBox = (await tabList.boundingBox())!; + for (const tab of await testPage.getByRole("tab").all()) { + const box = await tab.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.y).toBeGreaterThanOrEqual(trackBox.y + 3); + expect(box!.y + box!.height).toBeLessThanOrEqual(trackBox.y + trackBox.height - 3); + expect( + await tab.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2), + ); + }), + ).toBe(true); + } + await testPage.getByRole("tab", { name: "Database", exact: true }).tap(); + await expect(testPage.getByTestId("settings-data-storage-database")).toBeVisible(); + await testPage.getByRole("tab", { name: "Logs", exact: true }).tap(); + await testPage.screenshot({ path: testInfo.outputPath(`tabs-touch-${width}.png`) }); + await expect(testPage.getByTestId("customize-diagnostic-bundle")).toBeVisible(); + await expect + .poll(() => + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ) + .toBe(true); + }); + } + + test("places tabs below the description and returns focus from touch help", async ({ + testPage, + prCapture, + }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + await testPage.goto("/settings/system/storage?tab=office-retention"); + + const title = testPage.getByTestId("system-page-title"); + const tabList = testPage.getByRole("tablist", { name: "Storage" }); + const titleBox = await title.boundingBox(); + const tabListBox = await tabList.boundingBox(); + expect(titleBox).not.toBeNull(); + expect(tabListBox).not.toBeNull(); + expect(tabListBox!.y).toBeGreaterThan(titleBox!.y + titleBox!.height); + + const officeTab = testPage.getByRole("tab", { name: "Office retention", exact: true }); + const hostTab = testPage.getByRole("tab", { name: "Host", exact: true }); + for (const tab of [hostTab, officeTab]) { + const box = await tab.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + await expect(testPage.getByTestId("retention-status-card")).toBeVisible(); + + const help = testPage + .getByRole("button", { name: "More information about Retention window (days)" }) + .first(); + const helpBox = await help.boundingBox(); + expect(helpBox).not.toBeNull(); + expect(helpBox!.width).toBeGreaterThanOrEqual(44); + expect(helpBox!.height).toBeGreaterThanOrEqual(44); + await help.tap(); + + const drawer = testPage.getByRole("dialog"); + await expect(drawer).toBeVisible(); + await expect(drawer).toContainText("Finished rows older than this many days"); + await drawer.getByRole("button", { name: "Close" }).tap(); + await expect(help).toBeFocused(); + + const windowField = testPage.getByTestId("retention-routine-runs-window-days"); + const floorField = testPage.getByTestId("retention-routine-runs-floor-per-owner"); + const windowBox = await windowField.boundingBox(); + const floorBox = await floorField.boundingBox(); + expect(windowBox).not.toBeNull(); + expect(floorBox).not.toBeNull(); + expect(floorBox!.y).toBeGreaterThan(windowBox!.y + windowBox!.height); + await expect + .poll(() => + testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ) + .toBe(true); + if (prCapture.capturing) { + await testPage.evaluate(() => { + window.scrollTo(0, 0); + for (const element of document.querySelectorAll("*")) { + const style = getComputedStyle(element); + if ( + (style.overflowY === "auto" || style.overflowY === "scroll") && + element.scrollHeight > element.clientHeight + ) { + element.scrollTop = 0; + } + } + }); + } + await prCapture.screenshot("mobile-storage-office-retention", { + caption: "Office retention keeps status, policy, tabs, and touch help usable on a phone.", + fullPage: true, + }); + }); +}); diff --git a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts index b77e66a18c..9e1a90f86a 100644 --- a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts +++ b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts @@ -45,7 +45,7 @@ test.describe("Mobile storage maintenance", () => { expect(box).not.toBeNull(); expect(box!.height).toBeGreaterThanOrEqual(44); await cleanButton.tap(); - await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await expect(testPage.getByText("Clean inactive Kandev temporary files?")).toBeVisible(); await prCapture.screenshot("temporary-artifacts-confirmation", { caption: "Mobile storage keeps stale artifact cleanup in a reachable confirmation", }); @@ -128,7 +128,7 @@ test.describe("Mobile storage maintenance", () => { await testPage .getByRole("button", { name: "More information about Scheduled maintenance" }) .click(); - await expect(testPage.getByRole("tooltip")).toContainText( + await expect(testPage.getByRole("dialog")).toContainText( "Turning it off does not disable Analyze or Run now", ); await testPage.keyboard.press("Escape"); @@ -176,11 +176,11 @@ test.describe("Mobile storage maintenance", () => { await testPage .getByRole("button", { name: "More information about Folders Kandev will check" }) .tap(); - await expect(testPage.getByRole("tooltip")).toContainText("recursively"); + await expect(testPage.getByRole("dialog")).toContainText("recursively"); await testPage.reload(); await expect(testPage.getByTestId("storage-settings-page")).toBeVisible(); await testPage.getByRole("button", { name: "More information about Quarantine" }).click(); - await expect(testPage.getByRole("tooltip")).toContainText("recoverable holding area"); + await expect(testPage.getByRole("dialog")).toContainText("recoverable holding area"); await expect .poll(() => testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), @@ -269,7 +269,7 @@ test.describe("Mobile storage maintenance", () => { ); } await timingHelp.tap(); - await expect(testPage.getByRole("tooltip")).toContainText("Scan duration"); + await expect(testPage.getByRole("dialog")).toContainText("Scan duration"); await prCapture.screenshot("progressive-analysis-timing", { caption: "Mobile storage opens progressive scan timing by touch", }); diff --git a/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts b/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts index c8aaf3d5f9..9c6d68ba76 100644 --- a/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts +++ b/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts @@ -80,7 +80,7 @@ test.describe("Mobile system temporary folders", () => { expect(box).not.toBeNull(); expect(box!.height).toBeGreaterThanOrEqual(44); await cleanButton.tap(); - await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await expect(testPage.getByText("Clean inactive Kandev temporary files?")).toBeVisible(); await prCapture.screenshot("mobile-system-temporary-cleanup-confirmation", { caption: "Mobile storage keeps explicit registered cleanup in a touch-sized flow", }); diff --git a/apps/web/e2e/tests/system/restore-dialog.spec.ts b/apps/web/e2e/tests/system/restore-dialog.spec.ts index addf113686..fd803e4363 100644 --- a/apps/web/e2e/tests/system/restore-dialog.spec.ts +++ b/apps/web/e2e/tests/system/restore-dialog.spec.ts @@ -57,7 +57,7 @@ test.describe("Restore snapshot dialog", () => { } expect(found).toBeTruthy(); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/data-storage?tab=database"); await expect(testPage.getByTestId("system-backups-table")).toBeVisible({ timeout: 15_000 }); const firstRow = testPage.locator('[data-testid="system-backups-row"]').first(); diff --git a/apps/web/e2e/tests/system/retention-settings.spec.ts b/apps/web/e2e/tests/system/retention-settings.spec.ts index 08a75c7739..18f1e23bcc 100644 --- a/apps/web/e2e/tests/system/retention-settings.spec.ts +++ b/apps/web/e2e/tests/system/retention-settings.spec.ts @@ -1,4 +1,13 @@ import { test, expect } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; + +async function saveChanges(page: Page): Promise { + const saveBar = page.getByTestId("settings-floating-save"); + await expect(saveBar).toBeAttached(); + await saveBar.scrollIntoViewIfNeeded(); + await saveBar.getByRole("button", { name: "Save changes" }).click(); + await expect(saveBar).toContainText("Saved"); +} /** * Office run-history retention (docs/specs/office/requirements/run-history-retention*.md). @@ -14,7 +23,8 @@ test.describe("System retention settings", () => { backend, }) => { test.setTimeout(90_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/storage?tab=office-retention"); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); const batchLimit = testPage.getByTestId("retention-batch-limit"); await expect(batchLimit).toBeVisible(); const original = await batchLimit.inputValue(); @@ -22,19 +32,21 @@ test.describe("System retention settings", () => { try { await batchLimit.fill(updated); - await testPage.getByRole("button", { name: "Save changes" }).click(); - await expect(testPage.getByTestId("settings-floating-save")).toContainText("Saved"); + await saveChanges(testPage); await testPage.reload(); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); await expect(testPage.getByTestId("retention-batch-limit")).toHaveValue(updated); await backend.restart(); await testPage.reload(); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); await expect(testPage.getByTestId("retention-batch-limit")).toHaveValue(updated); } finally { + await testPage.goto("/settings/system/storage?tab=office-retention"); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); await testPage.getByTestId("retention-batch-limit").fill(original); - await testPage.getByRole("button", { name: "Save changes" }).click(); - await expect(testPage.getByTestId("settings-floating-save")).toContainText("Saved"); + await saveChanges(testPage); } }); @@ -45,7 +57,8 @@ test.describe("System retention settings", () => { seedData, }) => { test.setTimeout(90_000); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/storage?tab=office-retention"); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); const warnField = testPage.getByTestId("retention-runs-warn-rows"); await expect(warnField).toBeVisible(); const originalWarnRows = await warnField.inputValue(); @@ -70,8 +83,7 @@ test.describe("System retention settings", () => { try { await warnField.fill(String(newWarnRows)); - await testPage.getByRole("button", { name: "Save changes" }).click(); - await expect(testPage.getByTestId("settings-floating-save")).toContainText("Saved"); + await saveChanges(testPage); // The census only re-evaluates on its interval timer or at scheduler Start; a // restart forces an immediate re-evaluation against the settings just saved and @@ -83,15 +95,15 @@ test.describe("System retention settings", () => { await expect(issue).toBeVisible({ timeout: 15_000 }); await expect(issue).toContainText("over its threshold"); - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/storage?tab=office-retention"); const retainedRuns = testPage.getByTestId("retention-retained-runs"); await expect(retainedRuns).toBeVisible(); await expect(retainedRuns).toContainText(String(expectedRetained)); } finally { - await testPage.goto("/settings/system/data-storage"); + await testPage.goto("/settings/system/storage?tab=office-retention"); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); await testPage.getByTestId("retention-runs-warn-rows").fill(originalWarnRows); - await testPage.getByRole("button", { name: "Save changes" }).click(); - await expect(testPage.getByTestId("settings-floating-save")).toContainText("Saved"); + await saveChanges(testPage); } }); }); diff --git a/apps/web/e2e/tests/system/settings-header-tabs.spec.ts b/apps/web/e2e/tests/system/settings-header-tabs.spec.ts new file mode 100644 index 0000000000..a1b54f0916 --- /dev/null +++ b/apps/web/e2e/tests/system/settings-header-tabs.spec.ts @@ -0,0 +1,217 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Settings header tabs", () => { + test("switches Data & Logs with manual keyboard activation and keeps the panels contained", async ({ + testPage, + prCapture, + }) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + await testPage.goto("/settings/system/data-storage?tab=database"); + + const title = testPage.getByTestId("system-page-title"); + const tabList = testPage.getByRole("tablist", { name: "Data & Logs" }); + const databaseTab = testPage.getByRole("tab", { name: "Database", exact: true }); + const logsTab = testPage.getByRole("tab", { name: "Logs", exact: true }); + const databasePanel = testPage.getByTestId("settings-data-storage-database"); + const logsPanel = testPage.getByTestId("settings-data-storage-logs"); + + await expect(title).toHaveText("Data & Logs"); + await expect(databaseTab).toHaveAttribute("aria-selected", "true"); + await expect(databasePanel).toBeVisible(); + await expect(logsPanel).toBeHidden(); + + const titleBox = await title.boundingBox(); + const tabListBox = await tabList.boundingBox(); + expect(titleBox).not.toBeNull(); + expect(tabListBox).not.toBeNull(); + expect(tabListBox!.x).toBeGreaterThan(titleBox!.x + titleBox!.width); + + await logsTab.focus(); + await testPage.keyboard.press("ArrowLeft"); + await expect(databaseTab).toHaveAttribute("aria-selected", "true"); + await expect(logsTab).toHaveAttribute("aria-selected", "false"); + await testPage.keyboard.press("ArrowRight"); + await expect(logsTab).toBeFocused(); + await logsTab.press("Enter"); + + await expect(logsTab).toHaveAttribute("aria-selected", "true"); + await expect(databasePanel).toBeHidden(); + await expect(logsPanel).toBeVisible(); + await expect(testPage).toHaveURL(/\/settings\/system\/data-storage\?tab=logs$/); + await expect(testPage.getByTestId("customize-diagnostic-bundle")).toBeVisible(); + await prCapture.screenshot("desktop-data-logs-tabs", { + caption: "Data & Logs keeps Database and Logs available from the page header.", + fullPage: true, + }); + }); + + test("keeps a retention draft through a tab round trip and resolves legacy destinations", async ({ + testPage, + }) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + await testPage.goto("/settings/system/storage?tab=office-retention"); + + const officeTab = testPage.getByRole("tab", { name: "Office retention", exact: true }); + const hostTab = testPage.getByRole("tab", { name: "Host", exact: true }); + await testPage.getByTestId("retention-advanced-settings").locator("summary").click(); + const batchLimit = testPage.getByTestId("retention-batch-limit"); + await expect(batchLimit).toBeVisible(); + const original = await batchLimit.inputValue(); + const updated = original === "7777" ? "8888" : "7777"; + + try { + await batchLimit.fill(updated); + await hostTab.click(); + await expect(testPage.getByTestId("storage-settings-page")).toBeVisible(); + await officeTab.click(); + await expect(batchLimit).toHaveValue(updated); + await expect(testPage).toHaveURL(/\/settings\/system\/storage\?tab=office-retention$/); + } finally { + if ((await batchLimit.inputValue()) !== original) { + const advanced = testPage.getByTestId("retention-advanced-settings"); + if ((await advanced.getAttribute("open")) === null) { + await advanced.locator("summary").click(); + } + await batchLimit.fill(original); + } + } + + await testPage.goto("/settings/system/backups?preserve=1#legacy"); + await expect(testPage).toHaveURL( + /\/settings\/system\/data-storage\?preserve=1&tab=database#setting-system-backups$/, + ); + await expect(testPage.getByTestId("system-backups-card")).toBeVisible(); + + await testPage.goto("/settings/system/data-storage?preserve=1#setting-system-retention"); + await expect(testPage).toHaveURL( + /\/settings\/system\/storage\?preserve=1&tab=office-retention#setting-system-retention$/, + ); + await expect(testPage.getByTestId("retention-policy-card")).toBeVisible(); + }); + + test("opens the tab that owns a discovery target", async ({ testPage }) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + await testPage.goto("/settings/system/storage?tab=host#setting-system-retention"); + + await expect( + testPage.getByRole("tab", { name: "Office retention", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(testPage.getByTestId("retention-policy-card")).toBeVisible(); + await expect(testPage.getByTestId("retention-policy-card")).toHaveAttribute( + "data-settings-target-highlight", + "true", + { timeout: 5_000 }, + ); + }); + + test("reacts when browser history changes only the target hash", async ({ testPage }) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + await testPage.goto("/settings/system/storage?tab=host"); + await expect(testPage.getByRole("tab", { name: "Host", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + + await testPage.evaluate(() => { + window.location.hash = "#setting-system-retention"; + }); + + await expect( + testPage.getByRole("tab", { name: "Office retention", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(testPage.getByTestId("retention-policy-card")).toBeVisible(); + }); + + test("clears a target fragment when switching away from its tab", async ({ testPage }) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + await testPage.goto("/settings/system/storage?tab=office-retention#setting-system-retention"); + + await expect( + testPage.getByRole("tab", { name: "Office retention", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await testPage.getByRole("tab", { name: "Host", exact: true }).click(); + + await expect(testPage).toHaveURL(/\/settings\/system\/storage\?tab=host$/); + await expect(testPage.getByRole("tab", { name: "Host", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + }); +}); + +// @covers AC-UI-SETTINGS-HEADER-TABS-001.3/.4/.5 +for (const theme of ["light", "dark"] as const) { + for (const surface of [ + { path: "data-storage", active: "Database", inactive: "Logs" }, + { path: "storage", active: "Host", inactive: "Office retention" }, + ]) { + test(`distinguishes tab visual states on ${surface.path} in ${theme}`, async ({ + testPage, + }, testInfo) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + await testPage.addInitScript((value) => localStorage.setItem("theme", value), theme); + await testPage.goto(`/settings/system/${surface.path}`); + await expect(testPage.locator("html")).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`)); + const active = testPage.getByRole("tab", { name: surface.active, exact: true }); + const inactive = testPage.getByRole("tab", { name: surface.inactive, exact: true }); + await expect(active).toHaveAttribute("aria-selected", "true"); + await testPage.mouse.move(0, 0); + const inactiveBackground = await inactive.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ); + await expect + .poll(() => active.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe(inactiveBackground); + const selectedBackground = await active.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ); + expect( + await active.evaluate((element) => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d")!; + context.fillStyle = getComputedStyle(element).backgroundColor; + context.fillRect(0, 0, 1, 1); + return context.getImageData(0, 0, 1, 1).data[3]; + }), + ).toBe(255); + const inactiveBorder = await inactive.evaluate((el) => getComputedStyle(el).borderTopColor); + await expect + .poll(() => active.evaluate((el) => getComputedStyle(el).borderTopColor)) + .not.toBe(inactiveBorder); + const originalBox = await inactive.boundingBox(); + const activeBox = (await active.boundingBox())!; + const trackBox = (await testPage.getByRole("tablist").boundingBox())!; + expect(activeBox.height).toBeCloseTo(28, 0); + expect(activeBox.y).toBeGreaterThanOrEqual(trackBox.y + 3); + expect(activeBox.y + activeBox.height).toBeLessThanOrEqual(trackBox.y + trackBox.height - 3); + + await inactive.hover(); + await expect + .poll(() => inactive.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe(inactiveBackground); + await expect(active).toHaveCSS("background-color", selectedBackground); + await testPage.mouse.move(0, 0); + await expect(inactive).toHaveCSS("background-color", inactiveBackground); + const defaultShadow = await inactive.evaluate((el) => getComputedStyle(el).boxShadow); + await active.focus(); + await active.press("ArrowRight"); + await expect(inactive).toBeFocused(); + await expect(inactive).toHaveAttribute("aria-selected", "false"); + await expect + .poll(() => inactive.evaluate((el) => getComputedStyle(el).boxShadow)) + .not.toBe(defaultShadow); + await inactive.press("Enter"); + await expect(inactive).toHaveAttribute("aria-selected", "true"); + await expect(inactive).toHaveCSS("background-color", selectedBackground); + expect((await inactive.boundingBox())!.width).toBeCloseTo(originalBox!.width, 0); + await testPage.emulateMedia({ reducedMotion: "reduce" }); + await expect(inactive).toHaveCSS("transition-property", "none"); + await testPage + .getByRole("tablist") + .screenshot({ path: testInfo.outputPath(`tabs-focus-${surface.path}-${theme}.png`) }); + await inactive.blur(); + await testPage.screenshot({ path: testInfo.outputPath(`tabs-${surface.path}-${theme}.png`) }); + }); + } +} diff --git a/apps/web/e2e/tests/system/storage-maintenance.spec.ts b/apps/web/e2e/tests/system/storage-maintenance.spec.ts index 899f82feff..c69737f3a0 100644 --- a/apps/web/e2e/tests/system/storage-maintenance.spec.ts +++ b/apps/web/e2e/tests/system/storage-maintenance.spec.ts @@ -66,7 +66,7 @@ test.describe("System storage maintenance", () => { const cleanButton = testPage.getByTestId("storage-temporary-artifacts-clean"); await expect(cleanButton).toBeEnabled(); await cleanButton.click(); - await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await expect(testPage.getByText("Clean inactive Kandev temporary files?")).toBeVisible(); await prCapture.screenshot("temporary-artifacts-confirmation", { caption: "Desktop storage confirms stale registered artifacts before quarantine", }); diff --git a/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts b/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts index 0a5bd47746..93f7b8abfb 100644 --- a/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts +++ b/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts @@ -117,7 +117,7 @@ test.describe("System temporary folders", () => { ); await testPage.getByTestId("storage-policy-temporary-artifacts-clean").click(); - await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await expect(testPage.getByText("Clean inactive Kandev temporary files?")).toBeVisible(); await prCapture.screenshot("system-temporary-cleanup-confirmation", { caption: "Desktop storage confirms registered artifact cleanup before quarantine", }); diff --git a/apps/web/e2e/tests/task/plan-mode-followup.spec.ts b/apps/web/e2e/tests/task/plan-mode-followup.spec.ts index af7a7f999b..e15f595eeb 100644 --- a/apps/web/e2e/tests/task/plan-mode-followup.spec.ts +++ b/apps/web/e2e/tests/task/plan-mode-followup.spec.ts @@ -185,7 +185,9 @@ test.describe("Plan mode follow-up messages", () => { await runBtn.click(); // The comment should appear in the chat formatted as plan comment markdown. - await expect(session.chat.getByText("Plan Comments", { exact: false })).toBeVisible({ + await expect( + session.chat.getByRole("heading", { name: "Plan Comments", exact: true }), + ).toBeVisible({ timeout: 15_000, }); const commentBubble = session.chat diff --git a/apps/web/hooks/domains/settings/use-settings-tab.test.ts b/apps/web/hooks/domains/settings/use-settings-tab.test.ts new file mode 100644 index 0000000000..e12aa650b8 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-settings-tab.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSettingsTab } from "./use-settings-tab"; + +describe("resolveSettingsTab", () => { + const tabs = ["database", "logs"] as const; + + it("uses the default when the query is missing or invalid", () => { + expect(resolveSettingsTab(null, tabs, "database")).toBe("database"); + expect(resolveSettingsTab("unknown", tabs, "database")).toBe("database"); + }); + + it("keeps a valid query selection", () => { + expect(resolveSettingsTab("logs", tabs, "database")).toBe("logs"); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-settings-tab.ts b/apps/web/hooks/domains/settings/use-settings-tab.ts new file mode 100644 index 0000000000..097f94446b --- /dev/null +++ b/apps/web/hooks/domains/settings/use-settings-tab.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router"; +import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard"; +import { LOCATION_CHANGE_EVENT } from "@/lib/routing/navigation-event"; +import { + SETTINGS_TARGET_REQUEST_EVENT, + settingsTargetFromHash, + type SettingsTargetRequestDetail, +} from "@/lib/settings-discovery/target"; + +export type SettingsTabId = string; + +export type UseSettingsTabOptions = { + tabs: readonly SettingsTabId[]; + defaultTab: SettingsTabId; + targetToTab?: Readonly>; +}; + +export function resolveSettingsTab( + value: string | null, + tabs: readonly SettingsTabId[], + defaultTab: SettingsTabId, +): SettingsTabId { + return value && tabs.includes(value) ? value : defaultTab; +} + +function currentTargetTab( + targetToTab: Readonly>, + hash: string, +): string | null { + const targetId = settingsTargetFromHash(hash); + return targetId ? (targetToTab[targetId] ?? null) : null; +} + +function tabHref(pathname: string, tab: string, removeHash: boolean): string { + const params = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search); + params.set("tab", tab); + const query = params.toString(); + const hash = removeHash || typeof window === "undefined" ? "" : window.location.hash; + return `${pathname}${query ? `?${query}` : ""}${hash}`; +} + +export function useSettingsTab({ tabs, defaultTab, targetToTab = {} }: UseSettingsTabOptions) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + const [locationHash, setLocationHash] = useState(() => + typeof window === "undefined" ? "" : window.location.hash, + ); + useEffect(() => { + const updateHash = () => setLocationHash(window.location.hash); + window.addEventListener("hashchange", updateHash); + window.addEventListener("popstate", updateHash); + window.addEventListener(LOCATION_CHANGE_EVENT, updateHash); + return () => { + window.removeEventListener("hashchange", updateHash); + window.removeEventListener("popstate", updateHash); + window.removeEventListener(LOCATION_CHANGE_EVENT, updateHash); + }; + }, []); + const value = useMemo( + () => + currentTargetTab(targetToTab, locationHash) ?? + resolveSettingsTab(searchParams.get("tab"), tabs, defaultTab), + [defaultTab, locationHash, searchParams, tabs, targetToTab], + ); + + const selectTab = useCallback( + (nextTab: string, removeHash = true) => { + if (!tabs.includes(nextTab) || pathname !== window.location.pathname) return; + const href = tabHref(pathname, nextTab, removeHash); + runWithNavigationBlockerBypassed(() => router.replace(href, { scroll: false })); + }, + [pathname, router, tabs], + ); + + useEffect(() => { + const targetTab = currentTargetTab(targetToTab, locationHash); + if (targetTab && targetTab !== value) selectTab(targetTab, false); + }, [locationHash, selectTab, targetToTab, value]); + + useEffect(() => { + const requestTargetTab = (event: Event) => { + const targetId = (event as CustomEvent).detail?.targetId; + const targetTab = targetId ? targetToTab[targetId] : undefined; + if (targetTab && targetTab !== value) selectTab(targetTab, false); + }; + window.addEventListener(SETTINGS_TARGET_REQUEST_EVENT, requestTargetTab); + return () => window.removeEventListener(SETTINGS_TARGET_REQUEST_EVENT, requestTargetTab); + }, [selectTab, targetToTab, value]); + + return { value, selectTab }; +} diff --git a/apps/web/lib/settings-discovery/catalog/system.ts b/apps/web/lib/settings-discovery/catalog/system.ts index 78c49697c5..6634fe9ae4 100644 --- a/apps/web/lib/settings-discovery/catalog/system.ts +++ b/apps/web/lib/settings-discovery/catalog/system.ts @@ -57,7 +57,7 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ labelKey: "system:navDatabase", parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID, groupId: "system", - href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + href: `${SYSTEM_DATA_STORAGE_SETTINGS_HREF}?tab=database`, targetId: SYSTEM_SETTINGS_TARGETS.database, order: 621, }, @@ -65,9 +65,9 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ id: "system-retention", kind: "section", labelKey: "system:navRetention", - parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID, + parentId: SYSTEM_STORAGE_DISCOVERY_ID, groupId: "system", - href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + href: `${SYSTEM_STORAGE_SETTINGS_HREF}?tab=office-retention`, targetId: SYSTEM_SETTINGS_TARGETS.retention, order: 622, }, @@ -77,7 +77,7 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ labelKey: "system:toolPayload.title", parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID, groupId: "system", - href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + href: `${SYSTEM_DATA_STORAGE_SETTINGS_HREF}?tab=database`, targetId: SYSTEM_SETTINGS_TARGETS.toolPayloadRetention, order: 622.5, }, @@ -87,7 +87,7 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ labelKey: "system:navBackups", parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID, groupId: "system", - href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + href: `${SYSTEM_DATA_STORAGE_SETTINGS_HREF}?tab=database`, targetId: SYSTEM_SETTINGS_TARGETS.backups, order: 623, }, @@ -97,7 +97,7 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ labelKey: "system:navLogs", parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID, groupId: "system", - href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + href: `${SYSTEM_DATA_STORAGE_SETTINGS_HREF}?tab=logs`, targetId: SYSTEM_SETTINGS_TARGETS.logs, order: 624, }, diff --git a/apps/web/lib/settings-discovery/target.test.ts b/apps/web/lib/settings-discovery/target.test.ts index 683c21d2c3..2858c6c25f 100644 --- a/apps/web/lib/settings-discovery/target.test.ts +++ b/apps/web/lib/settings-discovery/target.test.ts @@ -67,6 +67,24 @@ describe("settings target registry", () => { expect(reveal).toHaveBeenCalledTimes(2); }); + + it("waits for a hidden target until its owning tab is visible", () => { + const reveal = vi.fn(); + const registry = createSettingsTargetRegistry(reveal); + const panel = document.createElement("div"); + panel.hidden = true; + const target = document.createElement("div"); + panel.appendChild(target); + document.body.appendChild(panel); + + registry.register("backups", target); + expect(registry.request("backups")).toBe(false); + expect(reveal).not.toHaveBeenCalled(); + + panel.hidden = false; + registry.register("backups", target); + expect(reveal).toHaveBeenCalledWith(target); + }); }); describe("revealSettingsTarget", () => { diff --git a/apps/web/lib/settings-discovery/target.ts b/apps/web/lib/settings-discovery/target.ts index 7b186a1f43..fe77e2109e 100644 --- a/apps/web/lib/settings-discovery/target.ts +++ b/apps/web/lib/settings-discovery/target.ts @@ -48,11 +48,16 @@ export function createSettingsTargetRegistry( const targets = new Map(); let pendingTargetId: string | null = null; + const isHidden = (element: HTMLElement) => + element.hidden || + element.closest("[hidden]") !== null || + element.closest('[aria-hidden="true"]') !== null; + return { register(targetId, element) { targets.set(targetId, element); element.setAttribute(SETTINGS_TARGET_ATTRIBUTE, targetId); - if (pendingTargetId === targetId) { + if (pendingTargetId === targetId && !isHidden(element)) { pendingTargetId = null; reveal(element); } @@ -69,6 +74,10 @@ export function createSettingsTargetRegistry( pendingTargetId = targetId; return false; } + if (isHidden(element)) { + pendingTargetId = targetId; + return false; + } pendingTargetId = null; reveal(element); return true; diff --git a/apps/web/lib/utils/retention-status.test.ts b/apps/web/lib/utils/retention-status.test.ts new file mode 100644 index 0000000000..2d0b9daa42 --- /dev/null +++ b/apps/web/lib/utils/retention-status.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { RetentionSettings, RetentionStatus } from "@/lib/types/system"; +import { buildRetentionStatusViewModel } from "./retention-status"; + +const settings: RetentionSettings = { + enabled: true, + sweep_interval_hours: 6, + batch_limit: 5000, + routine_runs: { window_days: 30, floor_per_owner: 50, warn_rows: 10 }, + runs: { window_days: 30, floor_per_owner: 50, warn_rows: 10 }, + run_events: { warn_rows: 10 }, +}; +const AS_OF = "2026-09-15T10:00:00Z"; + +function status(overrides: Partial = {}): RetentionStatus { + return { + settings, + last_sweep: null, + skip_count: 0, + retained_counts: { + office_routine_runs: { state: "not_computed", retained_count: 0, as_of: "" }, + runs: { state: "not_computed", retained_count: 0, as_of: "" }, + run_events: { state: "not_computed", retained_count: 0, as_of: "" }, + }, + ...overrides, + }; +} + +describe("buildRetentionStatusViewModel", () => { + it("keeps unavailable counts distinct from zero and compares thresholds strictly", () => { + const model = buildRetentionStatusViewModel( + status({ + retained_counts: { + office_routine_runs: { + state: "fresh", + retained_count: 10, + as_of: AS_OF, + }, + runs: { state: "fresh", retained_count: 11, as_of: AS_OF }, + run_events: { state: "fresh", retained_count: 0, as_of: AS_OF }, + }, + }), + ); + + expect(model.counts.map((count) => [count.state, count.overThreshold])).toEqual([ + ["measured", false], + ["measured", true], + ["zero", false], + ]); + expect(model.commonAsOf).toBe(AS_OF); + }); + + it("sums committed records and preserves mixed preview and deletion state", () => { + const model = buildRetentionStatusViewModel( + status({ + last_sweep: { + started_at: "2026-09-15T09:00:00Z", + finished_at: "2026-09-15T09:00:05Z", + office_routine_runs: { + deleted: 4, + backlog: true, + error: "", + previewed: false, + would_delete: 0, + }, + runs: { deleted: 0, backlog: false, error: "", previewed: true, would_delete: 8 }, + run_events: { deleted: 2, backlog: false, error: "" }, + route_attempts: { deleted: 1, backlog: false, error: "" }, + run_skills: { deleted: 3, backlog: false, error: "" }, + }, + }), + ); + + expect(model.sweep).toMatchObject({ + outcome: "mixed", + deletionTotal: 10, + hasDeletion: true, + hasPreview: true, + hasBacklog: true, + hasError: false, + }); + }); +}); diff --git a/apps/web/lib/utils/retention-status.ts b/apps/web/lib/utils/retention-status.ts new file mode 100644 index 0000000000..97ca7a7fad --- /dev/null +++ b/apps/web/lib/utils/retention-status.ts @@ -0,0 +1,117 @@ +import type { + RetentionLastSweep, + RetentionStatus, + RetentionTableCensus, + RetentionTableSweepResult, + RetentionSweptTableResult, +} from "@/lib/types/system"; + +export const RETENTION_COUNT_KEYS = ["office_routine_runs", "runs", "run_events"] as const; + +export type RetentionCountKey = (typeof RETENTION_COUNT_KEYS)[number]; +export type RetentionCountState = "unavailable" | "zero" | "measured" | "stale"; +export type RetentionSweepOutcome = "none" | "success" | "preview" | "mixed" | "error"; + +export type RetentionCountView = { + key: RetentionCountKey; + census: RetentionTableCensus; + state: RetentionCountState; + overThreshold: boolean; +}; + +export type RetentionSweepView = { + outcome: RetentionSweepOutcome; + deletionTotal: number; + hasDeletion: boolean; + hasPreview: boolean; + hasBacklog: boolean; + hasError: boolean; +}; + +export type RetentionStatusViewModel = { + enabled: boolean; + counts: RetentionCountView[]; + commonAsOf: string | null; + sweep: RetentionSweepView; +}; + +const sweepResults = (lastSweep: RetentionLastSweep | null): RetentionTableSweepResult[] => { + if (!lastSweep) return []; + return [ + lastSweep.office_routine_runs, + lastSweep.runs, + lastSweep.run_events, + lastSweep.route_attempts, + lastSweep.run_skills, + ]; +}; + +const isSweptResult = (result: RetentionTableSweepResult): result is RetentionSweptTableResult => + "previewed" in result; + +function countState(census: RetentionTableCensus): RetentionCountState { + if (census.state === "not_computed") return "unavailable"; + if (census.state === "stale") return "stale"; + return census.retained_count === 0 ? "zero" : "measured"; +} + +function commonTimestamp(counts: RetentionCountView[]): string | null { + const timestamps = counts + .filter((count) => count.census.state !== "not_computed" && count.census.as_of) + .map((count) => count.census.as_of); + if (timestamps.length !== counts.length || new Set(timestamps).size !== 1) return null; + return timestamps[0] ?? null; +} + +function sweepView(lastSweep: RetentionLastSweep | null): RetentionSweepView { + if (!lastSweep) { + return { + outcome: "none", + deletionTotal: 0, + hasDeletion: false, + hasPreview: false, + hasBacklog: false, + hasError: false, + }; + } + const results = sweepResults(lastSweep); + const hasDeletion = results.some((result) => result.deleted > 0); + const hasPreview = results.some((result) => isSweptResult(result) && result.previewed); + const hasBacklog = results.some((result) => result.backlog); + const hasError = results.some((result) => Boolean(result.error)); + let outcome: RetentionSweepOutcome = "success"; + if (hasError) outcome = "error"; + else if (hasDeletion && hasPreview) outcome = "mixed"; + else if (hasPreview) outcome = "preview"; + return { + outcome, + deletionTotal: results.reduce((total, result) => total + result.deleted, 0), + hasDeletion, + hasPreview, + hasBacklog, + hasError, + }; +} + +export function buildRetentionStatusViewModel(status: RetentionStatus): RetentionStatusViewModel { + const thresholds: Record = { + office_routine_runs: status.settings.routine_runs.warn_rows, + runs: status.settings.runs.warn_rows, + run_events: status.settings.run_events.warn_rows, + }; + const counts = RETENTION_COUNT_KEYS.map((key) => { + const census = status.retained_counts[key]; + return { + key, + census, + state: countState(census), + overThreshold: thresholds[key] > 0 && census.retained_count > thresholds[key], + }; + }); + return { + enabled: status.settings.enabled, + counts, + commonAsOf: commonTimestamp(counts), + sweep: sweepView(status.last_sweep), + }; +} diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index 622406f6a9..7e016852ac 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -210,37 +210,72 @@ "navFeatureToggles": "Feature Toggles", "navLicenses": "Licenses", "navLogs": "Logs", - "navRetention": "Run History Retention", + "navRetention": "Office retention", "navUpdates": "Updates", "navUsers": "Users", "retentionPageDescription": "Bounds how long Office run history (office_routine_runs, runs, and their satellite tables) stays in the database.", "retentionAdminOnly": "Only an admin can change retention settings.", "retentionLoadFailed": "Retention settings could not be loaded.", "retentionSaveFailed": "Retention settings could not be saved.", + "retentionInvalidFields": "Fix the highlighted retention fields before saving.", + "retentionInvalidField": "Enter a value within the allowed range.", "retentionPolicyTitle": "Retention policy", - "retentionPolicyDescription": "A scheduled sweep, separate from the 5-second Office tick, deletes finished run history once it passes its retention window. Rows that represent current work in progress are never deleted, regardless of these settings.", - "retentionEnabledLabel": "Delete eligible run history", - "retentionEnabledDescription": "When off, Kandev never deletes office_routine_runs or runs rows. Retained-row counts below keep updating so you can see backlog build up before turning deletion back on.", + "retentionPolicyDescription": "Automatically remove old finished history and related run details. Active work remains protected.", + "retentionEnabledLabel": "Automatic cleanup", + "retentionEnabledDescription": "When on, Kandev removes old finished history and related details. Active work remains protected.", + "retentionActiveWorkProtection": "Cleanup removes old finished history and related run details. Active work remains protected.", + "retentionAdvancedSettingsTitle": "Advanced settings", + "retentionAdvancedSettingsDescription": "Adjust cleanup timing, batch size, and warning thresholds.", "retentionSweepIntervalLabel": "Sweep interval (hours)", "retentionSweepIntervalHelp": "How often the sweep runs, from 1 to 168 hours (1 week).", "retentionBatchLimitLabel": "Rows deleted per sweep", "retentionBatchLimitHelp": "Maximum rows deleted per table in one sweep, from 100 to 100,000. Lower this if a sweep competes with other database load; a large backlog is cleared over several sweeps instead of one.", - "retentionRoutineRunsSectionTitle": "office_routine_runs", - "retentionRoutineRunsSectionDescription": "Routine execution records: one row per completed or failed routine run.", - "retentionRunsSectionTitle": "runs", - "retentionRunsSectionDescription": "Task run records: one row per completed, failed, or cancelled run.", + "retentionRoutineRunsSectionTitle": "Routine history", + "retentionRoutineRunsSectionDescription": "Routine activity, including skipped and coalesced executions.", + "retentionRunsSectionTitle": "Agent run history", + "retentionRunsSectionDescription": "Office agent runs and their finished states.", + "retentionRoutineHistoryLabel": "Routine history", + "retentionRoutineHistoryDescription": "Routine activity, including skipped and coalesced executions.", + "retentionAgentRunHistoryLabel": "Agent run history", + "retentionAgentRunHistoryDescription": "Office agent runs and their finished states.", + "retentionRunEventsLabel": "Run events", + "retentionProviderAttemptsLabel": "Provider attempts", + "retentionRunSkillRecordsLabel": "Run skill records", "retentionRunEventsSectionTitle": "run_events and satellite tables", "retentionRunEventsSectionDescription": "run_events, office_run_route_attempts, and office_run_skills rows are deleted together with the run that owns them; they have no window or floor of their own.", "retentionWindowDaysLabel": "Retention window (days)", "retentionWindowDaysHelp": "Finished rows older than this many days become eligible for deletion, from 1 to 3650 days.", "retentionFloorPerOwnerLabel": "Minimum kept per owner", + "retentionRoutineFloorLabel": "Minimum per routine", + "retentionAgentFloorLabel": "Minimum per agent profile", "retentionFloorPerOwnerHelp": "Always keeps at least this many of each owner's most recent finished rows, even past the retention window, from 0 to 10,000. Set to 0 to disable the floor.", "retentionWarnRowsLabel": "Warn above this many retained rows", "retentionWarnRowsHelp": "Raises a health warning once retained rows exceed this count. Set to 0 to disable the warning.", "retentionRunEventsWarnRowsHelp": "Raises a health warning once total retained run_events rows exceed this count. Set to 0 to disable the warning.", + "retentionWarningThresholdsTitle": "Warning thresholds", + "retentionWarningThresholdsDescription": "Warnings apply to stored rows. A value of 0 disables that warning.", "retentionStatusTitle": "Retention status", "retentionStatusDescription": "The most recent sweep outcome and the current retained-row counts for each thresholded table.", "retentionNeverSweptMessage": "No sweep has run yet since the backend last started.", + "retentionAutomaticCleanupLabel": "Automatic cleanup", + "retentionEnabledValue": "On", + "retentionDisabledValue": "Off", + "retentionLastCleanupLabel": "Last cleanup", + "retentionOutcomeCompleted": "Completed", + "retentionOutcomePreview": "Preview only", + "retentionOutcomeMixed": "Mixed result", + "retentionOutcomeError": "Completed with errors", + "retentionDeletionTotal_one": "{{count}} record deleted", + "retentionDeletionTotal_other": "{{count}} records deleted", + "retentionBacklogNotice": "More eligible history remains for a later cleanup.", + "retentionStoredCountsDescription": "Stored counts include protected work and are not a deletion estimate.", + "retentionUpdatedLabel": "Updated", + "retentionCleanupDetailsTitle": "Cleanup details", + "retentionCensusUnavailable": "Not available", + "retentionCensusZero": "0 records", + "retentionRecords_one": "{{count}} record", + "retentionRecords_other": "{{count}} records", + "retentionAboveWarningThreshold": "Above warning threshold", "retentionSweepStartedAtLabel": "Started", "retentionSweepFinishedAtLabel": "Finished", "retentionDeletedLabel": "Deleted", @@ -390,7 +425,9 @@ "storageDeleteDialogTitle": "Permanently delete quarantined data", "storageDeletePermanently": "Delete permanently", "storageDeletionComplete": "Deletion complete", - "storageDescription": "Review disk use and reclaim space from Kandev-owned workspaces, caches, and Docker resources whenever your installation needs it.", + "storageDescription": "Manage host disk use and Office history retention while keeping active work protected.", + "storageHostTab": "Host", + "officeRetentionTab": "Office retention", "storageDockerBuildCache": "Docker build cache", "storageDockerBuildCacheDescription": "Remove old build cache while retaining the configured amount.", "storageDockerBuildCacheHelp": "Uses Docker's age and storage filters to remove old build cache. It does not run docker system prune, does not remove volumes globally, and remains disabled until the dedicated-daemon acknowledgment is confirmed.", @@ -496,9 +533,9 @@ "storageTemporaryArtifacts": "Kandev temporary artifacts", "storageTemporaryArtifactsActiveCount_one": "{{count}} active", "storageTemporaryArtifactsActiveCount_other": "{{count}} active", - "storageTemporaryArtifactsDialogAction": "Move to quarantine", + "storageTemporaryArtifactsDialogAction": "Move eligible files to quarantine", "storageTemporaryArtifactsDialogDescription": "Eligible registered artifacts will be moved to quarantine and can be restored during the retention period. Active or recent artifacts are protected.", - "storageTemporaryArtifactsDialogTitle": "Clean stale Kandev artifacts?", + "storageTemporaryArtifactsDialogTitle": "Clean inactive Kandev temporary files?", "storageTemporaryArtifactsNoStale": "No registered temporary artifacts are older than 24 hours.", "storageTemporaryArtifactsProtectedCount_one": "{{count}} protected", "storageTemporaryArtifactsProtectedCount_other": "{{count}} protected", @@ -506,11 +543,11 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} stale eligible", "storageTemporaryArtifactsStaleBytes": "{{value}} eligible", "storageTemporaryArtifactsUnavailable": "Temporary artifact ownership could not be measured", - "storageTemporaryArtifactsPolicyTitle": "Temporary artifacts", - "storageTemporaryArtifactsPolicyDescription": "Optionally clean registered Kandev artifacts during scheduled maintenance.", - "storageCleanStaleTemporaryArtifacts": "Clean stale Kandev temporary artifacts", - "storageTemporaryArtifactsPolicySummary": "Registered inactive artifacts only. Minimum age: 24 hours.", - "storageTemporaryArtifactsPolicyHelp": "Only registered artifacts that are inactive and at least 24 hours old are eligible. Files move to quarantine before permanent deletion. Shared and legacy temporary files remain untouched.", + "storageTemporaryArtifactsPolicyTitle": "Temporary Kandev files", + "storageTemporaryArtifactsPolicyDescription": "Diagnostic bundles and utility working folders that Kandev registers for temporary use.", + "storageCleanStaleTemporaryArtifacts": "Clean inactive Kandev temporary files", + "storageTemporaryArtifactsPolicySummary": "Registered diagnostic bundles and utility working folders. Inactive for at least 24 hours.", + "storageTemporaryArtifactsPolicyHelp": "Only registered files that are inactive and at least 24 hours old are eligible. Files move to quarantine before permanent deletion. Shared temporary folders and unrelated package caches are excluded.", "storageTemporaryArtifactsPolicyAge": "Registered inactive artifacts only. Minimum age: 24 hours.", "storageTemporaryArtifactsPolicyQuarantine": "Files move to quarantine before permanent deletion.", "storageTemporaryArtifactsPolicyShared": "Shared temporary files are excluded.", diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index c87997ecdb..292cbd61a5 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -194,7 +194,7 @@ "cursorPaginationSummary_other": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţś", "allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś", "changeRequestCreationFailed": "Ćĥàńĝē-ŕēqũēśţ ćŕēàţĩōń ƒàĩĺēď", - "resultCount_one": "Ŕēśũĺţ {{count}}", - "resultCount_other": "Ŕēśũĺţś {{count}}", + "resultCount_one": "Ŕēśũĺţ <ćōũńţ>{{count}}", + "resultCount_other": "Ŕēśũĺţś <ćōũńţ>{{count}}", "loadingResults": "Ĺōàďĩńĝ ŕēśũĺţś…" } diff --git a/apps/web/src/locales/pseudo/stats.json b/apps/web/src/locales/pseudo/stats.json index 8eb3722a52..e08b442c4d 100644 --- a/apps/web/src/locales/pseudo/stats.json +++ b/apps/web/src/locales/pseudo/stats.json @@ -83,7 +83,7 @@ "failedToLoadSection": "Ƒàĩĺēď ţō ĺōàď śēćţĩōń", "failedToLoadStats": "Ƒàĩĺēď ţō ĺōàď śţàţś", "statsAccessDenied": "Ŷōũ ďō ńōţ ĥàvē àććēśś ţō ţĥēśē śţàţĩśţĩćś.", - "statsTemporarilyUnavailable": "Śţàţĩśţĩćś àŕē ţēḿƥōŕàŕĩĺŷ ũńàvàĩĺàɓĺē. Ţŕŷ àĝàĩń.", + "statsTemporarilyUnavailable": "Śţàţĩśţĩćś àŕē ţēḿƥōŕàŕĩĺŷ ũńàvàĩĺàƀĺē. Ţŕŷ àĝàĩń.", "retry": "Ŕēţŕŷ", "retrying": "Ŕēţŕŷĩńĝ…", "loadingStats": "Ĺōàďĩńĝ śţàţś…", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index 78b1b1760a..047a20cdfc 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -210,37 +210,72 @@ "navFeatureToggles": "Ƒēàţũŕē Ţōĝĝĺēś", "navLicenses": "Ĺĩćēńśēś", "navLogs": "Ĺōĝś", - "navRetention": "Ŕũń Ĥĩśţōŕŷ Ŕēţēńţĩōń", + "navRetention": "Ōƒƒĩćē ŕēţēńţĩōń", "navUpdates": "Ũƥďàţēś", "navUsers": "Ũśēŕś", "retentionPageDescription": "Ɓōũńďś ĥōŵ ĺōńĝ Ōƒƒĩćē ŕũń ĥĩśţōŕŷ (ōƒƒĩćē_ŕōũţĩńē_ŕũńś, ŕũńś, àńď ţĥēĩŕ śàţēĺĺĩţē ţàƀĺēś) śţàŷś ĩń ţĥē ďàţàƀàśē.", "retentionAdminOnly": "Ōńĺŷ àń àďḿĩń ćàń ćĥàńĝē ŕēţēńţĩōń śēţţĩńĝś.", "retentionLoadFailed": "Ŕēţēńţĩōń śēţţĩńĝś ćōũĺď ńōţ ƀē ĺōàďēď.", "retentionSaveFailed": "Ŕēţēńţĩōń śēţţĩńĝś ćōũĺď ńōţ ƀē śàvēď.", + "retentionInvalidFields": "Ƒĩx ţĥē ĥĩĝĥĺĩĝĥţēď ŕēţēńţĩōń ƒĩēĺďś ƀēƒōŕē śàvĩńĝ.", + "retentionInvalidField": "Ēńţēŕ à vàĺũē ŵĩţĥĩń ţĥē àĺĺōŵēď ŕàńĝē.", "retentionPolicyTitle": "Ŕēţēńţĩōń ƥōĺĩćŷ", - "retentionPolicyDescription": "À śćĥēďũĺēď śŵēēƥ, śēƥàŕàţē ƒŕōḿ ţĥē 5-śēćōńď Ōƒƒĩćē ţĩćķ, ďēĺēţēś ƒĩńĩśĥēď ŕũń ĥĩśţōŕŷ ōńćē ĩţ ƥàśśēś ĩţś ŕēţēńţĩōń ŵĩńďōŵ. Ŕōŵś ţĥàţ ŕēƥŕēśēńţ ćũŕŕēńţ ŵōŕķ ĩń ƥŕōĝŕēśś àŕē ńēvēŕ ďēĺēţēď, ŕēĝàŕďĺēśś ōƒ ţĥēśē śēţţĩńĝś.", - "retentionEnabledLabel": "Ďēĺēţē ēĺĩĝĩƀĺē ŕũń ĥĩśţōŕŷ", - "retentionEnabledDescription": "Ŵĥēń ōƒƒ, Ķàńďēv ńēvēŕ ďēĺēţēś ōƒƒĩćē_ŕōũţĩńē_ŕũńś ōŕ ŕũńś ŕōŵś. Ŕēţàĩńēď-ŕōŵ ćōũńţś ƀēĺōŵ ķēēƥ ũƥďàţĩńĝ śō ŷōũ ćàń śēē ƀàćķĺōĝ ƀũĩĺď ũƥ ƀēƒōŕē ţũŕńĩńĝ ďēĺēţĩōń ƀàćķ ōń.", + "retentionPolicyDescription": "Àũţōḿàţĩćàĺĺŷ ŕēḿōvē ōĺď ƒĩńĩśĥēď ĥĩśţōŕŷ àńď ŕēĺàţēď ŕũń ďēţàĩĺś. Àćţĩvē ŵōŕķ ŕēḿàĩńś ƥŕōţēćţēď.", + "retentionEnabledLabel": "Àũţōḿàţĩć ćĺēàńũƥ", + "retentionEnabledDescription": "Ŵĥēń ōń, Ķàńďēv ŕēḿōvēś ōĺď ƒĩńĩśĥēď ĥĩśţōŕŷ àńď ŕēĺàţēď ďēţàĩĺś. Àćţĩvē ŵōŕķ ŕēḿàĩńś ƥŕōţēćţēď.", + "retentionActiveWorkProtection": "Ćĺēàńũƥ ŕēḿōvēś ōĺď ƒĩńĩśĥēď ĥĩśţōŕŷ àńď ŕēĺàţēď ŕũń ďēţàĩĺś. Àćţĩvē ŵōŕķ ŕēḿàĩńś ƥŕōţēćţēď.", + "retentionAdvancedSettingsTitle": "Àďvàńćēď śēţţĩńĝś", + "retentionAdvancedSettingsDescription": "Àďĵũśţ ćĺēàńũƥ ţĩḿĩńĝ, ƀàţćĥ śĩźē, àńď ŵàŕńĩńĝ ţĥŕēśĥōĺďś.", "retentionSweepIntervalLabel": "Śŵēēƥ ĩńţēŕvàĺ (ĥōũŕś)", "retentionSweepIntervalHelp": "Ĥōŵ ōƒţēń ţĥē śŵēēƥ ŕũńś, ƒŕōḿ 1 ţō 168 ĥōũŕś (1 ŵēēķ).", "retentionBatchLimitLabel": "Ŕōŵś ďēĺēţēď ƥēŕ śŵēēƥ", "retentionBatchLimitHelp": "Ḿàxĩḿũḿ ŕōŵś ďēĺēţēď ƥēŕ ţàƀĺē ĩń ōńē śŵēēƥ, ƒŕōḿ 100 ţō 100,000. Ĺōŵēŕ ţĥĩś ĩƒ à śŵēēƥ ćōḿƥēţēś ŵĩţĥ ōţĥēŕ ďàţàƀàśē ĺōàď; à ĺàŕĝē ƀàćķĺōĝ ĩś ćĺēàŕēď ōvēŕ śēvēŕàĺ śŵēēƥś ĩńśţēàď ōƒ ōńē.", - "retentionRoutineRunsSectionTitle": "ōƒƒĩćē_ŕōũţĩńē_ŕũńś", - "retentionRoutineRunsSectionDescription": "Ŕōũţĩńē ēxēćũţĩōń ŕēćōŕďś: ōńē ŕōŵ ƥēŕ ćōḿƥĺēţēď ōŕ ƒàĩĺēď ŕōũţĩńē ŕũń.", - "retentionRunsSectionTitle": "ŕũńś", - "retentionRunsSectionDescription": "Ţàśķ ŕũń ŕēćōŕďś: ōńē ŕōŵ ƥēŕ ćōḿƥĺēţēď, ƒàĩĺēď, ōŕ ćàńćēĺĺēď ŕũń.", + "retentionRoutineRunsSectionTitle": "Ŕōũţĩńē ĥĩśţōŕŷ", + "retentionRoutineRunsSectionDescription": "Ŕōũţĩńē àćţĩvĩţŷ, ĩńćĺũďĩńĝ śķĩƥƥēď àńď ćōàĺēśćēď ēxēćũţĩōńś.", + "retentionRunsSectionTitle": "Àĝēńţ ŕũń ĥĩśţōŕŷ", + "retentionRunsSectionDescription": "Ōƒƒĩćē àĝēńţ ŕũńś àńď ţĥēĩŕ ƒĩńĩśĥēď śţàţēś.", + "retentionRoutineHistoryLabel": "Ŕōũţĩńē ĥĩśţōŕŷ", + "retentionRoutineHistoryDescription": "Ŕōũţĩńē àćţĩvĩţŷ, ĩńćĺũďĩńĝ śķĩƥƥēď àńď ćōàĺēśćēď ēxēćũţĩōńś.", + "retentionAgentRunHistoryLabel": "Àĝēńţ ŕũń ĥĩśţōŕŷ", + "retentionAgentRunHistoryDescription": "Ōƒƒĩćē àĝēńţ ŕũńś àńď ţĥēĩŕ ƒĩńĩśĥēď śţàţēś.", + "retentionRunEventsLabel": "Ŕũń ēvēńţś", + "retentionProviderAttemptsLabel": "Ƥŕōvĩďēŕ àţţēḿƥţś", + "retentionRunSkillRecordsLabel": "Ŕũń śķĩĺĺ ŕēćōŕďś", "retentionRunEventsSectionTitle": "ŕũń_ēvēńţś àńď śàţēĺĺĩţē ţàƀĺēś", "retentionRunEventsSectionDescription": "ŕũń_ēvēńţś, ōƒƒĩćē_ŕũń_ŕōũţē_àţţēḿƥţś, àńď ōƒƒĩćē_ŕũń_śķĩĺĺś ŕōŵś àŕē ďēĺēţēď ţōĝēţĥēŕ ŵĩţĥ ţĥē ŕũń ţĥàţ ōŵńś ţĥēḿ; ţĥēŷ ĥàvē ńō ŵĩńďōŵ ōŕ ƒĺōōŕ ōƒ ţĥēĩŕ ōŵń.", "retentionWindowDaysLabel": "Ŕēţēńţĩōń ŵĩńďōŵ (ďàŷś)", "retentionWindowDaysHelp": "Ƒĩńĩśĥēď ŕōŵś ōĺďēŕ ţĥàń ţĥĩś ḿàńŷ ďàŷś ƀēćōḿē ēĺĩĝĩƀĺē ƒōŕ ďēĺēţĩōń, ƒŕōḿ 1 ţō 3650 ďàŷś.", "retentionFloorPerOwnerLabel": "Ḿĩńĩḿũḿ ķēƥţ ƥēŕ ōŵńēŕ", + "retentionRoutineFloorLabel": "Ḿĩńĩḿũḿ ƥēŕ ŕōũţĩńē", + "retentionAgentFloorLabel": "Ḿĩńĩḿũḿ ƥēŕ àĝēńţ ƥŕōƒĩĺē", "retentionFloorPerOwnerHelp": "Àĺŵàŷś ķēēƥś àţ ĺēàśţ ţĥĩś ḿàńŷ ōƒ ēàćĥ ōŵńēŕ'ś ḿōśţ ŕēćēńţ ƒĩńĩśĥēď ŕōŵś, ēvēń ƥàśţ ţĥē ŕēţēńţĩōń ŵĩńďōŵ, ƒŕōḿ 0 ţō 10,000. Śēţ ţō 0 ţō ďĩśàƀĺē ţĥē ƒĺōōŕ.", "retentionWarnRowsLabel": "Ŵàŕń àƀōvē ţĥĩś ḿàńŷ ŕēţàĩńēď ŕōŵś", "retentionWarnRowsHelp": "Ŕàĩśēś à ĥēàĺţĥ ŵàŕńĩńĝ ōńćē ŕēţàĩńēď ŕōŵś ēxćēēď ţĥĩś ćōũńţ. Śēţ ţō 0 ţō ďĩśàƀĺē ţĥē ŵàŕńĩńĝ.", "retentionRunEventsWarnRowsHelp": "Ŕàĩśēś à ĥēàĺţĥ ŵàŕńĩńĝ ōńćē ţōţàĺ ŕēţàĩńēď ŕũń_ēvēńţś ŕōŵś ēxćēēď ţĥĩś ćōũńţ. Śēţ ţō 0 ţō ďĩśàƀĺē ţĥē ŵàŕńĩńĝ.", + "retentionWarningThresholdsTitle": "Ŵàŕńĩńĝ ţĥŕēśĥōĺďś", + "retentionWarningThresholdsDescription": "Ŵàŕńĩńĝś àƥƥĺŷ ţō śţōŕēď ŕōŵś. À vàĺũē ōƒ 0 ďĩśàƀĺēś ţĥàţ ŵàŕńĩńĝ.", "retentionStatusTitle": "Ŕēţēńţĩōń śţàţũś", "retentionStatusDescription": "Ţĥē ḿōśţ ŕēćēńţ śŵēēƥ ōũţćōḿē àńď ţĥē ćũŕŕēńţ ŕēţàĩńēď-ŕōŵ ćōũńţś ƒōŕ ēàćĥ ţĥŕēśĥōĺďēď ţàƀĺē.", "retentionNeverSweptMessage": "Ńō śŵēēƥ ĥàś ŕũń ŷēţ śĩńćē ţĥē ƀàćķēńď ĺàśţ śţàŕţēď.", + "retentionAutomaticCleanupLabel": "Àũţōḿàţĩć ćĺēàńũƥ", + "retentionEnabledValue": "Ōń", + "retentionDisabledValue": "Ōƒƒ", + "retentionLastCleanupLabel": "Ĺàśţ ćĺēàńũƥ", + "retentionOutcomeCompleted": "Ćōḿƥĺēţēď", + "retentionOutcomePreview": "Ƥŕēvĩēŵ ōńĺŷ", + "retentionOutcomeMixed": "Ḿĩxēď ŕēśũĺţ", + "retentionOutcomeError": "Ćōḿƥĺēţēď ŵĩţĥ ēŕŕōŕś", + "retentionDeletionTotal_one": "{{count}} ŕēćōŕď ďēĺēţēď", + "retentionDeletionTotal_other": "{{count}} ŕēćōŕďś ďēĺēţēď", + "retentionBacklogNotice": "Ḿōŕē ēĺĩĝĩƀĺē ĥĩśţōŕŷ ŕēḿàĩńś ƒōŕ à ĺàţēŕ ćĺēàńũƥ.", + "retentionStoredCountsDescription": "Śţōŕēď ćōũńţś ĩńćĺũďē ƥŕōţēćţēď ŵōŕķ àńď àŕē ńōţ à ďēĺēţĩōń ēśţĩḿàţē.", + "retentionUpdatedLabel": "Ũƥďàţēď", + "retentionCleanupDetailsTitle": "Ćĺēàńũƥ ďēţàĩĺś", + "retentionCensusUnavailable": "Ńōţ àvàĩĺàƀĺē", + "retentionCensusZero": "0 ŕēćōŕďś", + "retentionRecords_one": "{{count}} ŕēćōŕď", + "retentionRecords_other": "{{count}} ŕēćōŕďś", + "retentionAboveWarningThreshold": "Àƀōvē ŵàŕńĩńĝ ţĥŕēśĥōĺď", "retentionSweepStartedAtLabel": "Śţàŕţēď", "retentionSweepFinishedAtLabel": "Ƒĩńĩśĥēď", "retentionDeletedLabel": "Ďēĺēţēď", @@ -390,7 +425,9 @@ "storageDeleteDialogTitle": "Ƥēŕḿàńēńţĺŷ ďēĺēţē qũàŕàńţĩńēď ďàţà", "storageDeletePermanently": "Ďēĺēţē ƥēŕḿàńēńţĺŷ", "storageDeletionComplete": "Ďēĺēţĩōń ćōḿƥĺēţē", - "storageDescription": "Ŕēvĩēŵ ďĩśķ ũśē àńď ŕēćĺàĩḿ śƥàćē ƒŕōḿ Ķàńďēv-ōŵńēď ŵōŕķśƥàćēś, ćàćĥēś, àńď Ďōćķēŕ ŕēśōũŕćēś ŵĥēńēvēŕ ŷōũŕ ĩńśţàĺĺàţĩōń ńēēďś ĩţ.", + "storageDescription": "Ḿàńàĝē ĥōśţ ďĩśķ ũśē àńď Ōƒƒĩćē ĥĩśţōŕŷ ŕēţēńţĩōń ŵĥĩĺē ķēēƥĩńĝ àćţĩvē ŵōŕķ ƥŕōţēćţēď.", + "storageHostTab": "Ĥōśţ", + "officeRetentionTab": "Ōƒƒĩćē ŕēţēńţĩōń", "storageDockerBuildCache": "Ďōćķēŕ ƀũĩĺď ćàćĥē", "storageDockerBuildCacheDescription": "Ŕēḿōvē ōĺď ƀũĩĺď ćàćĥē ŵĥĩĺē ŕēţàĩńĩńĝ ţĥē ćōńƒĩĝũŕēď àḿōũńţ.", "storageDockerBuildCacheHelp": "Ũśēś Ďōćķēŕ'ś àĝē àńď śţōŕàĝē ƒĩĺţēŕś ţō ŕēḿōvē ōĺď ƀũĩĺď ćàćĥē. Ĩţ ďōēś ńōţ ŕũń ďōćķēŕ śŷśţēḿ ƥŕũńē, ďōēś ńōţ ŕēḿōvē vōĺũḿēś ĝĺōƀàĺĺŷ, àńď ŕēḿàĩńś ďĩśàƀĺēď ũńţĩĺ ţĥē ďēďĩćàţēď-ďàēḿōń àćķńōŵĺēďĝḿēńţ ĩś ćōńƒĩŕḿēď.", @@ -496,9 +533,9 @@ "storageTemporaryArtifacts": "Ķàńďēv ţēḿƥōŕàŕŷ àŕţĩƒàćţś", "storageTemporaryArtifactsActiveCount_one": "{{count}} àćţĩvē", "storageTemporaryArtifactsActiveCount_other": "{{count}} àćţĩvē", - "storageTemporaryArtifactsDialogAction": "Ḿōvē ţō qũàŕàńţĩńē", + "storageTemporaryArtifactsDialogAction": "Ḿōvē ēĺĩĝĩƀĺē ƒĩĺēś ţō qũàŕàńţĩńē", "storageTemporaryArtifactsDialogDescription": "Ēĺĩĝĩƀĺē ŕēĝĩśţēŕēď àŕţĩƒàćţś ŵĩĺĺ ƀē ḿōvēď ţō qũàŕàńţĩńē àńď ćàń ƀē ŕēśţōŕēď ďũŕĩńĝ ţĥē ŕēţēńţĩōń ƥēŕĩōď. Àćţĩvē ōŕ ŕēćēńţ àŕţĩƒàćţś àŕē ƥŕōţēćţēď.", - "storageTemporaryArtifactsDialogTitle": "Ćĺēàń śţàĺē Ķàńďēv àŕţĩƒàćţś?", + "storageTemporaryArtifactsDialogTitle": "Ćĺēàń ĩńàćţĩvē Ķàńďēv ţēḿƥōŕàŕŷ ƒĩĺēś?", "storageTemporaryArtifactsNoStale": "Ńō ŕēĝĩśţēŕēď ţēḿƥōŕàŕŷ àŕţĩƒàćţś àŕē ōĺďēŕ ţĥàń 24 ĥōũŕś.", "storageTemporaryArtifactsProtectedCount_one": "{{count}} ƥŕōţēćţēď", "storageTemporaryArtifactsProtectedCount_other": "{{count}} ƥŕōţēćţēď", @@ -506,11 +543,11 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} śţàĺē ēĺĩĝĩƀĺē", "storageTemporaryArtifactsStaleBytes": "{{value}} ēĺĩĝĩƀĺē", "storageTemporaryArtifactsUnavailable": "Ţēḿƥōŕàŕŷ àŕţĩƒàćţ ōŵńēŕśĥĩƥ ćōũĺď ńōţ ƀē ḿēàśũŕēď", - "storageTemporaryArtifactsPolicyTitle": "Ţēḿƥōŕàŕŷ àŕţĩƒàćţś", - "storageTemporaryArtifactsPolicyDescription": "Ōƥţĩōńàĺĺŷ ćĺēàń ŕēĝĩśţēŕēď Ķàńďēv àŕţĩƒàćţś ďũŕĩńĝ śćĥēďũĺēď ḿàĩńţēńàńćē.", - "storageCleanStaleTemporaryArtifacts": "Ćĺēàń śţàĺē Ķàńďēv ţēḿƥōŕàŕŷ àŕţĩƒàćţś", - "storageTemporaryArtifactsPolicySummary": "Ŕēĝĩśţēŕēď ĩńàćţĩvē àŕţĩƒàćţś ōńĺŷ. Ḿĩńĩḿũḿ àĝē: 24 ĥōũŕś.", - "storageTemporaryArtifactsPolicyHelp": "Ōńĺŷ ŕēĝĩśţēŕēď àŕţĩƒàćţś ţĥàţ àŕē ĩńàćţĩvē àńď àţ ĺēàśţ 24 ĥōũŕś ōĺď àŕē ēĺĩĝĩƀĺē. Ƒĩĺēś ḿōvē ţō qũàŕàńţĩńē ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń. Śĥàŕēď àńď ĺēĝàćŷ ţēḿƥōŕàŕŷ ƒĩĺēś ŕēḿàĩń ũńţōũćĥēď.", + "storageTemporaryArtifactsPolicyTitle": "Ţēḿƥōŕàŕŷ Ķàńďēv ƒĩĺēś", + "storageTemporaryArtifactsPolicyDescription": "Ďĩàĝńōśţĩć ƀũńďĺēś àńď ũţĩĺĩţŷ ŵōŕķĩńĝ ƒōĺďēŕś ţĥàţ Ķàńďēv ŕēĝĩśţēŕś ƒōŕ ţēḿƥōŕàŕŷ ũśē.", + "storageCleanStaleTemporaryArtifacts": "Ćĺēàń ĩńàćţĩvē Ķàńďēv ţēḿƥōŕàŕŷ ƒĩĺēś", + "storageTemporaryArtifactsPolicySummary": "Ŕēĝĩśţēŕēď ďĩàĝńōśţĩć ƀũńďĺēś àńď ũţĩĺĩţŷ ŵōŕķĩńĝ ƒōĺďēŕś. Ĩńàćţĩvē ƒōŕ àţ ĺēàśţ 24 ĥōũŕś.", + "storageTemporaryArtifactsPolicyHelp": "Ōńĺŷ ŕēĝĩśţēŕēď ƒĩĺēś ţĥàţ àŕē ĩńàćţĩvē àńď àţ ĺēàśţ 24 ĥōũŕś ōĺď àŕē ēĺĩĝĩƀĺē. Ƒĩĺēś ḿōvē ţō qũàŕàńţĩńē ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń. Śĥàŕēď ţēḿƥōŕàŕŷ ƒōĺďēŕś àńď ũńŕēĺàţēď ƥàćķàĝē ćàćĥēś àŕē ēxćĺũďēď.", "storageTemporaryArtifactsPolicyAge": "Ŕēĝĩśţēŕēď ĩńàćţĩvē àŕţĩƒàćţś ōńĺŷ. Ḿĩńĩḿũḿ àĝē: 24 ĥōũŕś.", "storageTemporaryArtifactsPolicyQuarantine": "Ƒĩĺēś ḿōvē ţō qũàŕàńţĩńē ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń.", "storageTemporaryArtifactsPolicyShared": "Śĥàŕēď ţēḿƥōŕàŕŷ ƒĩĺēś àŕē ēxćĺũďēď.", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 45922acddc..239cb65d6d 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -226,7 +226,7 @@ "cancelling": "Ćàńćēĺĺĩńĝ...", "cannotMarkDoneAwaitingApprovalFrom": "Ćàńńōţ ḿàŕķ ďōńē: àŵàĩţĩńĝ àƥƥŕōvàĺ ƒŕōḿ {{names}}", "cannotMarkDoneAwaitingApprovals": "Ćàńńōţ ḿàŕķ ďōńē: àŵàĩţĩńĝ àƥƥŕōvàĺś", - "cannotMarkDoneBeforeFinalWorkflowStep": "Ćàńńōţ ḿàŕķ ďōńē: ḿōvē ţħē ţàśķ ţō ţħē ƒĩńàĺ ŵōŕķƒĺōŵ śţēƥ ƒĩŕśţ", + "cannotMarkDoneBeforeFinalWorkflowStep": "Ćàńńōţ ḿàŕķ ďōńē: ḿōvē ţĥē ţàśķ ţō ţĥē ƒĩńàĺ ŵōŕķƒĺōŵ śţēƥ ƒĩŕśţ", "cannotPreviewThisFile": "Ćàńńōţ ƥŕēvĩēŵ ţĥĩś ƒĩĺē", "changeGithubIssue": "Ćĥàńĝē ĜĩţĤũƀ ĩśśũē", "changes": "Ćĥàńĝēś", @@ -707,7 +707,7 @@ "hideRightPanel": "Ĥĩďē ŕĩĝĥţ ƥàńēĺ", "hideRightPane": "Ĥĩďē ŕĩĝĥţ ƥàńē", "hideRightPanels": "Ĥĩďē ŕĩĝĥţ ƥàńēĺś", - "rightPaneUnavailable": "Ńō śēƥàŕàţē ŕĩĝĥţ ƥàńē ƭō ĥĩďē", + "rightPaneUnavailable": "Ńō śēƥàŕàţē ŕĩĝĥţ ƥàńē ţō ĥĩďē", "rightPaneUnavailableWhileMaximized": "Ŕĩĝĥţ ƥàńē ĩś ũńàvàĩĺàƀĺē ŵĥĩĺē à ƥàńēĺ ĩś ḿàxĩḿĩźēď", "rightPanelsUnavailableWhileMaximized": "Ŕĩĝĥţ ƥàńēĺś àŕē ũńàvàĩĺàƀĺē ŵĥĩĺē à ƥàńēĺ ĩś ḿàxĩḿĩźēď", "hideSidebar": "Ĥĩďē śĩďēƀàŕ", diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index e8a0371de2..0eb42acebe 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -205,37 +205,72 @@ "navFeatureToggles": "Interruptores de funcionalidades", "navLicenses": "Licenças", "navLogs": "Registos", - "navRetention": "Retenção do histórico de execuções", + "navRetention": "Retenção do Office", "navUpdates": "Atualizações", "navUsers": "Utilizadores", "retentionPageDescription": "Limita durante quanto tempo o histórico de execuções do Office (office_routine_runs, runs e as respetivas tabelas satélite) permanece na base de dados.", "retentionAdminOnly": "Só um administrador pode alterar as definições de retenção.", "retentionLoadFailed": "Não foi possível carregar as definições de retenção.", "retentionSaveFailed": "Não foi possível guardar as definições de retenção.", + "retentionInvalidFields": "Corrija os campos de retenção destacados antes de guardar.", + "retentionInvalidField": "Introduza um valor dentro do intervalo permitido.", "retentionPolicyTitle": "Política de retenção", - "retentionPolicyDescription": "Uma limpeza agendada, separada do ciclo do Office de 5 segundos, elimina o histórico de execuções concluído assim que ultrapassa a sua janela de retenção. As linhas que representam trabalho em curso nunca são eliminadas, independentemente destas definições.", - "retentionEnabledLabel": "Eliminar histórico de execuções elegível", - "retentionEnabledDescription": "Quando desativado, o Kandev nunca elimina linhas de office_routine_runs ou runs. As contagens de linhas retidas abaixo continuam a ser atualizadas para poder ver a acumulação de atrasos antes de reativar a eliminação.", + "retentionPolicyDescription": "Remova automaticamente o histórico concluído antigo e os detalhes relacionados das execuções. O trabalho ativo permanece protegido.", + "retentionEnabledLabel": "Limpeza automática", + "retentionEnabledDescription": "Quando está ativa, o Kandev remove o histórico concluído antigo e os detalhes relacionados. O trabalho ativo permanece protegido.", + "retentionActiveWorkProtection": "A limpeza remove o histórico concluído antigo e os detalhes relacionados das execuções. O trabalho ativo permanece protegido.", + "retentionAdvancedSettingsTitle": "Definições avançadas", + "retentionAdvancedSettingsDescription": "Ajuste o intervalo de limpeza, o tamanho dos lotes e os limiares de aviso.", "retentionSweepIntervalLabel": "Intervalo de limpeza (horas)", "retentionSweepIntervalHelp": "Frequência com que a limpeza é executada, entre 1 e 168 horas (1 semana).", "retentionBatchLimitLabel": "Linhas eliminadas por limpeza", "retentionBatchLimitHelp": "Número máximo de linhas eliminadas por tabela numa limpeza, entre 100 e 100 000. Reduza este valor se a limpeza competir com outra carga na base de dados; um atraso grande é eliminado ao longo de várias limpezas em vez de uma só.", - "retentionRoutineRunsSectionTitle": "office_routine_runs", - "retentionRoutineRunsSectionDescription": "Registos de execução de rotinas: uma linha por cada execução de rotina concluída ou falhada.", - "retentionRunsSectionTitle": "runs", - "retentionRunsSectionDescription": "Registos de execução de tarefas: uma linha por cada execução concluída, falhada ou cancelada.", + "retentionRoutineRunsSectionTitle": "Histórico de rotinas", + "retentionRoutineRunsSectionDescription": "Atividade de rotinas, incluindo execuções ignoradas e agrupadas.", + "retentionRunsSectionTitle": "Histórico de execuções de agentes", + "retentionRunsSectionDescription": "Execuções de agentes do Office e os seus estados concluídos.", + "retentionRoutineHistoryLabel": "Histórico de rotinas", + "retentionRoutineHistoryDescription": "Atividade de rotinas, incluindo execuções ignoradas e agrupadas.", + "retentionAgentRunHistoryLabel": "Histórico de execuções de agentes", + "retentionAgentRunHistoryDescription": "Execuções de agentes do Office e os seus estados concluídos.", + "retentionRunEventsLabel": "Eventos de execução", + "retentionProviderAttemptsLabel": "Tentativas de fornecedor", + "retentionRunSkillRecordsLabel": "Registos de competências da execução", "retentionRunEventsSectionTitle": "run_events e tabelas satélite", "retentionRunEventsSectionDescription": "As linhas de run_events, office_run_route_attempts e office_run_skills são eliminadas em conjunto com a execução que as possui; não têm janela nem limite mínimo próprios.", "retentionWindowDaysLabel": "Janela de retenção (dias)", "retentionWindowDaysHelp": "As linhas concluídas com mais deste número de dias tornam-se elegíveis para eliminação, entre 1 e 3650 dias.", "retentionFloorPerOwnerLabel": "Mínimo mantido por proprietário", + "retentionRoutineFloorLabel": "Mínimo por rotina", + "retentionAgentFloorLabel": "Mínimo por perfil de agente", "retentionFloorPerOwnerHelp": "Mantém sempre pelo menos este número de linhas concluídas mais recentes de cada proprietário, mesmo além da janela de retenção, entre 0 e 10 000. Defina 0 para desativar este limite mínimo.", "retentionWarnRowsLabel": "Avisar acima deste número de linhas retidas", "retentionWarnRowsHelp": "Gera um aviso de saúde quando as linhas retidas ultrapassam este número. Defina 0 para desativar o aviso.", "retentionRunEventsWarnRowsHelp": "Gera um aviso de saúde quando o total de linhas de run_events retidas ultrapassa este número. Defina 0 para desativar o aviso.", + "retentionWarningThresholdsTitle": "Limiares de aviso", + "retentionWarningThresholdsDescription": "Os avisos aplicam-se às linhas armazenadas. O valor 0 desativa o aviso.", "retentionStatusTitle": "Estado da retenção", "retentionStatusDescription": "O resultado da limpeza mais recente e as contagens atuais de linhas retidas para cada tabela com limiar definido.", "retentionNeverSweptMessage": "Ainda não foi executada nenhuma limpeza desde o último arranque do backend.", + "retentionAutomaticCleanupLabel": "Limpeza automática", + "retentionEnabledValue": "Ativa", + "retentionDisabledValue": "Desativada", + "retentionLastCleanupLabel": "Última limpeza", + "retentionOutcomeCompleted": "Concluída", + "retentionOutcomePreview": "Apenas pré-visualização", + "retentionOutcomeMixed": "Resultado misto", + "retentionOutcomeError": "Concluída com erros", + "retentionDeletionTotal_one": "{{count}} registo eliminado", + "retentionDeletionTotal_other": "{{count}} registos eliminados", + "retentionBacklogNotice": "Ainda existe histórico elegível para uma limpeza posterior.", + "retentionStoredCountsDescription": "As contagens armazenadas incluem trabalho protegido e não são uma estimativa de eliminação.", + "retentionUpdatedLabel": "Atualizado", + "retentionCleanupDetailsTitle": "Detalhes da limpeza", + "retentionCensusUnavailable": "Indisponível", + "retentionCensusZero": "0 registos", + "retentionRecords_one": "{{count}} registo", + "retentionRecords_other": "{{count}} registos", + "retentionAboveWarningThreshold": "Acima do limiar de aviso", "retentionSweepStartedAtLabel": "Iniciada", "retentionSweepFinishedAtLabel": "Concluída", "retentionDeletedLabel": "Eliminadas", @@ -385,7 +420,9 @@ "storageDeleteDialogTitle": "Eliminar permanentemente os dados em quarentena", "storageDeletePermanently": "Eliminar permanentemente", "storageDeletionComplete": "Eliminação concluída", - "storageDescription": "Reveja a utilização do disco e recupere espaço das áreas de trabalho, caches e recursos Docker do Kandev sempre que a sua instalação precisar.", + "storageDescription": "Gira o uso do disco do anfitrião e a retenção do histórico do Office, mantendo o trabalho ativo protegido.", + "storageHostTab": "Anfitrião", + "officeRetentionTab": "Retenção do Office", "storageDockerBuildCache": "Cache de compilação do Docker", "storageDockerBuildCacheDescription": "Remove a cache de compilação antiga, retendo a quantidade configurada.", "storageDockerBuildCacheHelp": "Usa os filtros de idade e de armazenamento do Docker para remover cache de compilação antiga. Não executa o docker system prune, não remove volumes globalmente e mantém-se desativado até o reconhecimento de daemon dedicado ser confirmado.", @@ -491,9 +528,9 @@ "storageTemporaryArtifacts": "Artefactos temporários do Kandev", "storageTemporaryArtifactsActiveCount_one": "{{count}} ativo", "storageTemporaryArtifactsActiveCount_other": "{{count}} ativos", - "storageTemporaryArtifactsDialogAction": "Mover para a quarentena", + "storageTemporaryArtifactsDialogAction": "Mover ficheiros elegíveis para a quarentena", "storageTemporaryArtifactsDialogDescription": "Os artefactos registados elegíveis serão movidos para a quarentena e poderão ser restaurados durante o período de retenção. Os artefactos ativos ou recentes ficam protegidos.", - "storageTemporaryArtifactsDialogTitle": "Limpar artefactos antigos do Kandev?", + "storageTemporaryArtifactsDialogTitle": "Limpar ficheiros temporários inativos do Kandev?", "storageTemporaryArtifactsNoStale": "Não existem artefactos temporários registados com mais de 24 horas.", "storageTemporaryArtifactsProtectedCount_one": "{{count}} protegido", "storageTemporaryArtifactsProtectedCount_other": "{{count}} protegidos", @@ -501,11 +538,11 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} antigos elegíveis", "storageTemporaryArtifactsStaleBytes": "{{value}} elegíveis", "storageTemporaryArtifactsUnavailable": "Não foi possível medir a propriedade dos artefactos temporários", - "storageTemporaryArtifactsPolicyTitle": "Artefactos temporários", - "storageTemporaryArtifactsPolicyDescription": "Limpe opcionalmente artefactos Kandev registados durante a manutenção agendada.", - "storageCleanStaleTemporaryArtifacts": "Limpar artefactos temporários antigos do Kandev", - "storageTemporaryArtifactsPolicySummary": "Apenas artefactos registados e inativos. Idade mínima: 24 horas.", - "storageTemporaryArtifactsPolicyHelp": "Apenas os artefactos registados, inativos e com pelo menos 24 horas são elegíveis. Os ficheiros são movidos para a quarentena antes da eliminação permanente. Os ficheiros temporários partilhados e antigos permanecem intactos.", + "storageTemporaryArtifactsPolicyTitle": "Ficheiros temporários do Kandev", + "storageTemporaryArtifactsPolicyDescription": "Pacotes de diagnóstico e pastas de trabalho de utilitários que o Kandev regista para uso temporário.", + "storageCleanStaleTemporaryArtifacts": "Limpar ficheiros temporários inativos do Kandev", + "storageTemporaryArtifactsPolicySummary": "Pacotes de diagnóstico e pastas de trabalho registados. Inativos há pelo menos 24 horas.", + "storageTemporaryArtifactsPolicyHelp": "Apenas os ficheiros registados, inativos e com pelo menos 24 horas são elegíveis. Os ficheiros são movidos para a quarentena antes da eliminação permanente. As pastas temporárias partilhadas e as caches de pacotes não relacionadas ficam excluídas.", "storageTemporaryArtifactsPolicyAge": "Apenas artefactos registados e inativos. Idade mínima: 24 horas.", "storageTemporaryArtifactsPolicyQuarantine": "Os ficheiros são movidos para a quarentena antes da eliminação permanente.", "storageTemporaryArtifactsPolicyShared": "Os ficheiros temporários partilhados são excluídos.", diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index e4846f0d18..08a1926f65 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -207,37 +207,72 @@ "navFeatureToggles": "功能开关", "navLicenses": "许可证", "navLogs": "日志", - "navRetention": "运行历史保留", + "navRetention": "Office 保留", "navUpdates": "更新", "navUsers": "用户", "retentionPageDescription": "限制 Office 运行历史(office_routine_runs、runs 及其附属表)在数据库中保留的时长。", "retentionAdminOnly": "只有管理员才能更改保留设置。", "retentionLoadFailed": "无法加载保留设置。", "retentionSaveFailed": "无法保存保留设置。", + "retentionInvalidFields": "保存前请修正突出显示的保留字段。", + "retentionInvalidField": "请输入允许范围内的值。", "retentionPolicyTitle": "保留策略", - "retentionPolicyDescription": "一个与 5 秒 Office 心跳独立的定时清理任务,会在已完成的运行历史超出其保留窗口后将其删除。代表当前进行中工作的行永远不会被删除,与这些设置无关。", - "retentionEnabledLabel": "删除符合条件的运行历史", - "retentionEnabledDescription": "关闭时,Kandev 永远不会删除 office_routine_runs 或 runs 中的行。下方的保留行数仍会持续更新,方便你在重新启用删除前查看积压情况。", + "retentionPolicyDescription": "自动移除旧的已完成历史记录和相关运行详情。活动中的工作会受到保护。", + "retentionEnabledLabel": "自动清理", + "retentionEnabledDescription": "启用后,Kandev 会移除旧的已完成历史记录和相关详情。活动中的工作会受到保护。", + "retentionActiveWorkProtection": "清理会移除旧的已完成历史记录和相关运行详情。活动中的工作会受到保护。", + "retentionAdvancedSettingsTitle": "高级设置", + "retentionAdvancedSettingsDescription": "调整清理时间、批量大小和警告阈值。", "retentionSweepIntervalLabel": "清理间隔(小时)", "retentionSweepIntervalHelp": "清理任务运行的频率,介于 1 到 168 小时(1 周)之间。", "retentionBatchLimitLabel": "每次清理删除的行数", "retentionBatchLimitHelp": "每次清理中每张表最多删除的行数,介于 100 到 100000 之间。如果清理与其他数据库负载相互争用,可以调低此值;较大的积压会分多次清理逐步处理,而不是一次完成。", - "retentionRoutineRunsSectionTitle": "office_routine_runs", - "retentionRoutineRunsSectionDescription": "例行任务执行记录:每完成或失败一次例行任务运行对应一行。", - "retentionRunsSectionTitle": "runs", - "retentionRunsSectionDescription": "任务运行记录:每完成、失败或取消一次运行对应一行。", + "retentionRoutineRunsSectionTitle": "例行任务历史", + "retentionRoutineRunsSectionDescription": "例行任务活动,包括跳过和合并的执行。", + "retentionRunsSectionTitle": "智能体运行历史", + "retentionRunsSectionDescription": "Office 智能体运行及其已完成状态。", + "retentionRoutineHistoryLabel": "例行任务历史", + "retentionRoutineHistoryDescription": "例行任务活动,包括跳过和合并的执行。", + "retentionAgentRunHistoryLabel": "智能体运行历史", + "retentionAgentRunHistoryDescription": "Office 智能体运行及其已完成状态。", + "retentionRunEventsLabel": "运行事件", + "retentionProviderAttemptsLabel": "提供程序尝试", + "retentionRunSkillRecordsLabel": "运行技能记录", "retentionRunEventsSectionTitle": "run_events 及附属表", "retentionRunEventsSectionDescription": "run_events、office_run_route_attempts 和 office_run_skills 中的行会随其所属的运行一起删除;它们没有自己的窗口或下限设置。", "retentionWindowDaysLabel": "保留窗口(天)", "retentionWindowDaysHelp": "已完成且超过此天数的行将符合删除条件,介于 1 到 3650 天之间。", "retentionFloorPerOwnerLabel": "每个所有者的最少保留数", + "retentionRoutineFloorLabel": "每个例行任务的最少保留数", + "retentionAgentFloorLabel": "每个智能体配置的最少保留数", "retentionFloorPerOwnerHelp": "始终至少保留每个所有者最近完成的这么多行,即使超出保留窗口,介于 0 到 10000 之间。设为 0 可停用此下限。", "retentionWarnRowsLabel": "超过此保留行数时发出警告", "retentionWarnRowsHelp": "当保留行数超过此数值时触发健康警告。设为 0 可停用该警告。", "retentionRunEventsWarnRowsHelp": "当保留的 run_events 总行数超过此数值时触发健康警告。设为 0 可停用该警告。", + "retentionWarningThresholdsTitle": "警告阈值", + "retentionWarningThresholdsDescription": "警告适用于存储的行数。值为 0 时停用该警告。", "retentionStatusTitle": "保留状态", "retentionStatusDescription": "最近一次清理的结果,以及每张设有阈值的表当前的保留行数。", "retentionNeverSweptMessage": "自后端上次启动以来,尚未运行过清理任务。", + "retentionAutomaticCleanupLabel": "自动清理", + "retentionEnabledValue": "开启", + "retentionDisabledValue": "关闭", + "retentionLastCleanupLabel": "上次清理", + "retentionOutcomeCompleted": "已完成", + "retentionOutcomePreview": "仅预览", + "retentionOutcomeMixed": "混合结果", + "retentionOutcomeError": "完成但有错误", + "retentionDeletionTotal_one": "已删除 {{count}} 条记录", + "retentionDeletionTotal_other": "已删除 {{count}} 条记录", + "retentionBacklogNotice": "仍有符合条件的历史记录将在后续清理中处理。", + "retentionStoredCountsDescription": "存储计数包括受保护的工作,不能作为删除估算。", + "retentionUpdatedLabel": "更新时间", + "retentionCleanupDetailsTitle": "清理详情", + "retentionCensusUnavailable": "不可用", + "retentionCensusZero": "0 条记录", + "retentionRecords_one": "{{count}} 条记录", + "retentionRecords_other": "{{count}} 条记录", + "retentionAboveWarningThreshold": "超过警告阈值", "retentionSweepStartedAtLabel": "开始于", "retentionSweepFinishedAtLabel": "结束于", "retentionDeletedLabel": "已删除", @@ -387,7 +422,9 @@ "storageDeleteDialogTitle": "永久删除隔离数据", "storageDeletePermanently": "永久删除", "storageDeletionComplete": "删除完成", - "storageDescription": "查看磁盘用量,并在安装需要时从 Kandev 拥有的工作区、缓存与 Docker 资源回收空间。", + "storageDescription": "管理主机磁盘用量和 Office 历史保留,同时保护活动中的工作。", + "storageHostTab": "主机", + "officeRetentionTab": "Office 保留", "storageDockerBuildCache": "Docker 构建缓存", "storageDockerBuildCacheDescription": "移除旧构建缓存,同时保留配置的量。", "storageDockerBuildCacheHelp": "使用 Docker 的年龄与存储过滤器移除旧构建缓存。不会运行 docker system prune,不会全局移除卷,且在确认专用守护进程前保持禁用。", @@ -493,9 +530,9 @@ "storageTemporaryArtifacts": "Kandev 临时文件", "storageTemporaryArtifactsActiveCount_one": "{{count}} 个活动中", "storageTemporaryArtifactsActiveCount_other": "{{count}} 个活动中", - "storageTemporaryArtifactsDialogAction": "移入隔离区", + "storageTemporaryArtifactsDialogAction": "将符合条件的文件移入隔离区", "storageTemporaryArtifactsDialogDescription": "符合条件的已登记临时文件会先移入隔离区,并可在保留期内恢复。活动中或最近创建的文件会受到保护。", - "storageTemporaryArtifactsDialogTitle": "清理过期的 Kandev 临时文件?", + "storageTemporaryArtifactsDialogTitle": "清理不活跃的 Kandev 临时文件?", "storageTemporaryArtifactsNoStale": "没有超过 24 小时的已登记临时文件。", "storageTemporaryArtifactsProtectedCount_one": "{{count}} 个受保护", "storageTemporaryArtifactsProtectedCount_other": "{{count}} 个受保护", @@ -503,11 +540,11 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} 个过期可清理", "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", "storageTemporaryArtifactsUnavailable": "无法测量临时文件的所有权", - "storageTemporaryArtifactsPolicyTitle": "临时文件", - "storageTemporaryArtifactsPolicyDescription": "可选择在计划维护期间清理已注册的 Kandev 文件。", - "storageCleanStaleTemporaryArtifacts": "清理过期的 Kandev 临时文件", - "storageTemporaryArtifactsPolicySummary": "仅限已注册的非活动文件。最短期限:24 小时。", - "storageTemporaryArtifactsPolicyHelp": "只有已注册、处于非活动状态且至少存在 24 小时的文件才符合条件。文件会先移入隔离区,然后才会永久删除。共享和旧版临时文件保持不变。", + "storageTemporaryArtifactsPolicyTitle": "Kandev 临时文件", + "storageTemporaryArtifactsPolicyDescription": "Kandev 为临时使用而登记的诊断包和工具工作文件夹。", + "storageCleanStaleTemporaryArtifacts": "清理不活动的 Kandev 临时文件", + "storageTemporaryArtifactsPolicySummary": "已登记的诊断包和工具工作文件夹。至少非活动 24 小时。", + "storageTemporaryArtifactsPolicyHelp": "只有已登记、处于非活动状态且至少存在 24 小时的文件才符合条件。文件会先移入隔离区,然后才会永久删除。共享临时文件夹和无关的软件包缓存会被排除。", "storageTemporaryArtifactsPolicyAge": "仅限已注册的非活动文件。最短期限:24 小时。", "storageTemporaryArtifactsPolicyQuarantine": "文件会先移入隔离区,然后才会永久删除。", "storageTemporaryArtifactsPolicyShared": "共享临时文件会被排除。", diff --git a/apps/web/src/locales/zh-hk/canvases.json b/apps/web/src/locales/zh-hk/canvases.json index cb09179433..37467a58a6 100644 --- a/apps/web/src/locales/zh-hk/canvases.json +++ b/apps/web/src/locales/zh-hk/canvases.json @@ -104,7 +104,7 @@ "sourceActorSystem": "Kandev", "sourceActorUnknown": "未知作者", "archivedCanvasActionHelp": "此畫布已歸檔。請先在工作區設定中恢復它,然後再進行更改。", - "disabledCanvasActionHelp": "此畫布已停用。請先啟用它,然後再進行更改。", + "disabledCanvasActionHelp": "此畫布已停用。請先啓用它,然後再進行更改。", "shareCanvas": "分享畫布", "shareCanvasDescription": "準備經過驗證的包或原始碼歸檔,以便審核和分享。", "prepareDownloads": "準備下載", diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 677f22a298..c5835db379 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -207,37 +207,72 @@ "navFeatureToggles": "功能開關", "navLicenses": "許可證", "navLogs": "日誌", - "navRetention": "執行歷史保留", + "navRetention": "Office 保留", "navUpdates": "更新", "navUsers": "用戶", "retentionPageDescription": "限制 Office 執行歷史(office_routine_runs、runs 及其附屬表)在數據庫中保留的時長。", "retentionAdminOnly": "只有管理員才能更改保留設定。", "retentionLoadFailed": "無法載入保留設定。", "retentionSaveFailed": "無法儲存保留設定。", + "retentionInvalidFields": "儲存前請修正突出顯示的保留欄位。", + "retentionInvalidField": "請輸入允許範圍內的值。", "retentionPolicyTitle": "保留策略", - "retentionPolicyDescription": "一個與 5 秒 Office 心跳獨立的定時清理任務,會在已完成的執行歷史超出其保留窗口後將其刪除。代表目前進行中工作的行永遠不會被刪除,與這些設定無關。", - "retentionEnabledLabel": "刪除符合條件的執行歷史", - "retentionEnabledDescription": "關閉時,Kandev 永遠不會刪除 office_routine_runs 或 runs 中的行。下方的保留行數仍會持續更新,方便你在重新啓用刪除前查看積壓情況。", + "retentionPolicyDescription": "自動移除舊的已完成歷史記錄和相關執行詳情。活動中的工作會受到保護。", + "retentionEnabledLabel": "自動清理", + "retentionEnabledDescription": "啓用後,Kandev 會移除舊的已完成歷史記錄和相關詳情。活動中的工作會受到保護。", + "retentionActiveWorkProtection": "清理會移除舊的已完成歷史記錄和相關執行詳情。活動中的工作會受到保護。", + "retentionAdvancedSettingsTitle": "高級設定", + "retentionAdvancedSettingsDescription": "調整清理時間、批量大小和警告閾值。", "retentionSweepIntervalLabel": "清理間隔(小時)", "retentionSweepIntervalHelp": "清理任務執行的頻率,介於 1 到 168 小時(1 周)之間。", "retentionBatchLimitLabel": "每次清理刪除的行數", "retentionBatchLimitHelp": "每次清理中每張表最多刪除的行數,介於 100 到 100000 之間。如果清理與其他數據庫負載相互爭用,可以調低此值;較大的積壓會分多次清理逐步處理,而不是一次完成。", - "retentionRoutineRunsSectionTitle": "office_routine_runs", - "retentionRoutineRunsSectionDescription": "例行任務執行記錄:每完成或失敗一次例行任務執行對應一行。", - "retentionRunsSectionTitle": "runs", - "retentionRunsSectionDescription": "任務執行記錄:每完成、失敗或取消一次執行對應一行。", + "retentionRoutineRunsSectionTitle": "例行任務歷史", + "retentionRoutineRunsSectionDescription": "例行任務活動,包括跳過和合並的執行。", + "retentionRunsSectionTitle": "代理程式執行歷史", + "retentionRunsSectionDescription": "Office 代理程式執行及其已完成狀態。", + "retentionRoutineHistoryLabel": "例行任務歷史", + "retentionRoutineHistoryDescription": "例行任務活動,包括跳過和合並的執行。", + "retentionAgentRunHistoryLabel": "代理程式執行歷史", + "retentionAgentRunHistoryDescription": "Office 代理程式執行及其已完成狀態。", + "retentionRunEventsLabel": "執行事件", + "retentionProviderAttemptsLabel": "提供程序嘗試", + "retentionRunSkillRecordsLabel": "執行技能記錄", "retentionRunEventsSectionTitle": "run_events 及附屬表", "retentionRunEventsSectionDescription": "run_events、office_run_route_attempts 和 office_run_skills 中的行會隨其所屬的執行一起刪除;它們沒有自己的窗口或下限設定。", "retentionWindowDaysLabel": "保留窗口(天)", "retentionWindowDaysHelp": "已完成且超過此天數的行將符合刪除條件,介於 1 到 3650 天之間。", "retentionFloorPerOwnerLabel": "每個所有者的最少保留數", + "retentionRoutineFloorLabel": "每個例行任務的最少保留數", + "retentionAgentFloorLabel": "每個代理程式設定檔的最少保留數", "retentionFloorPerOwnerHelp": "始終至少保留每個所有者最近完成的這麼多行,即使超出保留窗口,介於 0 到 10000 之間。設為 0 可停用此下限。", "retentionWarnRowsLabel": "超過此保留行數時發出警告", "retentionWarnRowsHelp": "當保留行數超過此數值時觸發健康警告。設為 0 可停用該警告。", "retentionRunEventsWarnRowsHelp": "當保留的 run_events 總行數超過此數值時觸發健康警告。設為 0 可停用該警告。", + "retentionWarningThresholdsTitle": "警告閾值", + "retentionWarningThresholdsDescription": "警告適用於存儲的行數。值為 0 時停用該警告。", "retentionStatusTitle": "保留狀態", "retentionStatusDescription": "最近一次清理的結果,以及每張設有閾值的表目前的保留行數。", "retentionNeverSweptMessage": "自後端上次啓動以來,尚未執行過清理任務。", + "retentionAutomaticCleanupLabel": "自動清理", + "retentionEnabledValue": "開啓", + "retentionDisabledValue": "關閉", + "retentionLastCleanupLabel": "上次清理", + "retentionOutcomeCompleted": "已完成", + "retentionOutcomePreview": "僅預覽", + "retentionOutcomeMixed": "混合結果", + "retentionOutcomeError": "完成但有錯誤", + "retentionDeletionTotal_one": "已刪除 {{count}} 條記錄", + "retentionDeletionTotal_other": "已刪除 {{count}} 條記錄", + "retentionBacklogNotice": "仍有符合條件的歷史記錄將在後續清理中處理。", + "retentionStoredCountsDescription": "存儲計數包括受保護的工作,不能作為刪除估算。", + "retentionUpdatedLabel": "更新時間", + "retentionCleanupDetailsTitle": "清理詳情", + "retentionCensusUnavailable": "不可用", + "retentionCensusZero": "0 條記錄", + "retentionRecords_one": "{{count}} 條記錄", + "retentionRecords_other": "{{count}} 條記錄", + "retentionAboveWarningThreshold": "超過警告閾值", "retentionSweepStartedAtLabel": "開始於", "retentionSweepFinishedAtLabel": "結束於", "retentionDeletedLabel": "已刪除", @@ -387,7 +422,9 @@ "storageDeleteDialogTitle": "永久刪除隔離數據", "storageDeletePermanently": "永久刪除", "storageDeletionComplete": "刪除完成", - "storageDescription": "查看磁碟用量,並在安裝需要時從 Kandev 擁有的工作區、快取與 Docker 資源回收空間。", + "storageDescription": "管理主機磁碟用量和 Office 歷史保留,同時保護活動中的工作。", + "storageHostTab": "主機", + "officeRetentionTab": "Office 保留", "storageDockerBuildCache": "Docker 構建快取", "storageDockerBuildCacheDescription": "移除舊構建快取,同時保留設定的量。", "storageDockerBuildCacheHelp": "使用 Docker 的年齡與存儲過濾器移除舊構建快取。不會執行 docker system prune,不會全局移除卷,且在確認專用守護程序前保持停用。", @@ -493,9 +530,9 @@ "storageTemporaryArtifacts": "Kandev 臨時檔案", "storageTemporaryArtifactsActiveCount_one": "{{count}} 個活動中", "storageTemporaryArtifactsActiveCount_other": "{{count}} 個活動中", - "storageTemporaryArtifactsDialogAction": "移入隔離區", + "storageTemporaryArtifactsDialogAction": "將符合條件的檔案移入隔離區", "storageTemporaryArtifactsDialogDescription": "符合條件的已登記臨時檔案會先移入隔離區,並可在保留期內恢復。活動中或最近建立的檔案會受到保護。", - "storageTemporaryArtifactsDialogTitle": "清理過期的 Kandev 臨時檔案?", + "storageTemporaryArtifactsDialogTitle": "清理不活躍的 Kandev 臨時檔案?", "storageTemporaryArtifactsNoStale": "沒有超過 24 小時的已登記臨時檔案。", "storageTemporaryArtifactsProtectedCount_one": "{{count}} 個受保護", "storageTemporaryArtifactsProtectedCount_other": "{{count}} 個受保護", @@ -503,11 +540,11 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} 個過期可清理", "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", "storageTemporaryArtifactsUnavailable": "無法測量臨時檔案的所有權", - "storageTemporaryArtifactsPolicyTitle": "臨時檔案", - "storageTemporaryArtifactsPolicyDescription": "可選擇在計劃維護期間清理已註冊的 Kandev 檔案。", - "storageCleanStaleTemporaryArtifacts": "清理過期的 Kandev 臨時檔案", - "storageTemporaryArtifactsPolicySummary": "僅限已註冊的非活動檔案。最短期限:24 小時。", - "storageTemporaryArtifactsPolicyHelp": "只有已註冊、處於非活動狀態且至少存在 24 小時的檔案才符合條件。檔案會先移入隔離區,然後才會永久刪除。共享和舊版臨時檔案保持不變。", + "storageTemporaryArtifactsPolicyTitle": "Kandev 臨時檔案", + "storageTemporaryArtifactsPolicyDescription": "Kandev 為臨時使用而登記的診斷包和工具工作資料夾。", + "storageCleanStaleTemporaryArtifacts": "清理不活動的 Kandev 臨時檔案", + "storageTemporaryArtifactsPolicySummary": "已登記的診斷包和工具工作資料夾。至少非活動 24 小時。", + "storageTemporaryArtifactsPolicyHelp": "只有已登記、處於非活動狀態且至少存在 24 小時的檔案才符合條件。檔案會先移入隔離區,然後才會永久刪除。共享臨時資料夾和無關的軟件包快取會被排除。", "storageTemporaryArtifactsPolicyAge": "僅限已註冊的非活動檔案。最短期限:24 小時。", "storageTemporaryArtifactsPolicyQuarantine": "檔案會先移入隔離區,然後才會永久刪除。", "storageTemporaryArtifactsPolicyShared": "共享臨時檔案會被排除。", @@ -627,62 +664,62 @@ "toolPayload.title": "訊息精簡", "toolPayload.description": "移除舊的工具輸入和輸出,同時保留訊息元數據。", "toolPayload.enabled": "自動精簡", - "toolPayload.age": "任务未活动时长", - "toolPayload.unit": "保留期限单位", + "toolPayload.age": "任務未活動時長", + "toolPayload.unit": "保留期限單位", "toolPayload.weeks": "周", "toolPayload.months": "月", "toolPayload.ageHelp": "不處理活躍任務、排隊工作和等待輸入的任務。", - "toolPayload.invalidAge": "请输入 1–520 个整周或 1–120 个整月。", + "toolPayload.invalidAge": "請輸入 1–520 個整週或 1–120 個整月。", "toolPayload.beforeCleanup": "首次精簡前", - "toolPayload.backupHelp": "选择创建备份或明确选择不备份。所选备份必须成功后才能开始清理。备份占用额外空间。恢复备份会回滚整个数据库,包括较新的更改。", - "toolPayload.backup": "创建备份(推荐)", - "toolPayload.skip": "不备份并继续", - "toolPayload.chooseBackup": "保存前请选择是否创建备份。", - "toolPayload.analyze": "分析可节省空间", + "toolPayload.backupHelp": "選擇建立備份或明確選擇不備份。所選備份必須成功後才能開始清理。備份佔用額外空間。恢復備份會回滾整個數據庫,包括較新的更改。", + "toolPayload.backup": "建立備份(推薦)", + "toolPayload.skip": "不備份並繼續", + "toolPayload.chooseBackup": "儲存前請選擇是否建立備份。", + "toolPayload.analyze": "分析可節省空間", "toolPayload.notAnalyzed": "尚未分析。無需啓用精簡即可預覽預計節省的空間。", - "toolPayload.stale": "此估算已过期或使用了不同期限。请重新分析。", + "toolPayload.stale": "此估算已過期或使用了不同期限。請重新分析。", "toolPayload.empty": "此時間段內未找到可移除的工具詳情。", "toolPayload.estimate": "預計減少的工具詳情大小:{{size}}", - "toolPayload.progress": "已扫描 {{scanned}} 条消息。符合条件:{{tasks}} 个任务,{{messages}} 条消息。", - "toolPayload.removed_one": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", - "toolPayload.removed_other": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", - "toolPayload.skipped_one": "已跳过 {{count}} 项。", - "toolPayload.skipped_other": "已跳过 {{count}} 项。", - "toolPayload.window": "扫描时间:{{start}} 至 {{end}}。活动截止时间:{{cutoff}}。", - "toolPayload.compactionHelp": "估算衡量详细数据字节数,并非数据库文件大小。清理释放空间供重用。使用“数据库”部分的“压缩数据库”缩小文件。现有备份大小不变。", + "toolPayload.progress": "已掃描 {{scanned}} 條訊息。符合條件:{{tasks}} 個任務,{{messages}} 條訊息。", + "toolPayload.removed_one": "已刪除 {{count}} 條訊息的詳細數據。數據減少:{{size}}。", + "toolPayload.removed_other": "已刪除 {{count}} 條訊息的詳細數據。數據減少:{{size}}。", + "toolPayload.skipped_one": "已跳過 {{count}} 項。", + "toolPayload.skipped_other": "已跳過 {{count}} 項。", + "toolPayload.window": "掃描時間:{{start}} 至 {{end}}。活動截止時間:{{cutoff}}。", + "toolPayload.compactionHelp": "估算衡量詳細數據字節數,並非數據庫檔案大小。清理釋放空間供重用。使用「數據庫」部分的「壓縮數據庫」縮小檔案。現有備份大小不變。", "toolPayload.lastCleanup": "上次執行", - "toolPayload.never": "从未", - "toolPayload.nextCheck": "下次检查", - "toolPayload.disabled": "已禁用", - "toolPayload.pending": "正在等待操作开始。", - "toolPayload.preparing": "正在準備清理。清理尚未啟用。", - "toolPayload.preparationFailed": "准备失败。清理仍被阻止。请重试或取消准备。", - "toolPayload.retryBackup": "重试准备", - "toolPayload.cancelPreparation": "取消准备并禁用清理", + "toolPayload.never": "從未", + "toolPayload.nextCheck": "下次檢查", + "toolPayload.disabled": "已停用", + "toolPayload.pending": "正在等待操作開始。", + "toolPayload.preparing": "正在準備清理。清理尚未啓用。", + "toolPayload.preparationFailed": "準備失敗。清理仍被阻止。請重試或取消準備。", + "toolPayload.retryBackup": "重試準備", + "toolPayload.cancelPreparation": "取消準備並停用清理", "toolPayload.run": "立即精簡訊息", "toolPayload.cancel": "取消操作", - "toolPayload.refresh": "刷新状态", - "toolPayload.conflict": "策略已更改或其他操作正在运行。请刷新状态并放弃草稿,使用最新策略后重试。", - "toolPayload.adminOnly": "只有管理员可以更改此策略或运行分析与清理。", - "toolPayload.unsupported": "此数据库引擎不支持工具详细数据清理。", - "toolPayload.operationFailed": "操作未能完成。请刷新状态后重试。", + "toolPayload.refresh": "刷新狀態", + "toolPayload.conflict": "策略已更改或其他操作正在執行。請刷新狀態並放棄草稿,使用最新策略後重試。", + "toolPayload.adminOnly": "只有管理員可以更改此策略或執行分析與清理。", + "toolPayload.unsupported": "此數據庫引擎不支援工具詳細數據清理。", + "toolPayload.operationFailed": "操作未能完成。請刷新狀態後重試。", "toolPayload.analysis": "分析", "toolPayload.cleanup": "精簡", - "toolPayload.running": "正在运行", + "toolPayload.running": "正在執行", "toolPayload.succeeded": "已完成", - "toolPayload.failed": "失败", + "toolPayload.failed": "失敗", "toolPayload.partial": "部分完成", "toolPayload.cancelled": "已取消", - "toolPayload.partialHelp": "此结果不完整。所报告的节省空间仅包含已处理项。", + "toolPayload.partialHelp": "此結果不完整。所報告的節省空間僅包含已處理項。", "toolPayload.skipReason.protected_tasks": "受保護的任務", - "toolPayload.skipReason.oversize": "詳細資料超過清理限制", - "toolPayload.skipReason.already_removed": "詳細資料已刪除", - "toolPayload.skipReason.no_payload": "無可回收的詳細資料", - "toolPayload.skipReason.unsupported": "不支援或受保護的工具詳細資料", - "toolPayload.skipReason.malformed": "無法識別的工具詳細資料", + "toolPayload.skipReason.oversize": "詳細數據超過清理限制", + "toolPayload.skipReason.already_removed": "詳細數據已刪除", + "toolPayload.skipReason.no_payload": "無可回收的詳細數據", + "toolPayload.skipReason.unsupported": "不支援或受保護的工具詳細數據", + "toolPayload.skipReason.malformed": "無法識別的工具詳細數據", "toolPayload.skipReason.invalid_message": "無效訊息", - "toolPayload.skipReason.other": "其他已跳過項目", - "toolPayload.skipReason.eligibility_budget": "資格檢查逾時;任務已保留", + "toolPayload.skipReason.other": "其他已跳過項", + "toolPayload.skipReason.eligibility_budget": "資格檢查超時;任務已保留", "toolPayload.schedule": "Kandev 執行期間每 24 小時檢查一次。", "toolPayload.firstRun": "首次執行將在備份準備完成後開始。", "toolPayload.analysisDetails": "分析詳情", diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index 5f690161b5..8887eca645 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -207,37 +207,72 @@ "navFeatureToggles": "功能開關", "navLicenses": "許可證", "navLogs": "日誌", - "navRetention": "執行歷史保留", + "navRetention": "Office 保留", "navUpdates": "更新", "navUsers": "使用者", "retentionPageDescription": "限制 Office 執行歷史(office_routine_runs、runs 及其附屬表)在資料庫中保留的時長。", "retentionAdminOnly": "只有管理員才能更改保留設定。", "retentionLoadFailed": "無法載入保留設定。", "retentionSaveFailed": "無法儲存保留設定。", + "retentionInvalidFields": "儲存前請修正突出顯示的保留欄位。", + "retentionInvalidField": "請輸入允許範圍內的值。", "retentionPolicyTitle": "保留策略", - "retentionPolicyDescription": "一個與 5 秒 Office 心跳獨立的定時清理任務,會在已完成的執行歷史超出其保留視窗後將其刪除。代表目前進行中工作的行永遠不會被刪除,與這些設定無關。", - "retentionEnabledLabel": "刪除符合條件的執行歷史", - "retentionEnabledDescription": "關閉時,Kandev 永遠不會刪除 office_routine_runs 或 runs 中的行。下方的保留行數仍會持續更新,方便你在重新啟用刪除前檢視積壓情況。", + "retentionPolicyDescription": "自動移除舊的已完成歷史記錄和相關執行詳情。活動中的工作會受到保護。", + "retentionEnabledLabel": "自動清理", + "retentionEnabledDescription": "啟用後,Kandev 會移除舊的已完成歷史記錄和相關詳情。活動中的工作會受到保護。", + "retentionActiveWorkProtection": "清理會移除舊的已完成歷史記錄和相關執行詳情。活動中的工作會受到保護。", + "retentionAdvancedSettingsTitle": "進階設定", + "retentionAdvancedSettingsDescription": "調整清理時間、批次大小和警告閾值。", "retentionSweepIntervalLabel": "清理間隔(小時)", "retentionSweepIntervalHelp": "清理任務執行的頻率,介於 1 到 168 小時(1 周)之間。", "retentionBatchLimitLabel": "每次清理刪除的行數", "retentionBatchLimitHelp": "每次清理中每張表最多刪除的行數,介於 100 到 100000 之間。如果清理與其他資料庫負載相互爭用,可以調低此值;較大的積壓會分多次清理逐步處理,而不是一次完成。", - "retentionRoutineRunsSectionTitle": "office_routine_runs", - "retentionRoutineRunsSectionDescription": "例行任務執行記錄:每完成或失敗一次例行任務執行對應一行。", - "retentionRunsSectionTitle": "runs", - "retentionRunsSectionDescription": "任務執行記錄:每完成、失敗或取消一次執行對應一行。", + "retentionRoutineRunsSectionTitle": "例行任務歷史", + "retentionRoutineRunsSectionDescription": "例行任務活動,包括跳過和合並的執行。", + "retentionRunsSectionTitle": "代理程式執行歷史", + "retentionRunsSectionDescription": "Office 代理程式執行及其已完成狀態。", + "retentionRoutineHistoryLabel": "例行任務歷史", + "retentionRoutineHistoryDescription": "例行任務活動,包括跳過和合並的執行。", + "retentionAgentRunHistoryLabel": "代理程式執行歷史", + "retentionAgentRunHistoryDescription": "Office 代理程式執行及其已完成狀態。", + "retentionRunEventsLabel": "執行事件", + "retentionProviderAttemptsLabel": "提供程式嘗試", + "retentionRunSkillRecordsLabel": "執行技能記錄", "retentionRunEventsSectionTitle": "run_events 及附屬表", "retentionRunEventsSectionDescription": "run_events、office_run_route_attempts 和 office_run_skills 中的行會隨其所屬的執行一起刪除;它們沒有自己的視窗或下限設定。", "retentionWindowDaysLabel": "保留視窗(天)", "retentionWindowDaysHelp": "已完成且超過此天數的行將符合刪除條件,介於 1 到 3650 天之間。", "retentionFloorPerOwnerLabel": "每個所有者的最少保留數", + "retentionRoutineFloorLabel": "每個例行任務的最少保留數", + "retentionAgentFloorLabel": "每個代理程式設定檔的最少保留數", "retentionFloorPerOwnerHelp": "始終至少保留每個所有者最近完成的這麼多行,即使超出保留視窗,介於 0 到 10000 之間。設為 0 可停用此下限。", "retentionWarnRowsLabel": "超過此保留行數時發出警告", "retentionWarnRowsHelp": "當保留行數超過此數值時觸發健康警告。設為 0 可停用該警告。", "retentionRunEventsWarnRowsHelp": "當保留的 run_events 總行數超過此數值時觸發健康警告。設為 0 可停用該警告。", + "retentionWarningThresholdsTitle": "警告閾值", + "retentionWarningThresholdsDescription": "警告適用於儲存的行數。值為 0 時停用該警告。", "retentionStatusTitle": "保留狀態", "retentionStatusDescription": "最近一次清理的結果,以及每張設有閾值的表目前的保留行數。", "retentionNeverSweptMessage": "自後端上次啟動以來,尚未執行過清理任務。", + "retentionAutomaticCleanupLabel": "自動清理", + "retentionEnabledValue": "開啟", + "retentionDisabledValue": "關閉", + "retentionLastCleanupLabel": "上次清理", + "retentionOutcomeCompleted": "已完成", + "retentionOutcomePreview": "僅預覽", + "retentionOutcomeMixed": "混合結果", + "retentionOutcomeError": "完成但有錯誤", + "retentionDeletionTotal_one": "已刪除 {{count}} 條記錄", + "retentionDeletionTotal_other": "已刪除 {{count}} 條記錄", + "retentionBacklogNotice": "仍有符合條件的歷史記錄將在後續清理中處理。", + "retentionStoredCountsDescription": "儲存計數包括受保護的工作,不能作為刪除估算。", + "retentionUpdatedLabel": "更新時間", + "retentionCleanupDetailsTitle": "清理詳情", + "retentionCensusUnavailable": "不可用", + "retentionCensusZero": "0 條記錄", + "retentionRecords_one": "{{count}} 條記錄", + "retentionRecords_other": "{{count}} 條記錄", + "retentionAboveWarningThreshold": "超過警告閾值", "retentionSweepStartedAtLabel": "開始於", "retentionSweepFinishedAtLabel": "結束於", "retentionDeletedLabel": "已刪除", @@ -387,7 +422,9 @@ "storageDeleteDialogTitle": "永久刪除隔離資料", "storageDeletePermanently": "永久刪除", "storageDeletionComplete": "刪除完成", - "storageDescription": "檢視磁碟用量,並在安裝需要時從 Kandev 擁有的工作區、快取與 Docker 資源回收空間。", + "storageDescription": "管理主機磁碟用量和 Office 歷史保留,同時保護活動中的工作。", + "storageHostTab": "主機", + "officeRetentionTab": "Office 保留", "storageDockerBuildCache": "Docker 構建快取", "storageDockerBuildCacheDescription": "移除舊構建快取,同時保留設定的量。", "storageDockerBuildCacheHelp": "使用 Docker 的年齡與儲存過濾器移除舊構建快取。不會執行 docker system prune,不會全域移除卷,且在確認專用守護處理程序前保持停用。", @@ -493,9 +530,9 @@ "storageTemporaryArtifacts": "Kandev 臨時檔案", "storageTemporaryArtifactsActiveCount_one": "{{count}} 個活動中", "storageTemporaryArtifactsActiveCount_other": "{{count}} 個活動中", - "storageTemporaryArtifactsDialogAction": "移入隔離區", + "storageTemporaryArtifactsDialogAction": "將符合條件的檔案移入隔離區", "storageTemporaryArtifactsDialogDescription": "符合條件的已登記臨時檔案會先移入隔離區,並可在保留期內恢復。活動中或最近建立的檔案會受到保護。", - "storageTemporaryArtifactsDialogTitle": "清理過期的 Kandev 臨時檔案?", + "storageTemporaryArtifactsDialogTitle": "清理不活躍的 Kandev 臨時檔案?", "storageTemporaryArtifactsNoStale": "沒有超過 24 小時的已登記臨時檔案。", "storageTemporaryArtifactsProtectedCount_one": "{{count}} 個受保護", "storageTemporaryArtifactsProtectedCount_other": "{{count}} 個受保護", @@ -503,11 +540,11 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} 個過期可清理", "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", "storageTemporaryArtifactsUnavailable": "無法測量臨時檔案的所有權", - "storageTemporaryArtifactsPolicyTitle": "臨時檔案", - "storageTemporaryArtifactsPolicyDescription": "可選擇在計劃維護期間清理已註冊的 Kandev 檔案。", - "storageCleanStaleTemporaryArtifacts": "清理過期的 Kandev 臨時檔案", - "storageTemporaryArtifactsPolicySummary": "僅限已註冊的非活動檔案。最短期限:24 小時。", - "storageTemporaryArtifactsPolicyHelp": "只有已註冊、處於非活動狀態且至少存在 24 小時的檔案才符合條件。檔案會先移入隔離區,然後才會永久刪除。共享和舊版臨時檔案保持不變。", + "storageTemporaryArtifactsPolicyTitle": "Kandev 臨時檔案", + "storageTemporaryArtifactsPolicyDescription": "Kandev 為臨時使用而登記的診斷包和工具工作資料夾。", + "storageCleanStaleTemporaryArtifacts": "清理不活動的 Kandev 臨時檔案", + "storageTemporaryArtifactsPolicySummary": "已登記的診斷包和工具工作資料夾。至少非活動 24 小時。", + "storageTemporaryArtifactsPolicyHelp": "只有已登記、處於非活動狀態且至少存在 24 小時的檔案才符合條件。檔案會先移入隔離區,然後才會永久刪除。共享臨時資料夾和無關的軟體包快取會被排除。", "storageTemporaryArtifactsPolicyAge": "僅限已註冊的非活動檔案。最短期限:24 小時。", "storageTemporaryArtifactsPolicyQuarantine": "檔案會先移入隔離區,然後才會永久刪除。", "storageTemporaryArtifactsPolicyShared": "共享臨時檔案會被排除。", @@ -627,53 +664,53 @@ "toolPayload.title": "訊息精簡", "toolPayload.description": "移除舊的工具輸入和輸出,同時保留訊息後設資料。", "toolPayload.enabled": "自動精簡", - "toolPayload.age": "任务未活动时长", - "toolPayload.unit": "保留期限单位", + "toolPayload.age": "任務未活動時長", + "toolPayload.unit": "保留期限單位", "toolPayload.weeks": "周", "toolPayload.months": "月", "toolPayload.ageHelp": "不處理活躍任務、排隊工作和等待輸入的任務。", - "toolPayload.invalidAge": "请输入 1–520 个整周或 1–120 个整月。", + "toolPayload.invalidAge": "請輸入 1–520 個整週或 1–120 個整月。", "toolPayload.beforeCleanup": "首次精簡前", - "toolPayload.backupHelp": "选择创建备份或明确选择不备份。所选备份必须成功后才能开始清理。备份占用额外空间。恢复备份会回滚整个数据库,包括较新的更改。", - "toolPayload.backup": "创建备份(推荐)", - "toolPayload.skip": "不备份并继续", - "toolPayload.chooseBackup": "保存前请选择是否创建备份。", - "toolPayload.analyze": "分析可节省空间", + "toolPayload.backupHelp": "選擇建立備份或明確選擇不備份。所選備份必須成功後才能開始清理。備份佔用額外空間。恢復備份會回滾整個資料庫,包括較新的更改。", + "toolPayload.backup": "建立備份(推薦)", + "toolPayload.skip": "不備份並繼續", + "toolPayload.chooseBackup": "儲存前請選擇是否建立備份。", + "toolPayload.analyze": "分析可節省空間", "toolPayload.notAnalyzed": "尚未分析。無需啟用精簡即可預覽預計節省的空間。", - "toolPayload.stale": "此估算已过期或使用了不同期限。请重新分析。", + "toolPayload.stale": "此估算已過期或使用了不同期限。請重新分析。", "toolPayload.empty": "此時間段內未找到可移除的工具詳情。", "toolPayload.estimate": "預計減少的工具詳情大小:{{size}}", - "toolPayload.progress": "已扫描 {{scanned}} 条消息。符合条件:{{tasks}} 个任务,{{messages}} 条消息。", - "toolPayload.removed_one": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", - "toolPayload.removed_other": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", - "toolPayload.skipped_one": "已跳过 {{count}} 项。", - "toolPayload.skipped_other": "已跳过 {{count}} 项。", - "toolPayload.window": "扫描时间:{{start}} 至 {{end}}。活动截止时间:{{cutoff}}。", - "toolPayload.compactionHelp": "估算衡量详细数据字节数,并非数据库文件大小。清理释放空间供重用。使用“数据库”部分的“压缩数据库”缩小文件。现有备份大小不变。", + "toolPayload.progress": "已掃描 {{scanned}} 條訊息。符合條件:{{tasks}} 個任務,{{messages}} 條訊息。", + "toolPayload.removed_one": "已刪除 {{count}} 條訊息的詳細資料。資料減少:{{size}}。", + "toolPayload.removed_other": "已刪除 {{count}} 條訊息的詳細資料。資料減少:{{size}}。", + "toolPayload.skipped_one": "已跳過 {{count}} 項。", + "toolPayload.skipped_other": "已跳過 {{count}} 項。", + "toolPayload.window": "掃描時間:{{start}} 至 {{end}}。活動截止時間:{{cutoff}}。", + "toolPayload.compactionHelp": "估算衡量詳細資料位元組數,並非資料庫檔案大小。清理釋放空間供重用。使用「資料庫」部分的「壓縮資料庫」縮小檔案。現有備份大小不變。", "toolPayload.lastCleanup": "上次執行", - "toolPayload.never": "从未", - "toolPayload.nextCheck": "下次检查", - "toolPayload.disabled": "已禁用", - "toolPayload.pending": "正在等待操作开始。", + "toolPayload.never": "從未", + "toolPayload.nextCheck": "下次檢查", + "toolPayload.disabled": "已停用", + "toolPayload.pending": "正在等待操作開始。", "toolPayload.preparing": "正在準備清理。清理尚未啟用。", - "toolPayload.preparationFailed": "准备失败。清理仍被阻止。请重试或取消准备。", - "toolPayload.retryBackup": "重试准备", - "toolPayload.cancelPreparation": "取消准备并禁用清理", + "toolPayload.preparationFailed": "準備失敗。清理仍被阻止。請重試或取消準備。", + "toolPayload.retryBackup": "重試準備", + "toolPayload.cancelPreparation": "取消準備並停用清理", "toolPayload.run": "立即精簡訊息", "toolPayload.cancel": "取消操作", - "toolPayload.refresh": "刷新状态", - "toolPayload.conflict": "策略已更改或其他操作正在运行。请刷新状态并放弃草稿,使用最新策略后重试。", - "toolPayload.adminOnly": "只有管理员可以更改此策略或运行分析与清理。", - "toolPayload.unsupported": "此数据库引擎不支持工具详细数据清理。", - "toolPayload.operationFailed": "操作未能完成。请刷新状态后重试。", + "toolPayload.refresh": "重新整理狀態", + "toolPayload.conflict": "策略已更改或其他操作正在執行。請重新整理狀態並放棄草稿,使用最新策略後重試。", + "toolPayload.adminOnly": "只有管理員可以更改此策略或執行分析與清理。", + "toolPayload.unsupported": "此資料庫引擎不支援工具詳細資料清理。", + "toolPayload.operationFailed": "操作未能完成。請重新整理狀態後重試。", "toolPayload.analysis": "分析", "toolPayload.cleanup": "精簡", - "toolPayload.running": "正在运行", + "toolPayload.running": "正在執行", "toolPayload.succeeded": "已完成", - "toolPayload.failed": "失败", + "toolPayload.failed": "失敗", "toolPayload.partial": "部分完成", "toolPayload.cancelled": "已取消", - "toolPayload.partialHelp": "此结果不完整。所报告的节省空间仅包含已处理项。", + "toolPayload.partialHelp": "此結果不完整。所報告的節省空間僅包含已處理項。", "toolPayload.skipReason.protected_tasks": "受保護的任務", "toolPayload.skipReason.oversize": "詳細資料超過清理限制", "toolPayload.skipReason.already_removed": "詳細資料已刪除", @@ -681,8 +718,8 @@ "toolPayload.skipReason.unsupported": "不支援或受保護的工具詳細資料", "toolPayload.skipReason.malformed": "無法識別的工具詳細資料", "toolPayload.skipReason.invalid_message": "無效訊息", - "toolPayload.skipReason.other": "其他已跳過項目", - "toolPayload.skipReason.eligibility_budget": "資格檢查逾時;任務已保留", + "toolPayload.skipReason.other": "其他已跳過項", + "toolPayload.skipReason.eligibility_budget": "資格檢查超時;任務已保留", "toolPayload.schedule": "Kandev 執行期間每 24 小時檢查一次。", "toolPayload.firstRun": "首次執行將在備份準備完成後開始。", "toolPayload.analysisDetails": "分析詳情", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 4fc8a76473..441e1f191f 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1147,7 +1147,7 @@ "queueAdmissionFull": "訊息佇列已滿。請等待下一輪處理。", "queueAdmissionIdentityConflict": "此草稿已使用不同內容提交。", "queueAdmissionSessionUnavailable": "所選工作階段已無法接收輸入。", - "queueAdmissionUnavailable": "訊息佇列暫時無法使用。請重試。", + "queueAdmissionUnavailable": "訊息佇列暫時不可用。請重試。", "queueAdmissionValidation": "訊息內容或附件無效。", "queueMoreInstructions": "排隊更多指示…", "queued": "已排隊", @@ -1198,7 +1198,7 @@ "openPROnGitHub": "在 GitHub 上開啟 PR", "remoteContributionPublishDescription": "用任務歷史記錄替換已釋出的 PR 歷史記錄。需要確認,並且提供者的最新提交必須完全匹配。", "remoteContributionRestoreDescription": "用 PR 歷史記錄替換任務工作區歷史記錄。Kandev 會先建立恢復分支,並且工作區必須乾淨。", - "remoteContributionPublishConfirmation": "要在 {{repository}} 的 {{head}} 發佈任務版本嗎?這會用任務歷史記錄替換已釋出的 PR 歷史記錄。", + "remoteContributionPublishConfirmation": "要在 {{repository}} 的 {{head}} 釋出任務版本嗎?這會用任務歷史記錄替換已釋出的 PR 歷史記錄。", "remoteContributionRestoreConfirmation": "要恢復 {{head}} 的已釋出 PR 版本嗎?這會替換目前任務工作區歷史記錄。Kandev 會先建立恢復分支,並且工作區必須乾淨。", "remoteContributionTaskCommits_one": "任務提交:{{count}}", "remoteContributionTaskCommits_other": "任務提交:{{count}}", diff --git a/apps/web/src/settings-route-helpers.test.ts b/apps/web/src/settings-route-helpers.test.ts new file mode 100644 index 0000000000..ba21b6ef8e --- /dev/null +++ b/apps/web/src/settings-route-helpers.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveSettingsRedirect } from "./settings-route-helpers"; + +describe("resolveSettingsRedirect", () => { + afterEach(() => { + window.history.replaceState({}, "", "/settings/system/backups?filter=recent#old-target"); + }); + + it("preserves unrelated query state and uses the destination target", () => { + window.history.replaceState( + {}, + "", + "/settings/system/backups?filter=recent&tab=old#old-target", + ); + + expect( + resolveSettingsRedirect("/settings/system/data-storage?tab=database#setting-system-backups"), + ).toBe("/settings/system/data-storage?filter=recent&tab=database#setting-system-backups"); + }); + + it("drops a conflicting current hash when the destination selects a tab", () => { + window.history.replaceState({}, "", "/settings/system/storage?tab=host#setting-system-storage"); + + expect(resolveSettingsRedirect("/settings/system/storage?tab=office-retention")).toBe( + "/settings/system/storage?tab=office-retention", + ); + }); +}); diff --git a/apps/web/src/settings-route-helpers.tsx b/apps/web/src/settings-route-helpers.tsx index 718208f086..f54d90555a 100644 --- a/apps/web/src/settings-route-helpers.tsx +++ b/apps/web/src/settings-route-helpers.tsx @@ -13,12 +13,24 @@ export function SettingsRedirect({ to }: { to: string }) { const router = useRouter(); useEffect(() => { - router.replace(to); + router.replace(resolveSettingsRedirect(to)); }, [router, to]); return null; } +export function resolveSettingsRedirect(to: string): string { + if (typeof window === "undefined") return to; + const destination = new URL(to, window.location.href); + const current = new URL(window.location.href); + const query = new URLSearchParams(current.search); + for (const key of new Set(destination.searchParams.keys())) query.delete(key); + for (const [key, value] of destination.searchParams) query.append(key, value); + const search = query.toString(); + const hash = destination.hash || (destination.searchParams.has("tab") ? "" : current.hash); + return `${destination.pathname}${search ? `?${search}` : ""}${hash}`; +} + /** * Records the settings page bare `/settings` should return to. * diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index b327b5d2a2..33440820cc 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -18,8 +18,7 @@ import { } from "@/components/settings/settings-breadcrumbs"; import { TaskBehaviorSettings } from "@/components/settings/task-behavior-settings"; import { LegacyExecutorSettingsRoute } from "@/components/settings/legacy-executor-settings-route"; -import { StorageMaintenanceSettings } from "@/components/settings/system/storage/storage-maintenance-settings"; -import { SystemRouteShell } from "@/components/settings/system/system-route-shell"; +import { DataLogsSettings } from "@/components/settings/system/data-logs-settings"; import { WorkspaceSettingsShell } from "@/components/settings/workspaces/workspace-settings-shell"; import { SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS } from "@/lib/settings-discovery/catalog"; import { workspaceId, workflowId } from "@/lib/types/ids"; @@ -276,18 +275,23 @@ describe("renderSettingsRoute", () => { }); describe("system data and storage routes", () => { - it("renders Storage maintenance on its direct route", () => { - const route = renderSettingsRoute("/settings/system/storage") as ReactElement<{ - titleKey: string; - descriptionKey: string; - children: ReactElement; + it("renders the Data & Logs page composition directly", () => { + const route = renderSettingsRoute("/settings/system/data-storage"); + + expect(isValidElement(route)).toBe(true); + expect((route as ReactElement).type).toBe(DataLogsSettings); + }); + + it("redirects legacy backups into the Database tab and target", () => { + const route = renderSettingsRoute("/settings/system/backups") as ReactElement<{ + to: string; }>; expect(isValidElement(route)).toBe(true); - expect(route.type).toBe(SystemRouteShell); - expect(route.props.titleKey).toBe("system:storageTitle"); - expect(route.props.descriptionKey).toBe("system:storageDescription"); - expect(route.props.children.type).toBe(StorageMaintenanceSettings); + expect((route.type as { name?: string }).name).toBe("SettingsRedirect"); + expect(route.props.to).toBe( + "/settings/system/data-storage?tab=database#setting-system-backups", + ); }); }); diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 9ea6ea7385..438e397156 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -50,7 +50,7 @@ import { UnitsPage } from "@/components/settings/units/units-page"; import { HealthIssuesCard } from "@/components/settings/system/health-issues-card"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { SystemRouteShell } from "@/components/settings/system/system-route-shell"; -import { StorageMaintenanceSettings } from "@/components/settings/system/storage/storage-maintenance-settings"; +import { StorageSettings } from "@/components/settings/system/storage-settings"; import { UIStateCard } from "@/components/settings/system/ui-state-card"; import { UpdatesCard } from "@/components/settings/system/updates-card"; import { VersionSummaryCard } from "@/components/settings/system/version-summary-card"; @@ -201,16 +201,17 @@ const SETTINGS_ROUTES: Record = { ), - "/settings/system/data-storage": () => ( - - - + "/settings/system/data-storage": () => , + "/settings/system/backups": () => ( + + ), + "/settings/system/database": () => ( + ), - "/settings/system/backups": () => , - "/settings/system/database": () => , "/settings/units": () => ( @@ -230,7 +231,9 @@ const SETTINGS_ROUTES: Record = { ), "/settings/system/licenses": () => , - "/settings/system/logs": () => , + "/settings/system/logs": () => ( + + ), "/settings/system/message-queue": () => , "/settings/system/status": () => ( @@ -242,11 +245,7 @@ const SETTINGS_ROUTES: Record = { ), - "/settings/system/storage": () => ( - - - - ), + "/settings/system/storage": () => , "/settings/system/updates": renderUpdatesRoute, "/settings/changelog": () => , }; diff --git a/docs/decisions/2026-09-03-separate-system-data-storage-pages.md b/docs/decisions/2026-09-03-separate-system-data-storage-pages.md index ed1e2ada3d..1850ac78d5 100644 --- a/docs/decisions/2026-09-03-separate-system-data-storage-pages.md +++ b/docs/decisions/2026-09-03-separate-system-data-storage-pages.md @@ -29,8 +29,8 @@ System settings will expose two direct page destinations: The existing `Data & Logs` path remains stable. The legacy Database, Backups, and Logs paths will continue to redirect to that page. -Desktop and phone navigation will show both destinations directly. The split -will not add tabs, drawers, or another navigation level. +Desktop and phone navigation will show both destinations directly. The split originally added no tabs, drawers, or another navigation level. +The [header-tab decision](2026-09-15-settings-header-tabs.md) supersedes the no-tabs constraint. ## Consequences diff --git a/docs/decisions/2026-09-15-settings-header-tabs.md b/docs/decisions/2026-09-15-settings-header-tabs.md new file mode 100644 index 0000000000..99fc62152c --- /dev/null +++ b/docs/decisions/2026-09-15-settings-header-tabs.md @@ -0,0 +1,39 @@ +# ADR-2026-09-15-settings-header-tabs: Shared settings header tabs and maintenance grouping + +**Status:** accepted +**Date:** 2026-09-15 +**Area:** frontend + +## Context + +Data & Logs combines database actions, Office retention, compaction, backups, and logs in a long page. +The user selected tabs within the existing title/description header, aligned right on desktop. +The user also requested a reusable component for other settings pages. + +## Decision + +Extend the existing settings header with optional shared tab composition. +Place tabs below the description on phones. Preserve the same control treatment and keyboard behavior across consumers. +Data & Logs groups Database and Logs. Storage groups Host and Office retention. +Canonical page URLs stay stable. The `tab` query identifies the selected group. +Tab clicks replace the current history entry and preserve drafts. Cross-page navigation retains save protection. + +This decision supersedes only the no-tabs constraint in +[the page-split decision](2026-09-03-separate-system-data-storage-pages.md). +It preserves the two direct settings destinations and their ownership boundaries. +Implementation is complete in [the plan package](../plans/settings-storage-tabs/plan.md), +including focused desktop and phone verification and the published maintenance documentation. + +## Consequences + +The shared header owns layout and interaction. Consumers retain domain state and permissions. +Search and health links must select the correct tab before revealing a control. +Inactive forms must retain their save contributors without exposing hidden focus targets. +Future consumers use the shared component instead of copying its styles. + +## Alternatives Considered + +- A separate tab row below the header uses extra vertical space. The user selected tabs inside the header. +- Page-specific controls can drift in appearance and interaction. A shared component establishes one contract. +- More sidebar destinations add navigation density. The two existing pages remain sufficient. +- A local-only selection loses copied-link and reload context. The query parameter preserves that context. diff --git a/docs/plans/run-history-retention/plan.md b/docs/plans/run-history-retention/plan.md index 36b5862054..e6a6597595 100644 --- a/docs/plans/run-history-retention/plan.md +++ b/docs/plans/run-history-retention/plan.md @@ -25,7 +25,7 @@ was doing nothing useful. This plan adds one scheduled sweep, on its own interva (never the 5s Office tick), that bounds `office_routine_runs`, `runs`, and their satellites (`run_events`, `office_run_route_attempts`, `office_run_skills`) by age, with a per-owner floor, identical behavior on SQLite and PostgreSQL, and an operator -surface (Settings > System > Data & Logs) that reports policy, counts, previews, and +surface (Settings > System > Storage > Office retention) that reports policy, counts, previews, and warnings before and while rows are deleted. The two halves of the contract are split the same way the specs are split: the sweep @@ -49,7 +49,7 @@ System page surface are deleted in the same transaction as their parent run. - A per-table preview (report, delete nothing) on each table's first evaluation, and `health.Issue` warnings before the cap and on sweep/count failure. -- A `RetentionSettingsCard` on Settings > System > Data & Logs showing policy, retained +- A `RetentionSettingsCard` on Settings > System > Storage > Office retention showing policy, retained counts, preview state, last sweep, and backlog/error warnings. - Expression indexes serving the sweep's filter/order on both engines. @@ -62,3 +62,8 @@ System page surface are ## Tasks - [x] [Task 01: Bound run history with a scheduled retention sweep](task-01-bound-run-history-with-retention-sweep.md) + +## Follow-up presentation package + +The [settings storage tabs package](../settings-storage-tabs/plan.md) completed the header tabs and maintenance presentation changes. +Its results remain historical inputs to the current route, copy, and regression verification. diff --git a/docs/plans/run-history-retention/task-01-bound-run-history-with-retention-sweep.md b/docs/plans/run-history-retention/task-01-bound-run-history-with-retention-sweep.md index 7d7c2c1546..b27a84082e 100644 --- a/docs/plans/run-history-retention/task-01-bound-run-history-with-retention-sweep.md +++ b/docs/plans/run-history-retention/task-01-bound-run-history-with-retention-sweep.md @@ -74,7 +74,7 @@ Add `internal/office/retention`: a settings-backed sweep that ages out interval, a per-owner floor, status-only live/history classification, a preview pass before first deletion, engine-identical behavior on SQLite and PostgreSQL, and a `GET`/`PUT /api/v1/system/retention` operator surface with a matching -Settings > System > Data & Logs card. +Settings > System > Storage > Office retention card. ## In scope @@ -89,7 +89,7 @@ Settings > System > Data & Logs card. `office_run_skills`) in the same transaction as their parent run. - `health.Issue` warnings for approaching the backlog cap and for count/sweep failure; a one-time preview per table before any row is deleted. -- `RetentionSettingsCard` on Settings > System > Data & Logs: policy, retained +- `RetentionSettingsCard` on Settings > System > Storage > Office retention: policy, retained counts, preview/backlog state, last sweep, errors. - Expression indexes serving the sweep's filter/order on both engines. @@ -166,7 +166,7 @@ None. - Implemented `internal/office/retention` (settings store, `Sweeper`, `Scheduler`, `CensusTracker`, PostgreSQL advisory lock with SQLite equivalent, `Handler` for `GET`/`PUT /api/v1/system/retention`) plus the - `RetentionSettingsCard` on Settings > System > Data & Logs. + `RetentionSettingsCard` on Settings > System > Storage > Office retention. - Full backend gauntlet green: `go build ./...`, `go vet`, `gofmt -l`, `go test -race ./internal/office/retention/...` (SQLite), the PostgreSQL-gated suite against a real scratch instance (125 tests, 0 diff --git a/docs/plans/settings-storage-tabs/plan.md b/docs/plans/settings-storage-tabs/plan.md new file mode 100644 index 0000000000..56d5565910 --- /dev/null +++ b/docs/plans/settings-storage-tabs/plan.md @@ -0,0 +1,304 @@ +--- +created: 2026-09-15 +status: completed +requirements: + - REQ-UI-SETTINGS-HEADER-TABS-001 + - REQ-UI-SETTINGS-HEADER-TABS-002 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-001 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-002 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-004 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md + - ../../specs/system-page/system-design/system-data-storage-pages.md +legacy_specs: [] +--- + +# Implementation Plan: Settings Header Tabs and Storage UX + +## Overview + +Create reusable tabs inside the existing page header, then reorganize Data & Logs and Storage. +Improve retention controls, status, temporary-file explanations, and compaction width. +Implement sequentially: shared navigation, retention presentation, remaining cards, then public documentation. +The user explicitly selected right-aligned desktop tabs and phone tabs below the description. +Implementation is tracked by the work orders below without delegation. + +## Scope + +### In scope + +- Shared SettingsTabs and optional tabs in the existing SettingsPageHeader. +- Data & Logs: Database and Logs. Storage: Host and Office retention. +- URL selection, legacy/search/health destinations, and preserved save lifecycles. +- Compact desktop controls, touch help drawers, translated product labels, and accurate retention status. +- Full-row message compaction and clear temporary-file copy. +- Focused desktop/phone tests and public maintenance instructions. + +### Out of scope + +- New retention policy, database migrations, cleanup eligibility, APIs, or background jobs. +- Persisted sweep history, predicted next cleanup, or a new manual Office sweep action. +- Migration of unrelated settings pages, global tab restyling, or new runtime flags. +- Product screenshot recapture as a separate media project. Existing stale screenshots can be removed or relabeled without claiming a new capture. + +## Technical approach + +Use the [UI design](../../specs/ui/system-design/settings-header-tabs.md) for shared controls and state lifetime. +Use the [system-page design](../../specs/system-page/system-design/system-data-storage-pages.md) for domain composition and status derivation. +The [ADR](../../decisions/2026-09-15-settings-header-tabs.md) records the user-selected header placement and replaces the old no-tabs constraint. + +Existing SettingsPageHeader already has title, description, and actions. Extend it instead of adding another header. +SystemRouteShell/SystemPageShell expose the optional tabs slot. Each consuming page owns one Tabs root. +Use the existing router and query subscriptions. Tab clicks replace the current history entry and retain forms. +Guard bypass applies only to validated same-path tab replacements. Page exit still uses the save coordinator. +Keep one target registration per discovery ID. Activate a hidden target's tab before focus/highlight. +Office health fix links change, but health identifiers and retention wire contracts do not. + +## ASCII UI preview + +Structure is required; spacing, example values, and exact copy are illustrative. +All text inside UI code must use localization. Examples below use ASCII `(i)` for the information icon. + +### UI-01: Header tabs, desktop + +Entry: Settings > Data & Logs or Settings > Storage. State: selected tab, loaded content. +Before: the header has no tabs; Data & Logs stacks Database, Office retention, compaction, backups, and Logs. +After: + +```text +Data & Logs [ Database | Logs ] +Database statistics, backups, and server logs. +------------------------------------------------------------ +[ Database status and actions ] +[ Message compaction: full content width ] +[ Backups ] + +Storage [ Host | Office retention ] +Manage disk usage and history retention. +------------------------------------------------------------ +[ Content of the selected tab ] +``` + +Tabs belong inside the existing title/description header. There is no second content tab row. +Header and content use the existing page scroll owner; neither gains a new fixed position. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.1/.3/.6 and AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.1/.2. + +### UI-02: Header tabs, phone + +Entry: the mobile Settings navigation. State: Storage, Office retention selected. + +```text +Storage +Manage disk usage and history +retention. + +[ Host | Office retention ] +------------------------------ +Retention status +Automatic cleanup On +Last cleanup +Today, 09:00 Completed + +Routine history 1,240 +Agent run history 860 +Run events 12,400 + +> Cleanup details +------------------------------ +Retention policy +Automatically delete old +history [ON] +Active work remains protected. + +Routine history +Keep for (i) +[ 30 ] days +Minimum per routine (i) +[ 50 ] records + +> Advanced settings + + [ Save changes ] +``` + +Tabs move below the description inside the header. Counts and fields form a single column. +The existing floating save control appears only for dirty settings and clears the safe area. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.2/.5 and AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.6. + +### UI-03: Office retention, desktop and status variants + +Entry: Storage > Office retention. State: loaded, saved policy. + +```text +Storage [ Host | Office retention ] +Manage disk usage and history retention. +------------------------------------------------------------ +Retention status Automatic cleanup: On +Last cleanup Today, 09:00 - Completed +Routine history Agent run history Run events +1,240 records 860 records 12,400 records +Updated today, 09:00 +Stored counts include protected work. +> Cleanup details +------------------------------------------------------------ +Retention policy Automatic cleanup [ON] +Old finished history and related details are deleted. +Active work remains protected. + +Routine history +Keep for (i) [30] days Minimum per routine (i) [50] +Agent run history +Keep for (i) [30] days Minimum per agent profile (i) [50] + +v Advanced settings +Cleanup interval (i) [6] hours Batch limit (i) [5000] +Warn above (i) +Routine records [25000] Agent run records [25000] +Run events [250000] +``` + +Changed states: + +```text +Loading: [spinner] Loading retention settings +Load error: Unable to load retention settings. [existing retry path] +No result: No cleanup recorded since server startup. +Unavailable count: Routine history: Not available +Stale count: Run events: 12,400 (out of date, timestamp) +Preview: Routine history: Preview, 120 eligible, 0 deleted +Mixed: Routine history: Preview | Agent run history: 40 deleted +Error: Cleanup completed with errors. 40 records deleted. +Backlog: More eligible history remains for a later cleanup. +Disabled: Automatic cleanup: Off | Stored counts remain visible +``` + +The retry path means the existing hook/page reload behavior; it does not require a new API. +Error/backlog/stale notices remain outside collapsed details. Preview totals never count as deletions. +Use saved settings for status while a policy draft is dirty. +Maps to AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.1-.4 and -004.1-.6. + +### UI-04: Temporary files and help + +Entry: Storage > Host. State: cleanup available, automatic cleanup off. + +```text +Temporary Kandev files +Diagnostic bundles and utility working folders. +Clean inactive temporary files (i) [OFF] +Inactive for at least 24 hours. Files move to quarantine. +[ Clean temporary files ] +``` + +Desktop help opens on hover or focus. Phone tap opens the shared inset Drawer: + +```text ++--------------------------------+ +| About temporary Kandev files | +| Registered diagnostic bundles | +| and utility working folders. | +| Shared temporary folders and | +| unrelated caches are excluded. | +| [Close] | ++--------------------------------+ +``` + +The Drawer owns its temporary scroll area only when content exceeds available height. +Its close action returns focus to the information icon. Existing cleanup confirmation and disabled reasons remain intact. +Maps to AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.2/.5. + +## Tests + +Each named new test is a deliverable, not an existing suite. Test names below define the expected cases. + +| Criteria | Evidence | +| --- | --- | +| UI -001.1-.6 | `settings-tabs.test.tsx`: shared composition, manual keyboard activation, panel associations, no-tabs compatibility; rendered header tests | +| UI -002.1/.4/.5; system -002.4 | `use-settings-tab.test.tsx`: default/invalid query, target precedence, repeated target, query preservation, history destination; route/discovery tests | +| UI -002.2/.3; system -001.9/-002.5 | `settings-save-provider.test.tsx` plus tab integration: retained dirty contributors, save failure, discard and guarded exit | +| system -002.1-.3 | `settings-routes.test.ts`: correct tab composition and legacy mapping | +| system -003.1-.4 | `retention-settings-card.test.tsx`: labels, full saved payload, advanced fields and help | +| system -004.1-.6 | `retention-status.test.ts`: absent sweep, saved-enabled state, zero/unavailable/stale, differing timestamps, threshold equality/zero, mixed preview/deletion, partial errors, backlog, skip and attribution | +| system -003.5/.6 | Existing storage-policy and tool-payload card suites: retained actions and copy semantics | +| system -002.4 | Go retention/health tests: new Office retention FixURL | + +## E2E tests + +| Flow | File and project | Criteria | +| --- | --- | --- | +| Header placement, long labels, keyboard selection, URL/reload/history, dirty tab round-trip, guarded exit, search reveal | `system/settings-header-tabs.spec.ts`, chromium | UI -001/-002; system -002 | +| Phone header, 767/768 boundary checks, coarse-pointer target checks, tab strip overflow and focus | `system/mobile-settings-header-tabs.spec.ts`, mobile-chrome | UI -001; system -001.7/.8 | +| Retention edit/save/reload, advanced help, saved-policy status and mixed/error fixtures | `system/retention-settings.spec.ts`, chromium | system -003.1-.4/-004 | +| Retention touch help Drawer, close/focus return, single-column summary, controls >=44px | `system/mobile-retention-settings.spec.ts`, mobile-chrome | system -003.2/.3/-004.6 | +| Existing temporary cleanup and compaction workflows, full card width, help | Existing temporary-folders and tool-payload specs, chromium/mobile-chrome | system -003.5/.6 | +| Existing database/backups/logs navigation and actions | Existing system specs in task 01 | system -001/-002 | +| Member read-only host/database and denied Office mutations after tab changes | Existing data-storage member-gating specs, auth | system -002.5 | + +Use API fixtures for saved data and controlled response fixtures for rare status combinations. +Assertions operate through rendered UI. Restore modified settings after each test. +Measure desktop input height at 28px within 1px and touch targets at least 44px. +Measure header title/tab alignment and card width against the actual parent, not visibility alone. +Capture focused rendered desktop/phone evidence in each UI work order. Check pseudo-locale and long translated labels. + +## Work orders + +- [x] [Task 01: Shared header tabs and page destinations](task-01-shared-header-tabs.md) +- [x] [Task 02: Plain-language retention policy and status](task-02-retention-policy-status.md) +- [x] [Task 03: Temporary file explanations and full-row compaction](task-03-temporary-files-compaction.md) +- [x] [Task 04: Document the settings destinations](task-04-operations-documentation.md) + +## Verification setup + +From the repository root, install dependencies once before implementation checks: + +```bash +(cd apps && pnpm install --frozen-lockfile) +``` + +Each work order lists exact targeted commands. Run E2E commands sequentially without worker overrides. +The managed runner builds current sources. Use the configured chromium, mobile-chrome, and auth projects. +After locale edits, run `(cd apps/web && pnpm run i18n:zh-hant)` before the task's i18n checks. + +## Results + +Implemented the four work orders in sequence. The two system pages now use shared +header tabs with URL-addressable selection, preserved drafts, target activation, +and legacy redirects. Office retention has the revised policy and status +presentation, host maintenance retains its existing operations, and Database +contains message compaction and backups. + +Validation passed for focused Vitest coverage (16 files, 167 tests), frontend +typecheck and lint, i18n checks and ratchet, public documentation and +specification validation, gofmt, focused Office retention and health tests, and +the affected desktop, mobile, and authenticated E2E suites. The full backend +matrix still reports unrelated failures in existing process probe timing, +ambient config discovery, launcher setup, and an Office FTS migration test; +the changed retention backend packages pass independently. +New components/hooks must meet scoped lint limits. Run `pnpm exec eslint` from apps/web on every changed TS/TSX source and test path. +No generic review, QA, simplify, or broad verification work order is added. + +## Companion packages + +The page-split, Office run-retention, tool-payload-retention, and storage-temp-artifacts packages are historical inputs. +Their completed results remain historical. This package owns all new route and presentation verification. +Their manifests link here so future work can find the changed allocation. + +## Verification results + +Implementation is complete. All four work orders are done. Focused Vitest, +frontend typecheck and lint, i18n, public documentation, specification, Go, +desktop, mobile, and authenticated E2E checks passed. Fresh desktop and mobile +PR captures were published from the affected pages. + +The full backend matrix still has unrelated failures in existing process probe +timing, ambient config discovery, launcher setup, and an Office FTS migration +test. The changed retention backend packages pass independently. + +## Risks + +- Query updates can unregister dirty forms or bypass page-exit protection if mounted state is not preserved. +- Hidden panels can consume discovery focus before their tab opens. +- A mixed preview/deletion sweep or stale count can produce a misleading summary if flattened into one success badge. +- Raw technical IDs can leak through errors; diagnostics remain available, but normal labels must be translated. +- Retention is admin-scoped even though Host contains member-readable information. +- Old screenshots and links can describe the previous layout until the documentation work order ships. diff --git a/docs/plans/settings-storage-tabs/task-01-shared-header-tabs.md b/docs/plans/settings-storage-tabs/task-01-shared-header-tabs.md new file mode 100644 index 0000000000..0b6d9f9f93 --- /dev/null +++ b/docs/plans/settings-storage-tabs/task-01-shared-header-tabs.md @@ -0,0 +1,190 @@ +--- +id: "01-shared-header-tabs" +title: "Shared header tabs and page destinations" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-HEADER-TABS-001 + - REQ-UI-SETTINGS-HEADER-TABS-002 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-001 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-002 +acceptance_criteria: + - AC-UI-SETTINGS-HEADER-TABS-001.1 + - AC-UI-SETTINGS-HEADER-TABS-001.2 + - AC-UI-SETTINGS-HEADER-TABS-001.3 + - AC-UI-SETTINGS-HEADER-TABS-001.4 + - AC-UI-SETTINGS-HEADER-TABS-001.5 + - AC-UI-SETTINGS-HEADER-TABS-001.6 + - AC-UI-SETTINGS-HEADER-TABS-002.1 + - AC-UI-SETTINGS-HEADER-TABS-002.2 + - AC-UI-SETTINGS-HEADER-TABS-002.3 + - AC-UI-SETTINGS-HEADER-TABS-002.4 + - AC-UI-SETTINGS-HEADER-TABS-002.5 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.1 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.2 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.3 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.4 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.5 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md + - ../../specs/system-page/system-design/system-data-storage-pages.md +--- + +# Task 01: Shared header tabs and page destinations + +## Summary + +Deliver the shared header tabs through both real settings pages. Preserve drafts and route compatibility in the same vertical slice. + +## In scope + +- Extend the existing header and shells, add shared SettingsTabs and useSettingsTab, and adopt them on both pages. +- Move Office retention, update discovery and legacy redirects, and update the backend retention health fix URL. +- Mount each panel lazily on first activation, retain it after activation, hide inactive panels explicitly, and keep an active diagnostic bundle mounted while Logs is hidden. +- Add all changed locale keys and update route-dependent E2E selectors/helpers without removing existing action scenarios. +- Add the shared-component convention to apps/web/AGENTS.md. + +## Out of scope + +- New retention algorithms, provider eligibility, persistence, or API payloads. +- Unrelated settings-page migration, release flags, and task delegation. + +## Acceptance + +- Both pages use one shared header-tab implementation with correct desktop/phone placement and keyboard/touch operation. +- Direct links, defaults, invalid tabs, target fragments, repeated search, and history restore the correct visible panel. +- Dirty forms survive tab switches; save/discard, guarded page exit, save failure, and member permissions retain their behavior. + +## ASCII UI preview + +See the [combined previews](plan.md#ascii-ui-preview). + +### UI-01: Header tabs, desktop + +Entry: Settings > Data & Logs or Settings > Storage. State: selected tab, loaded content. +Before: the header has no tabs; Data & Logs stacks Database, Office retention, compaction, backups, and Logs. +After: + +```text +Data & Logs [ Database | Logs ] +Database statistics, backups, and server logs. +------------------------------------------------------------ +[ Database status and actions ] +[ Message compaction: full content width ] +[ Backups ] + +Storage [ Host | Office retention ] +Manage disk usage and history retention. +------------------------------------------------------------ +[ Content of the selected tab ] +``` + +Tabs belong inside the existing title/description header. There is no second content tab row. +Header and content use the existing page scroll owner; neither gains a new fixed position. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.1/.3/.6 and AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.1/.2. + +### UI-02: Header tabs, phone + +Entry: the mobile Settings navigation. State: Storage, Office retention selected. + +```text +Storage +Manage disk usage and history +retention. + +[ Host | Office retention ] +------------------------------ +Retention status +Automatic cleanup On +Last cleanup +Today, 09:00 Completed + +Routine history 1,240 +Agent run history 860 +Run events 12,400 + +> Cleanup details +------------------------------ +Retention policy +Automatically delete old +history [ON] +Active work remains protected. + +Routine history +Keep for (i) +[ 30 ] days +Minimum per routine (i) +[ 50 ] records + +> Advanced settings + + [ Save changes ] +``` + +Tabs move below the description inside the header. Counts and fields form a single column. +The existing floating save control appears only for dirty settings and clears the safe area. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.2/.5 and AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.6. + +## Verification + +Run from the repository root. New test paths below are implementation deliverables. +Use TDD for changed logic. Run focused failing tests before implementation, then the complete block after changes. +The E2E runner rebuilds current sources; do not pass `--no-build` for changed UI. + +```bash +(cd apps/web && pnpm exec vitest run components/settings/settings-tabs.test.tsx components/settings/settings-typography.test.ts hooks/domains/settings/use-settings-tab.test.ts src/settings-route-helpers.test.ts src/settings-routes.test.ts lib/settings-discovery/catalog.test.ts lib/settings-discovery/navigation.test.ts lib/settings-discovery/target.test.ts) +(cd apps/backend && go test ./internal/office/retention ./internal/health) +(cd apps/web && pnpm e2e:run --project chromium e2e/tests/system/settings-header-tabs.spec.ts e2e/tests/system/database-page.spec.ts e2e/tests/system/backups-page.spec.ts e2e/tests/system/logs-page.spec.ts e2e/tests/system/retention-settings.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-settings-header-tabs.spec.ts e2e/tests/system/mobile-database-page.spec.ts e2e/tests/system/mobile-logs-bundle.spec.ts) +(cd apps/web && pnpm e2e:run --project auth e2e/tests/auth/system-data-storage-member-gating.spec.ts e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts) +(cd apps/web && pnpm exec eslint components/settings/settings-tabs.tsx components/settings/settings-tabs.test.tsx components/settings/settings-typography.tsx components/settings/system/system-page-shell.tsx components/settings/system/system-route-shell.tsx components/settings/system/data-logs-settings.tsx components/settings/system/storage-settings.tsx hooks/domains/settings/use-settings-tab.ts hooks/domains/settings/use-settings-tab.test.tsx src/settings-routes.tsx lib/settings-discovery/catalog/system.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +git diff --check +``` + +## Files likely touched + +- apps/web/components/settings/settings-typography.tsx and settings-tabs.tsx (new), with tests +- apps/web/components/settings/system/system-page-shell.tsx and system-route-shell.tsx +- apps/web/components/settings/system/data-logs-settings.tsx and storage-settings.tsx (new) +- apps/web/hooks/domains/settings/use-settings-tab.ts (new), with tests +- apps/web/src/settings-routes.tsx and src/settings-routes.test.ts +- apps/web/lib/settings-discovery/catalog/system.ts, navigation.ts, and affected discovery tests +- apps/web/components/settings/settings-target-provider.tsx and settings-target.tsx if activation coordination requires changes +- apps/backend/internal/office/retention/health.go and health_test.go +- apps/web/e2e/tests/system/settings-header-tabs.spec.ts and mobile-settings-header-tabs.spec.ts (new) +- Existing E2E files named in Verification and their shared route helpers +- apps/web/src/locales/*/system.json, settings.json, and apps/web/AGENTS.md + +## Dependencies + +None + +## Risks + +An inactive mounted panel can steal target focus or lose its save registration. A generic bypass can disable unrelated navigation guards. + +## Parallelism + +`sequential` + +## Inputs + +- [Header-tab requirements](../../specs/ui/requirements/settings-header-tabs.md) and [design](../../specs/ui/system-design/settings-header-tabs.md). +- [Data/storage requirements](../../specs/system-page/requirements/system-data-storage-pages.md) and [design](../../specs/system-page/system-design/system-data-storage-pages.md). +- Existing SystemPageShell, SettingsPageHeader, settings save tests, and SleepInhibitionInfoTooltip touch pattern. + +## Results + +Implemented shared `SettingsTabs` and `useSettingsTab` support, header placement, +same-path query selection, draft-preserving panel mounts, discovery target +activation, legacy redirects, and the Data & Logs and Storage page composition. +Updated the Office retention health destination and route-dependent E2E helpers. + +Validation passed with the focused route/tab tests, focused Office retention and +health Go tests, frontend typecheck and lint, and the affected desktop, mobile, +and authenticated E2E suites. diff --git a/docs/plans/settings-storage-tabs/task-02-retention-policy-status.md b/docs/plans/settings-storage-tabs/task-02-retention-policy-status.md new file mode 100644 index 0000000000..d38196ad96 --- /dev/null +++ b/docs/plans/settings-storage-tabs/task-02-retention-policy-status.md @@ -0,0 +1,203 @@ +--- +id: "02-retention-policy-status" +title: "Plain-language retention policy and status" +status: done +wave: 2 +depends_on: ["01-shared-header-tabs"] +plan: "plan.md" +requirements: + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-004 +acceptance_criteria: + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.1 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.2 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.3 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.4 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.1 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.2 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.3 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.4 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.5 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.6 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md + - ../../specs/system-page/system-design/system-data-storage-pages.md +--- + +# Task 02: Plain-language retention policy and status + +## Summary + +Deliver the retention policy and status redesign on the Office retention tab. Preserve the existing backend semantics. + +## In scope + +- Split policy/status presentation into focused components and extract a pure status view-model helper. +- Replace schema labels, correct owner labels, move secondary help into accessible Tooltip/Drawer controls, and restore shared input sizing. +- Group tuning controls in Advanced settings and retain validation visibility. +- Show saved policy state, measured counts, committed deletion totals, and accurate preview/backlog/error states. +- Translate every changed label and preserve stable technical test IDs. + +## Out of scope + +- New retention algorithms, provider eligibility, persistence, or API payloads. +- Unrelated settings-page migration, release flags, and task delegation. + +## Acceptance + +- Policy controls retain settings values and save behavior, with correct labels, sizing, help, and advanced-field access. +- Status distinguishes unavailable, zero, stale, preview, mixed deletion/preview, backlog, and error data without invented metrics. +- Desktop and phone support the same editing and diagnostic tasks, including drawer dismissal, focus return, and saved-policy status during unsaved edits. + +## ASCII UI preview + +See the [combined previews](plan.md#ascii-ui-preview). + +### UI-02: Header tabs, phone + +Entry: the mobile Settings navigation. State: Storage, Office retention selected. + +```text +Storage +Manage disk usage and history +retention. + +[ Host | Office retention ] +------------------------------ +Retention status +Automatic cleanup On +Last cleanup +Today, 09:00 Completed + +Routine history 1,240 +Agent run history 860 +Run events 12,400 + +> Cleanup details +------------------------------ +Retention policy +Automatically delete old +history [ON] +Active work remains protected. + +Routine history +Keep for (i) +[ 30 ] days +Minimum per routine (i) +[ 50 ] records + +> Advanced settings + + [ Save changes ] +``` + +Tabs move below the description inside the header. Counts and fields form a single column. +The existing floating save control appears only for dirty settings and clears the safe area. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.2/.5 and AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.6. + +### UI-03: Office retention, desktop and status variants + +Entry: Storage > Office retention. State: loaded, saved policy. + +```text +Storage [ Host | Office retention ] +Manage disk usage and history retention. +------------------------------------------------------------ +Retention status Automatic cleanup: On +Last cleanup Today, 09:00 - Completed +Routine history Agent run history Run events +1,240 records 860 records 12,400 records +Updated today, 09:00 +Stored counts include protected work. +> Cleanup details +------------------------------------------------------------ +Retention policy Automatic cleanup [ON] +Old finished history and related details are deleted. +Active work remains protected. + +Routine history +Keep for (i) [30] days Minimum per routine (i) [50] +Agent run history +Keep for (i) [30] days Minimum per agent profile (i) [50] + +v Advanced settings +Cleanup interval (i) [6] hours Batch limit (i) [5000] +Warn above (i) +Routine records [25000] Agent run records [25000] +Run events [250000] +``` + +Changed states: + +```text +Loading: [spinner] Loading retention settings +Load error: Unable to load retention settings. [existing retry path] +No result: No cleanup recorded since server startup. +Unavailable count: Routine history: Not available +Stale count: Run events: 12,400 (out of date, timestamp) +Preview: Routine history: Preview, 120 eligible, 0 deleted +Mixed: Routine history: Preview | Agent run history: 40 deleted +Error: Cleanup completed with errors. 40 records deleted. +Backlog: More eligible history remains for a later cleanup. +Disabled: Automatic cleanup: Off | Stored counts remain visible +``` + +The retry path means the existing hook/page reload behavior; it does not require a new API. +Error/backlog/stale notices remain outside collapsed details. Preview totals never count as deletions. +Use saved settings for status while a policy draft is dirty. +Maps to AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.1-.4 and -004.1-.6. + +## Verification + +Run from the repository root. New test paths below are implementation deliverables. +Use TDD for changed logic. Run focused failing tests before implementation, then the complete block after changes. +The E2E runner rebuilds current sources; do not pass `--no-build` for changed UI. + +```bash +(cd apps/web && pnpm exec vitest run components/settings/system/retention-settings-card.test.tsx lib/utils/retention-status.test.ts) +(cd apps/web && pnpm e2e:run --project chromium e2e/tests/system/retention-settings.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-retention-settings.spec.ts) +(cd apps/web && pnpm exec eslint components/settings/system/retention-*.tsx lib/utils/retention-status.ts lib/utils/retention-status.test.ts e2e/tests/system/retention-settings.spec.ts e2e/tests/system/mobile-retention-settings.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +git diff --check +``` + +## Files likely touched + +- apps/web/components/settings/system/retention-settings-card.tsx and test +- Focused retention policy, status, and help components in the same directory (new) +- apps/web/lib/utils/retention-status.ts and retention-status.test.ts (new) +- apps/web/e2e/tests/system/retention-settings.spec.ts and mobile-retention-settings.spec.ts (new) +- apps/web/src/locales/*/system.json + +## Dependencies + +01-shared-header-tabs + +## Risks + +Preview is per category. Stale counts and unsaved thresholds must not produce a false healthy status. Hidden invalid fields must remain actionable. + +## Parallelism + +`sequential` + +## Inputs + +- [Header-tab requirements](../../specs/ui/requirements/settings-header-tabs.md) and [design](../../specs/ui/system-design/settings-header-tabs.md). +- [Data/storage requirements](../../specs/system-page/requirements/system-data-storage-pages.md) and [design](../../specs/system-page/system-design/system-data-storage-pages.md). +- Existing SystemPageShell, SettingsPageHeader, settings save tests, and SleepInhibitionInfoTooltip touch pattern. + +## Results + +Implemented the plain-language Office retention policy, saved-policy status +view-model, friendly scope labels, advanced tuning disclosure, desktop help +tooltips, mobile help Drawer, compact desktop controls, and status handling for +disabled, unavailable, zero, stale, preview, deletion, backlog, and error +states. Draft edits continue to use the existing save coordinator. + +Validation passed with retention unit tests, the focused 16-file Vitest run, +desktop retention E2E, mobile retention E2E, frontend typecheck and lint, and +i18n checks. diff --git a/docs/plans/settings-storage-tabs/task-03-temporary-files-compaction.md b/docs/plans/settings-storage-tabs/task-03-temporary-files-compaction.md new file mode 100644 index 0000000000..662eb51cf4 --- /dev/null +++ b/docs/plans/settings-storage-tabs/task-03-temporary-files-compaction.md @@ -0,0 +1,152 @@ +--- +id: "03-temporary-files-compaction" +title: "Temporary file explanations and full-row compaction" +status: done +wave: 3 +depends_on: ["02-retention-policy-status"] +plan: "plan.md" +requirements: + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003 +acceptance_criteria: + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.2 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.5 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.6 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md + - ../../specs/system-page/system-design/system-data-storage-pages.md +--- + +# Task 03: Temporary file explanations and full-row compaction + +## Summary + +Explain temporary Kandev files and give message compaction the full Database content row. Preserve both maintenance workflows. + +## In scope + +- Replace temporary-artifact terminology with concrete examples and remove repeated descriptions. +- Preserve the scheduled toggle, manual cleanup action, disabled reasons, confirmation, and quarantine behavior. +- Remove the message compaction width cap and keep responsive control wrapping. +- Update desktop/phone tests and translations in the same work order. + +## Out of scope + +- New retention algorithms, provider eligibility, persistence, or API payloads. +- Unrelated settings-page migration, release flags, and task delegation. + +## Acceptance + +- Temporary-file copy explains registered ownership, supported examples, inactivity, stale age, quarantine, and exclusions without repetition. +- Compaction fills the content row while analysis, backup preparation, saved activation, and manual action remain functional. +- Phone help uses a real Drawer with touch targets; both cards remain contained and usable on desktop and phone. + +## ASCII UI preview + +See the [combined previews](plan.md#ascii-ui-preview). + +### UI-01: Header tabs, desktop + +Entry: Settings > Data & Logs or Settings > Storage. State: selected tab, loaded content. +Before: the header has no tabs; Data & Logs stacks Database, Office retention, compaction, backups, and Logs. +After: + +```text +Data & Logs [ Database | Logs ] +Database statistics, backups, and server logs. +------------------------------------------------------------ +[ Database status and actions ] +[ Message compaction: full content width ] +[ Backups ] + +Storage [ Host | Office retention ] +Manage disk usage and history retention. +------------------------------------------------------------ +[ Content of the selected tab ] +``` + +Tabs belong inside the existing title/description header. There is no second content tab row. +Header and content use the existing page scroll owner; neither gains a new fixed position. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.1/.3/.6 and AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.1/.2. + +### UI-04: Temporary files and help + +Entry: Storage > Host. State: cleanup available, automatic cleanup off. + +```text +Temporary Kandev files +Diagnostic bundles and utility working folders. +Clean inactive temporary files (i) [OFF] +Inactive for at least 24 hours. Files move to quarantine. +[ Clean temporary files ] +``` + +Desktop help opens on hover or focus. Phone tap opens the shared inset Drawer: + +```text ++--------------------------------+ +| About temporary Kandev files | +| Registered diagnostic bundles | +| and utility working folders. | +| Shared temporary folders and | +| unrelated caches are excluded. | +| [Close] | ++--------------------------------+ +``` + +The Drawer owns its temporary scroll area only when content exceeds available height. +Its close action returns focus to the information icon. Existing cleanup confirmation and disabled reasons remain intact. +Maps to AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.2/.5. + +## Verification + +Run from the repository root. New test paths below are implementation deliverables. +Use TDD for changed logic. Run focused failing tests before implementation, then the complete block after changes. +The E2E runner rebuilds current sources; do not pass `--no-build` for changed UI. + +```bash +(cd apps/web && pnpm exec vitest run components/settings/system/storage/storage-policy-card.test.tsx components/settings/system/tool-payload-retention-card.test.tsx) +(cd apps/web && pnpm e2e:run --project chromium e2e/tests/system/storage-temporary-folders.spec.ts e2e/tests/system/tool-payload-retention.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-storage-temporary-folders.spec.ts e2e/tests/system/mobile-tool-payload-retention.spec.ts) +(cd apps/web && pnpm exec eslint components/settings/system/storage/storage-policy-card.tsx components/settings/system/tool-payload-retention-card.tsx e2e/tests/system/*temporary-folders.spec.ts e2e/tests/system/*tool-payload-retention.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +git diff --check +``` + +## Files likely touched + +- apps/web/components/settings/system/storage/storage-policy-card.tsx and test +- apps/web/components/settings/system/tool-payload-retention-card.tsx and test +- Desktop/mobile temporary-folder and tool-payload E2E files named in Verification +- apps/web/src/locales/*/system.json + +## Dependencies + +02-retention-policy-status + +## Risks + +Renaming copy must not change provider IDs or broaden cleanup eligibility. Width checks must measure the card against its content parent. + +## Parallelism + +`sequential` + +## Inputs + +- [Header-tab requirements](../../specs/ui/requirements/settings-header-tabs.md) and [design](../../specs/ui/system-design/settings-header-tabs.md). +- [Data/storage requirements](../../specs/system-page/requirements/system-data-storage-pages.md) and [design](../../specs/system-page/system-design/system-data-storage-pages.md). +- Existing SystemPageShell, SettingsPageHeader, settings save tests, and SleepInhibitionInfoTooltip touch pattern. + +## Results + +Updated temporary-file copy to explain registered ownership, supported examples, +inactivity, quarantine, and exclusions. Removed the compaction card width cap +while preserving its analysis, backup, activation, and cleanup behavior. The +mobile help path uses the shared touch Drawer and existing cleanup controls keep +their permissions and confirmation behavior. + +Validation passed with storage and compaction unit tests, desktop and mobile +temporary-folder and compaction E2E, the broader storage-maintenance suites, +frontend typecheck and lint, and i18n checks. diff --git a/docs/plans/settings-storage-tabs/task-04-operations-documentation.md b/docs/plans/settings-storage-tabs/task-04-operations-documentation.md new file mode 100644 index 0000000000..ace1681e62 --- /dev/null +++ b/docs/plans/settings-storage-tabs/task-04-operations-documentation.md @@ -0,0 +1,93 @@ +--- +id: "04-operations-documentation" +title: "Document the settings destinations" +status: done +wave: 4 +depends_on: ["03-temporary-files-compaction"] +plan: "plan.md" +requirements: + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-002 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-004 +acceptance_criteria: + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.4 + - AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.5 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md + - ../../specs/system-page/system-design/system-data-storage-pages.md +--- + +# Task 04: Document the settings destinations + +## Summary + +Update public maintenance instructions after the UI work passes. Synchronize the specification lifecycle with the implemented behavior. + +## In scope + +- Update operations navigation for Database, Logs, Host, and Office retention. +- Explain Temporary Kandev files with the same eligibility and quarantine wording as the UI. +- Audit authentication docs, README, and screenshot catalog for affected paths and captions. +- Resolve planned presentation notes in Office and tool-payload designs when the new allocation ships. +- Record work-order results and promote matching draft designs to current only after their checks pass. + +## Out of scope + +- New retention algorithms, provider eligibility, persistence, or API payloads. +- Unrelated settings-page migration, release flags, and task delegation. + +## Acceptance + +- Public operations instructions lead to the implemented tabs and use the final visible labels. +- Documentation preserves permissions, cleanup eligibility, database space-reuse explanations, and backend retention semantics. +- All work-order results and specification statuses accurately represent actual implementation and verification. + +## Verification + +Run from the repository root. New test paths below are implementation deliverables. +Use TDD for changed logic. Run focused failing tests before implementation, then the complete block after changes. +The E2E runner rebuilds current sources; do not pass `--no-build` for changed UI. + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check -- docs +``` + +## Files likely touched + +- docs/public/operations.md and authentication.md if navigation wording changes +- README.md and docs/screenshots.md only where the audit finds stale content +- Owning requirements/designs in this package and the linked Office/tool-payload presentation notes +- docs/plans/settings-storage-tabs/plan.md and work-order Results sections + +## Dependencies + +03-temporary-files-compaction + +## Risks + +Public documentation must describe shipped behavior. Existing historical test counts must not be presented as evidence for this package. + +## Parallelism + +`sequential` + +## Inputs + +- [Header-tab requirements](../../specs/ui/requirements/settings-header-tabs.md) and [design](../../specs/ui/system-design/settings-header-tabs.md). +- [Data/storage requirements](../../specs/system-page/requirements/system-data-storage-pages.md) and [design](../../specs/system-page/system-design/system-data-storage-pages.md). +- Existing SystemPageShell, SettingsPageHeader, settings save tests, and SleepInhibitionInfoTooltip touch pattern. + +## Results + +Updated public operations and authentication documentation with the Database, +Logs, Host, and Office retention destinations, plus the temporary-file and +retention semantics. Updated the linked requirements and system designs to +reflect the implemented tab allocation and presentation contract. + +Validation passed with public-doc tests and validation, the specification +catalog and linter, i18n checks, and documentation diff validation. diff --git a/docs/plans/settings-tab-visual-states/plan.md b/docs/plans/settings-tab-visual-states/plan.md new file mode 100644 index 0000000000..175635c3fc --- /dev/null +++ b/docs/plans/settings-tab-visual-states/plan.md @@ -0,0 +1,123 @@ +--- +created: 2026-09-15 +status: completed +requirements: + - REQ-UI-SETTINGS-HEADER-TABS-001 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md +legacy_specs: [] +--- + +# Settings Tab Visual States + +## Overview + +Strengthen visible selection and refine default, hover, and focus states on shared settings tabs. +The user's screenshot shows a flat track with little selected-state distinction. +Rendered checks confirm the missing hover surface. Compiled CSS confirms that the existing active selector works but uses a faint translucent dark fill. +This repair satisfies the existing shared selected/focus treatment contract. No new requirement is necessary. + +## Scope + +### In scope + +- Shared settings tab track, trigger surfaces, and explicit active-state overrides. +- Desktop and phone geometry, light/dark themes, keyboard focus, and reduced motion. + +### Out of scope + +- Global tab primitive migration, icons, new tab labels, navigation changes, or retention behavior. + +## Technical approach + +`apps/packages/ui/src/tabs.tsx` uses a 30% opaque dark selected fill and no inactive hover background. +Compiled Tailwind expands `data-active` to match Radix, contrary to the initial source-only hypothesis. +`SettingsTabsList` owns the stronger surface treatment and overrides the inherited horizontal track height. +Implement the correct surface classes in `apps/web/components/settings/settings-tabs.tsx`. +Use existing theme tokens and preserve manual keyboard activation and mounted panel state. +The system design defines the visual treatment and scope boundary. + +## ASCII UI preview + +### UI-01: Desktop header, Office retention selected + +```text +Storage +--------------------------------+ +Manage disk use and history. | Host +---------------------+ | + | | Office retention | | + | +---------------------+ | + +--------------------------------+ +-------------------------------------------------------------------- +``` + +The outer border is quiet. The inner selected segment has a stronger surface, border, and small shadow. +Inactive hover receives a light fill. Keyboard focus has its own ring, including when focus is on Host. +The sketch indicates hierarchy, not literal nested boxes or exact pixel spacing. + +### UI-02: Phone header, Office retention selected + +```text +Storage +Manage disk use and history. + ++--------------------------------+ +| Host +---------------------+ | +| | Office retention | | +| +---------------------+ | ++--------------------------------+ +---------------------------------- +``` + +Tabs stay below the description. Touch targets remain at least 44px, with the track inset outside them. +The existing page remains the vertical scroll owner. There is no new sticky region or overlay. + +### UI-03: State comparison + +```text +Default: readable muted label, transparent segment +Hover: stronger label, subtle segment fill +Selected: foreground label, raised surface, border and shadow +Focus: visible ring on either default or selected segment +``` + +The selected treatment must remain identifiable without relying on text color alone. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.1 through .6. + +## Tests + +Extend `settings-tabs.test.tsx` for active-state changes and keyboard focus separate from selection. +Computed appearance is a browser contract; class-string assertions alone cannot prove the repair. + +## E2E tests + +In `settings-header-tabs.spec.ts`, assert that active and inactive segments have different computed surfaces. +Assert that hover does not erase selected distinction and keyboard focus remains visible before activation. +Cover both consumers and light/dark themes, with focused screenshots for visual comparison. +In `mobile-settings-header-tabs.spec.ts`, assert phone placement, 44px targets, and unclipped track/trigger geometry. +Include the existing 767/768 checks and coarse-pointer tablet case. +Map these cases to AC-UI-SETTINGS-HEADER-TABS-001.1-.6. + +## Work orders + +- [x] [Task 01: Refine shared tab states](task-01-shared-tab-states.md) + +## Verification results + +Implemented in the shared settings component. Selected tabs use an opaque theme surface, +border, and shadow; inactive hover and keyboard focus have separate treatments. +The track overrides the primitive horizontal height so 44px touch targets remain inset. + +- RED: dark selected surface alpha was 77 instead of 255; inactive hover remained transparent. +- RED: mobile containment detected the inherited 32px track clipping 44px controls. +- GREEN: component tests 2/2; desktop browser tests 9/9; mobile browser tests 5/5. +- Targeted ESLint, TypeScript typecheck, specification validators, and diff whitespace check passed. +- Browser coverage includes both pages and themes, keyboard activation, reduced motion, + stable widths, 28px desktop controls, and 44px touch targets at 390/767/768/1024px. +- Inspected desktop light/dark focus screenshots and the 390px phone screenshot. +- Public documentation does not need an update: labels, navigation, and operations are unchanged. + +## Risks + +- Other consumers retain their existing primitive styling; this package limits implementation to settings tabs. +- Track padding can clip 44px targets or focus rings when its height is fixed. +- Theme backgrounds can coincide even with correct selectors. Browser checks must inspect the actual rendered surface. diff --git a/docs/plans/settings-tab-visual-states/task-01-shared-tab-states.md b/docs/plans/settings-tab-visual-states/task-01-shared-tab-states.md new file mode 100644 index 0000000000..cbd69347b6 --- /dev/null +++ b/docs/plans/settings-tab-visual-states/task-01-shared-tab-states.md @@ -0,0 +1,150 @@ +--- +id: "01-shared-tab-states" +title: "Refine shared settings tab states" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-HEADER-TABS-001 +acceptance_criteria: + - AC-UI-SETTINGS-HEADER-TABS-001.1 + - AC-UI-SETTINGS-HEADER-TABS-001.2 + - AC-UI-SETTINGS-HEADER-TABS-001.3 + - AC-UI-SETTINGS-HEADER-TABS-001.4 + - AC-UI-SETTINGS-HEADER-TABS-001.5 + - AC-UI-SETTINGS-HEADER-TABS-001.6 +system_design: + - ../../specs/ui/system-design/settings-header-tabs.md +--- + +# Task 01: Refine Shared Settings Tab States + +## Summary + +Give settings tabs a distinct selected surface and consistent default, hover, and focus states. +Override the faint primitive treatment at the shared settings boundary. + +## In scope + +- Track border/inset, selected surface/border/shadow, default/hover colors, and focus ring. +- Reduced-motion transition behavior and unclipped responsive target geometry. +- Focused component and browser regressions for both settings pages. + +## Out of scope + +- Changes to the global UI primitive or unrelated settings behavior. + +## Acceptance + +1. Selection visibly changes surface and border in both themes, using the actual Radix state attribute. +2. Default, hover, selected, and keyboard focus remain distinct with stable segment widths. +3. Desktop targets measure 28px within 1px; phone/coarse targets measure at least 44px without clipping or document overflow. + +## ASCII UI preview + +See the [combined preview](plan.md#ascii-ui-preview). + +### UI-01: Desktop header, Office retention selected + +```text +Storage +--------------------------------+ +Manage disk use and history. | Host +---------------------+ | + | | Office retention | | + | +---------------------+ | + +--------------------------------+ +-------------------------------------------------------------------- +``` + +The outer border is quiet. The inner selected segment has a stronger surface, border, and small shadow. +Inactive hover receives a light fill. Keyboard focus has its own ring, including when focus is on Host. +The sketch indicates hierarchy, not literal nested boxes or exact pixel spacing. + +### UI-02: Phone header, Office retention selected + +```text +Storage +Manage disk use and history. + ++--------------------------------+ +| Host +---------------------+ | +| | Office retention | | +| +---------------------+ | ++--------------------------------+ +---------------------------------- +``` + +Tabs stay below the description. Touch targets remain at least 44px, with the track inset outside them. +The existing page remains the vertical scroll owner. There is no new sticky region or overlay. + +### UI-03: State comparison + +```text +Default: readable muted label, transparent segment +Hover: stronger label, subtle segment fill +Selected: foreground label, raised surface, border and shadow +Focus: visible ring on either default or selected segment +``` + +The selected treatment must remain identifiable without relying on text color alone. +Maps to AC-UI-SETTINGS-HEADER-TABS-001.1 through .6. + +## Verification + +Use TDD for state behavior and a rendered regression for the broken selected surface. +Confirm the browser assertion fails before the correction because the selected surface is translucent and the hover fill is missing. +Run from the repository root. Install dependencies once if this worktree lacks them. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run components/settings/settings-tabs.test.tsx) +(cd apps/web && pnpm exec eslint components/settings/settings-tabs.tsx components/settings/settings-tabs.test.tsx e2e/tests/system/settings-header-tabs.spec.ts e2e/tests/system/mobile-settings-header-tabs.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm e2e:run --project chromium e2e/tests/system/settings-header-tabs.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-settings-header-tabs.spec.ts) +git diff --check +``` + +The managed E2E runner builds current sources. Do not pass `--no-build` or override workers. +Record desktop light/dark and phone screenshots alongside computed-style and geometry assertions. +No copy changes are expected. If copy changes, include all locales and run the repository i18n gates. + +## Files likely touched + +- `apps/web/components/settings/settings-tabs.tsx` +- `apps/web/components/settings/settings-tabs.test.tsx` +- `apps/web/e2e/tests/system/settings-header-tabs.spec.ts` +- `apps/web/e2e/tests/system/mobile-settings-header-tabs.spec.ts` + +## Dependencies + +Existing shared SettingsTabs implementation. No additional work order. + +## Risks + +Theme colors can coincide. Track overflow can clip inset borders, focus rings, or touch targets. + +## Parallelism + +`sequential` + +## Inputs + +- [Header requirements](../../specs/ui/requirements/settings-header-tabs.md). +- [Header design](../../specs/ui/system-design/settings-header-tabs.md), visual-state refinement. +- Existing settings tab tests and shared control-sizing contract. + +## Results + +Implemented in the shared settings component. Selected tabs use an opaque theme surface, +border, and shadow; inactive hover and keyboard focus have separate treatments. +The track overrides the primitive horizontal height so 44px touch targets remain inset. + +- RED: dark selected surface alpha was 77 instead of 255; inactive hover remained transparent. +- RED: mobile containment detected the inherited 32px track clipping 44px controls. +- GREEN: component tests 2/2; desktop browser tests 9/9; mobile browser tests 5/5. +- Targeted ESLint, TypeScript typecheck, specification validators, and diff whitespace check passed. +- Browser coverage includes both pages and themes, keyboard activation, reduced motion, + stable widths, 28px desktop controls, and 44px touch targets at 390/767/768/1024px. +- Inspected desktop light/dark focus screenshots and the 390px phone screenshot. +- Public documentation does not need an update: labels, navigation, and operations are unchanged. diff --git a/docs/plans/storage-temp-artifacts/plan.md b/docs/plans/storage-temp-artifacts/plan.md index 1daa0aa3dc..7272c016b1 100644 --- a/docs/plans/storage-temp-artifacts/plan.md +++ b/docs/plans/storage-temp-artifacts/plan.md @@ -183,3 +183,8 @@ Wave 4 (desktop/mobile E2E after Task 03): Wave 5 (docs after behavior is implemented): - [x] [Task 05 — Temporary-artifact operations documentation](task-05-storage-temp-artifact-docs.md) + +## Follow-up presentation package + +The [settings storage tabs package](../settings-storage-tabs/plan.md) owns the planned header tabs and maintenance presentation changes. +This package retains its historical implementation results. The follow-up owns new route, copy, and regression verification. diff --git a/docs/plans/system-data-storage-page-split/plan.md b/docs/plans/system-data-storage-page-split/plan.md index 6c98d32b1d..de824a60a0 100644 --- a/docs/plans/system-data-storage-page-split/plan.md +++ b/docs/plans/system-data-storage-page-split/plan.md @@ -123,3 +123,8 @@ The focused remediation test passed. be recaptured after the route split. - Discovery parent changes can break exact-target navigation if hrefs and parent identities do not change together. + +## Follow-up presentation package + +The [settings storage tabs package](../settings-storage-tabs/plan.md) owns the planned header tabs and maintenance presentation changes. +This package retains its historical implementation results. The follow-up owns new route, copy, and regression verification. diff --git a/docs/plans/tool-payload-retention/plan.md b/docs/plans/tool-payload-retention/plan.md index 1e942ebc8e..ec76e34c18 100644 --- a/docs/plans/tool-payload-retention/plan.md +++ b/docs/plans/tool-payload-retention/plan.md @@ -65,8 +65,8 @@ are illustrative. Product copy must use localization keys. ### UI-01: Desktop, disabled policy with optional estimate -Entry: Settings > System > Data & Logs. The existing Database section precedes -the retention area. Office history retains its independent card. +Entry: Settings > System > Data & Logs > Database. Office history is maintained +independently at Settings > System > Storage > Office retention. ```text Tool payload cleanup [Off] @@ -339,3 +339,8 @@ estimates, and errors remain visible outside collapsed details. Validation: focused component/hook tests, desktop backup/skip flows, and phone flows at 320px and 390px, including disclosure and control geometry checks. User requested no commit. + +## Follow-up presentation package + +The [settings storage tabs package](../settings-storage-tabs/plan.md) completed the header tabs and maintenance presentation changes. +This package retains its historical implementation results. Its route and copy references are current. diff --git a/docs/public/authentication.md b/docs/public/authentication.md index 32da6c3515..b797a56253 100644 --- a/docs/public/authentication.md +++ b/docs/public/authentication.md @@ -56,7 +56,7 @@ A server that listens on non-loopback interfaces with authentication disabled lo Roles are `admin`, `member`, and `guest`. With Organizations off, an admin manages users and system settings. With Organizations on, membership roles are associated with an organization and the instance operator tier is separate, but the current Users API does not consistently enforce the organization boundary; see [Limitations](#limitations). A guest reaches only explicitly shared workspaces. The current Users page changes accounts between admin and member; assign the API-only guest role as described in [Team Access](team-access.md#current-limits). -With Organizations off, install-wide data in `Settings > System > Data & Logs` and `Settings > System > Storage` follows the same admin/member split. Members can read the database stats, the backup listing, and the storage usage, policy, run history, and quarantine contents. Creating, downloading, restoring, and deleting a backup is admin only, because a backup is a copy of the whole database and downloading one would export every user's workspaces. Changing storage settings, adopting a Go cache, and running an analysis, cleanup, or quarantine restore/purge are admin only for the same reason database vacuum, optimize, and reset are: they act on the whole install. With Organizations enabled, org admins currently retain these install-wide capabilities; they are not yet restricted to the instance operator. +With Organizations off, install-wide data in `Settings > System > Data & Logs` and `Settings > System > Storage` follows the same admin/member split. Members can read the Database and Logs tabs, the backup listing, and the Host and Office retention tabs. Creating, downloading, restoring, and deleting a backup is admin only, because a backup is a copy of the whole database and downloading one would export every user's workspaces. Changing storage settings, adopting a Go cache, and running an analysis, cleanup, or quarantine restore/purge are admin only for the same reason database vacuum, optimize, and reset are: they act on the whole install. With Organizations enabled, org admins currently retain these install-wide capabilities; they are not yet restricted to the instance operator. ## Personal access tokens diff --git a/docs/public/operations.md b/docs/public/operations.md index 317a6772d9..d2ea1b042e 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -179,7 +179,7 @@ To recover capacity in one session, expand its queue chip in the task workbench. Database snapshots do not contain Git worktrees, clones, the master key, service metadata, or provider-side objects. Native agent and `gh` login files also normally live in the service user's home outside `~/.kandev` (for example `~/.codex` and `~/.config/gh`). The official container instead sets `HOME=/data/home`, so those CLI credentials live on its mounted volume. -The System Database and Backups pages use the configured SQLite file path. They use `backups/` under the parent directory of that file. The default remains `/data/kandev.db` with snapshots in `/data/backups/`. A custom path can place the database and snapshots outside the Kandev home. Kandev does not move snapshots from another directory automatically. +The **Data & Logs > Database** tab and its Backups section use the configured SQLite file path. They use `backups/` under the parent directory of that file. The default remains `/data/kandev.db` with snapshots in `/data/backups/`. A custom path can place the database and snapshots outside the Kandev home. Kandev does not move snapshots from another directory automatically. ### Canvas artifacts and recovery @@ -211,8 +211,8 @@ or cleanup jobs still exist.
Storage maintenance details -Open **Settings > System > Storage** to inspect Kandev-managed disk usage and -configure cleanup. +Open **Settings > System > Storage > Host** to inspect Kandev-managed disk usage and +configure cleanup. Open **Storage > Office retention** for Office history policy and status. **Analyze** is read-only. **Run now** applies only the enabled cleanup rules and refuses to start while another maintenance run owns the cleanup gate. If task resources are active, the page names the active work and offers **Run anyway** after an explicit disruption warning. Use that override @@ -310,14 +310,14 @@ Resolved roots, measured size, and partial or unavailable status are shown. This informational and can overlap counted categories, so it is excluded from **Total counted**. It has no cleanup action and does not claim ownership of any path. -The page separately reports **Kandev temporary artifacts** created by services that need a short-lived -directory under the host temporary root. Each current artifact is registered in the Kandev database -and carries an owner-only marker in its exact directory. Active artifacts and artifacts created -within the last 24 hours are protected. The **Clean stale Kandev temporary artifacts** policy is off +The Host tab separately reports **Temporary Kandev files** created by services that need a short-lived +directory under the host temporary root. Each current file is registered in the Kandev database +and carries an owner-only marker in its exact directory. Active files and files created within the +last 24 hours are protected. The **Clean inactive Kandev temporary files** policy is off by default. When enabled, scheduled maintenance and a full **Run now** may move eligible, registered -artifacts into Kandev quarantine. Kandev uses a same-filesystem rename when possible. If the +files into Kandev quarantine. Kandev uses a same-filesystem rename when possible. If the quarantine is on another filesystem, it stages and verifies a copy before publishing it and removes -the original only after revalidating its identity. An explicit **Clean stale artifacts** action is +the original only after revalidating its identity. An explicit **Clean inactive files** action is available even when the policy is off. Quarantine entries can be restored during the configured retention period; this action does not permanently delete them or immediately free disk space. The action does not inspect or claim arbitrary `/tmp` entries, shared caches, Node or Playwright @@ -350,7 +350,7 @@ the Kandev service first provides the clearest maintenance boundary. ## Office run history retention -Open **Settings > System > Data & Logs** to manage automatic deletion of old +Open **Settings > System > Storage > Office retention** to manage automatic deletion of old Office run history. Deletion is enabled by default. The first sweep starts five minutes after the backend starts or after you enable deletion. @@ -360,7 +360,7 @@ Deletion is permanent. Back up the database before you enable deletion if you need to keep old history outside the configured window. The retention window controls the age of rows that can be deleted. The minimum -kept per owner control keeps the newest rows for each routine or agent, even +per routine and minimum per agent profile controls keep the newest rows for each owner, even when those rows are older than the window. A floor of zero removes this extra protection. Run event, route attempt, and skill rows are deleted with their parent run. @@ -371,15 +371,15 @@ is disabled, so you can monitor growth before you enable it again. To disable automatic deletion: -1. Open **Settings > System > Data & Logs**. -2. Clear **Delete eligible run history**. +1. Open **Settings > System > Storage > Office retention**. +2. Turn off **Automatic cleanup**. 3. Select **Save changes**. 4. Check the retention status. It must show that deletion is disabled. ## Messages compaction -Open **Settings > System > Data & Logs** to reduce old tool details in a SQLite -database. **Messages compaction** is independent of Office run history retention +Open **Settings > System > Data & Logs > Database** to reduce old tool details in a SQLite +database. **Message compaction** is independent of Office run history retention and is disabled by default. The initial period is three calendar months. 1. Set **Tasks inactive for** to whole weeks or calendar months. @@ -445,7 +445,7 @@ explicit path skips legacy discovery. When both defaults contain task history, Kandev keeps the current default and does not merge the databases. Use a verified snapshot or a later deliberate recovery procedure to reconcile them. -Open **Settings > System > Database** to see database size, WAL size, schema version, path, and the newest modification time among regular entries in the sibling `backups/` directory. That timestamp is a filesystem hint, not proof of a valid snapshot: an unrelated or temporary file in the directory can affect it. SQLite exposes three maintenance actions: +Open **Settings > System > Data & Logs > Database** to see database size, WAL size, schema version, path, and the newest modification time among regular entries in the sibling `backups/` directory. That timestamp is a filesystem hint, not proof of a valid snapshot: an unrelated or temporary file in the directory can affect it. SQLite exposes three maintenance actions: - **Optimize** runs `PRAGMA optimize`. It is quick and updates planner statistics. - **Vacuum** runs `VACUUM`, compacts the file, and reports bytes reclaimed. It can need substantial temporary disk and can block writes, so run it during a quiet period. diff --git a/docs/specs/office/system-design/run-history-retention-operations.md b/docs/specs/office/system-design/run-history-retention-operations.md index f4195860f7..3e3a8a5c33 100644 --- a/docs/specs/office/system-design/run-history-retention-operations.md +++ b/docs/specs/office/system-design/run-history-retention-operations.md @@ -8,6 +8,13 @@ requirements: # Office Run History Retention Operations System Design +## Presentation allocation + +The [settings storage tabs design](../../system-page/system-design/system-data-storage-pages.md) +allocates the shipped page composition. Office retention lives at Storage > Office retention. +Message compaction remains in Data & Logs > Database. +Policy, API, and persistence contracts remain unchanged. + ## Purpose and boundaries This design covers the operator half of run history retention: the settings @@ -207,12 +214,10 @@ Three surfaces, in descending durability: 2. **Health issues.** The package implements `health.Checker` with `Name() = "Office run retention"` and `Category() = "office"`, returning a `health.Issue` per active condition with - `FixURL = "/settings/system/data-storage"`, which is the live route - registered in `apps/web/src/settings-routes.tsx` — note the suffix, as - `/settings/system/data` is not a registered path and `/settings/system/database` - is only a redirect to it. `internal/health/checks_test.go` pins fix URLs to - live routes ("expectedGitHubFixURL is the live route in ..."), and this one - is pinned the same way. This is the production-visible surface + `FixURL = "/settings/system/storage?tab=office-retention"`, which is the + Office retention tab of the live route registered in + `apps/web/src/settings-routes.tsx`. `internal/health/checks_test.go` pins fix + URLs to live routes, and this one is pinned the same way. This is the production-visible surface required by AC-OFFICE-RUN-HISTORY-RETENTION-003.8 and is why the debug metrics endpoint is not it: `/debug/vars` is gated on the dev profile. Issue ids are stable and one per condition: @@ -260,11 +265,11 @@ are indistinguishable. Successful writes return the normalized document. Both routes are admin-scoped like the other System routes and are readable while retention is disabled (AC-OFFICE-RUN-HISTORY-RETENTION-004.8). -One card on **Settings > System > Data & Logs**, the page served at -`/settings/system/data-storage` and rendered by -`apps/web/components/settings/system/data-logs-settings.tsx`, beside -`database-stats-card.tsx`: the enable toggle, the numeric fields, and the -last-sweep readout. It follows the storage-maintenance cards' shape. All new +One card on **Settings > System > Storage > Office retention**, the page served at +`/settings/system/storage?tab=office-retention` and rendered by +`apps/web/components/settings/system/retention-settings-card.tsx` with the +status card beside it: the enable toggle, numeric fields, and last-sweep readout. +It follows the storage-maintenance cards' shape. All new copy goes through `t()` and must ship in `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw`; `pnpm run i18n:check` and the new-code ratchet gate the build. Health issue titles and messages stay English, matching every other backend-produced diff --git a/docs/specs/system-page/requirements/system-data-storage-pages.md b/docs/specs/system-page/requirements/system-data-storage-pages.md index 07412e7994..7489a37d46 100644 --- a/docs/specs/system-page/requirements/system-data-storage-pages.md +++ b/docs/specs/system-page/requirements/system-data-storage-pages.md @@ -22,7 +22,7 @@ those features remains unchanged. - **Data & Logs:** The page for database status, backups, and diagnostic logs. - **Storage:** The page for disk analysis, cleanup policy, run history, and - quarantine management. + quarantine management, plus Office history retention. ## Requirements @@ -41,7 +41,7 @@ so that I can reach the required maintenance task quickly. - **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-001.2:** `Data & Logs` shall use `/settings/system/data-storage` and show Database, Backups, and Logs. - **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-001.3:** `Data & Logs` shall not show - storage analysis, cleanup, policy, history, or quarantine controls. + host storage analysis, cleanup, policy, maintenance history, or quarantine controls. - **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-001.4:** `Storage` shall use `/settings/system/storage` and show the complete storage maintenance flow. - **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-001.5:** Database, Backups, and Logs @@ -57,9 +57,54 @@ so that I can reach the required maintenance task quickly. storage policy save protection, role gates, loading states, error states, and maintenance actions. +### REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-002: Header tabs and destination compatibility + +**Intent:** Operators can select related maintenance content from the page header. + +#### Acceptance criteria + +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.1:** Data & Logs shall offer Database and Logs tabs. Database shall contain database status/actions, message compaction, and backups in that order. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.2:** Storage shall offer Host and Office retention tabs. Host shall retain the complete existing host-maintenance flow. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.3:** Office retention shall contain retention status followed by its policy. Data & Logs shall no longer contain Office retention. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.4:** Search, legacy links, and retention health links shall open the owning tab and target. Existing canonical page paths shall remain valid. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-002.5:** Both pages shall use the shared header-tab interaction. Role gates, save protection, loading/error states, and existing maintenance actions shall remain effective. + +### REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003: Understandable maintenance controls + +**Intent:** Operators can choose retention settings without database-schema knowledge. + +#### Acceptance criteria + +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.1:** Retention policy shall use Routine history and Agent run history labels. Minimum-kept fields shall identify their routine or agent-profile scope. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.2:** Field explanations shall use focusable hover information icons with tap-open help drawers on touch. Deletion consequences and active-work protection shall remain visible. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.3:** Inputs shall use 28px desktop height and at least 44px touch targets. Routine/agent retention windows and minimums shall remain in the main policy view. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.4:** Advanced settings shall contain cleanup interval, batch limit, and warning thresholds. The event threshold shall explicitly apply only to run events. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.5:** Temporary Kandev files shall identify diagnostic bundles and utility working folders. Copy shall explain inactivity, the 24-hour stale interval, quarantine, and shared-folder exclusions without repeated descriptions. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-003.6:** The message compaction card shall fill its content row on desktop and phone. Existing analysis, preparation, save, and compaction behavior shall remain available. + +### REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-004: Accurate retention summary + +**Intent:** Operators can assess stored history and the last cleanup without reading database names. + +#### Acceptance criteria + +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.1:** Status shall show the saved automatic-cleanup state and last known cleanup outcome. An absent result shall say no cleanup is recorded since server startup. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.2:** Stored counts shall distinguish zero, unavailable, and stale measurements for routine history, agent run history, and events. Counts shall not imply deletion eligibility. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.3:** Status shall distinguish per-category preview, committed deletion, backlog, and error outcomes. Mixed preview/deletion outcomes shall not imply that no deletion occurred. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.4:** Warning thresholds shall use saved settings and retain disabled-threshold behavior. A common timestamp shall appear only when all displayed count timestamps match. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.5:** Expandable cleanup details shall retain provider attempts, run skill records, unknown statuses, routine attribution, and skip information. Errors and stale-data notices shall remain visible when details collapse. +- **AC-SYSTEM-PAGE-DATA-STORAGE-PAGES-004.6:** Desktop shall align count summaries for comparison. Phone shall show a single-column summary with the same data and actions. + +## Planning status + +The added tab and presentation criteria describe the implemented 2026-09-15 +settings package. The two-page split and its tab allocation are current. +The [shared header contract](../../ui/requirements/settings-header-tabs.md) owns reusable tab interactions. +Office retains ownership of [retention semantics](../../office/requirements/run-history-retention-operations.md). + ## Out of scope - New backend APIs or storage-maintenance behavior. - Separate pages for Database, Backups, and Logs. -- A new tab, drawer, or accordion navigation model. +- New nested navigation outside the shared page-header tabs. - A new canonical path for `Data & Logs`. diff --git a/docs/specs/system-page/system-design/system-data-storage-pages.md b/docs/specs/system-page/system-design/system-data-storage-pages.md index 246605d3c8..be1697f14e 100644 --- a/docs/specs/system-page/system-design/system-data-storage-pages.md +++ b/docs/specs/system-page/system-design/system-data-storage-pages.md @@ -3,124 +3,182 @@ status: current system: system-page requirements: - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-001 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-002 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003 + - REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-004 --- # System Data and Storage Pages System Design ## Purpose and boundaries -The system-page system owns these routes and their operational content. This -design changes frontend composition and navigation only. Existing backend APIs, -permissions, jobs, and persistence remain unchanged. - -This design supersedes the route allocation for Database, Backups, Logs, and -Storage in `system-page-01.md`. +System-page owns the composition and maintenance presentation of these two routes. +This revision describes the implemented [settings storage tabs package](../../../plans/settings-storage-tabs/plan.md). +The existing two-page split and the new tab allocation are current. +The [UI header-tab design](../../ui/system-design/settings-header-tabs.md) owns the reusable interaction. +Office owns retention policy, deletion eligibility, preview markers, and count semantics. +Those backend contracts remain unchanged. ## Requirement mapping | Requirement | Design section | | --- | --- | -| `REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-001` | [Route composition](#route-composition), [Navigation and discovery](#navigation-and-discovery), [Mobile design contract](#mobile-design-contract), [Compatibility](#compatibility) | +| REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-001 | Route composition and compatibility | +| REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-002 | Route composition and compatibility; State and permissions | +| REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-003 | Retention controls; Temporary files and compaction | +| REQ-SYSTEM-PAGE-DATA-STORAGE-PAGES-004 | Retention status | -## Route composition +## Route composition and compatibility -`apps/web/src/settings-routes.tsx` will own two static route renderers. +`src/settings-routes.tsx` retains both canonical paths. +`SystemRouteShell` and `SystemPageShell` pass an optional tabs slot to the existing `SettingsPageHeader`. +Each page owns one shared Tabs root around its header and content. +Use dedicated route compositions when the shared root must contain the shell itself. +Do not put a second page heading or tab strip inside the content. -The `/settings/system/data-storage` renderer will use `SystemRouteShell` with -the existing `Data & Logs` title. Its content component will render these -sections in order: +| Path | Query | Content order | +| --- | --- | --- | +| `/settings/system/data-storage` | `tab=database` (default) | DatabaseStatsCard, ToolPayloadRetentionCard, BackupsTable | +| `/settings/system/data-storage` | `tab=logs` | LogViewer | +| `/settings/system/storage` | `tab=host` (default) | StorageMaintenanceSettings | +| `/settings/system/storage` | `tab=office-retention` | Retention status, retention policy | -1. Database. -2. Backups. -3. Logs. +`DataLogsSettings` is the Data & Logs tab composition. +`StorageSettings` wraps existing host maintenance and Office retention. +Settings menu labels and breadcrumb paths remain unchanged. +Storage description includes host cleanup and Office history retention. -The `/settings/system/storage` renderer will use `SystemRouteShell` with the -existing Storage title and description. It will render -`StorageMaintenanceSettings` directly. This removes the duplicate Storage -section heading from the new page. +In `lib/settings-discovery/catalog/system.ts`, move the retention entry to the Storage discovery parent. +Keep stable target IDs and add tab queries to all affected entries. +Backups and compaction resolve to Database, log results to Logs, and all existing host targets to Host. +A recognized target fragment selects its owning tab before focus/highlight. +A manually selected tab removes the old target fragment so it cannot override later navigation. -The route split will not duplicate domain hooks or action handlers. -`StorageMaintenanceSettings` will remain the single owner of storage data, -draft state, and action state. +Legacy routes resolve as follows: -## Navigation and discovery +- `/settings/system/database` -> Data & Logs, Database. +- `/settings/system/backups` -> Data & Logs, Database, existing backups target. +- `/settings/system/logs` -> Data & Logs, Logs. +- Old Data & Logs retention fragments -> Storage, Office retention, same retention target. -`apps/web/lib/settings-discovery/catalog/system.ts` will export a stable href -for the Storage page. The System menu will add a direct Storage row after -`Data & Logs`. +Unrelated query parameters survive redirects. Legacy redirects use replacement navigation. +An unqualified Data & Logs link still opens Database because it cannot identify a historical retention intent. +Update `internal/office/retention/health.go` `fixURL` to +`/settings/system/storage?tab=office-retention` and update its focused tests. +Other backend behavior and health issue identities remain unchanged. -The existing `system-data-storage` discovery page identity and href will remain -stable. Database, Backups, and Logs will remain its children. A new -`system-storage` page identity will own all existing storage targets. +## State and permissions -The breadcrumb label map will resolve the `storage` segment through the same -translation key as the menu and page title. Catalog order will place Storage -after `Data & Logs` and before Feature Toggles. +The existing `SettingsSaveProvider` is keyed by pathname, not query. +Use the UI design's same-path tab replacement and retained stateful panels. +Keep contributors `system:storage-policy`, `system:retention`, and `system:tool-payload-retention` in their owning page. +Do not duplicate them or reset their dirty baselines on tab activation. +Visit panels lazily, then retain forms until route exit. The Logs panel mounts +`LogViewer` on first activation and keeps it mounted while hidden so an in-progress +diagnostic bundle can finish and download. +Existing ongoing maintenance/preparation jobs keep their current lifecycle when a form is hidden. -## Copy and localization +The floating Save changes control saves all dirty contributors on the page, including inactive panels. +Discard restores their authoritative baselines. Failed saves keep drafts and expose the existing error feedback. +Return to the affected panel without losing state. Cross-page changes retain the existing navigation guard. -The `Data & Logs` page description will no longer mention disk cleanup. The -Storage route will reuse the existing localized Storage title and description. +Retain existing API role gates and read-only member surfaces. +Office retention remains admin-scoped even beside member-readable host information. +A failed Office request must not hide the Host tab or its permitted content. -All changed copy will remain complete in English, pseudo, Portuguese, Simplified -Chinese, and both Traditional Chinese catalogs. Traditional Chinese updates -will use the repository conversion command. +## Retention controls -## Save lifecycle +Refactor `retention-settings-card.tsx` into focused policy, status, and help components under the same system directory. +Keep `useRetentionSettings`, the settings wire types, and the save contributor contract. +Use stable technical IDs for test selectors and wire mapping, independently of translated display labels. -The Storage page will mount the existing `system:storage-policy` save -contributor. The route-scoped settings coordinator will continue to show the -shared save action and navigation guard for dirty storage policy settings. +| Existing identity | Display label | Explanation | +| --- | --- | --- | +| `office_routine_runs` | Routine history | Routine activity, including skipped and coalesced executions | +| `runs` | Agent run history | Office agent runs | +| `run_events` | Run events | Timeline entries belonging to a run | +| `office_run_route_attempts` | Provider attempts | Provider/model routing attempts for a run | +| `office_run_skills` | Run skill records | Skill references associated with a run | -The `Data & Logs` page will not mount the storage contributor. Database, -backup, and log commands will continue to execute immediately. +Use Minimum per routine for `routine_runs.floor_per_owner`. +Use Minimum per agent profile for `runs.floor_per_owner`. +Keep the enable switch, retention days, and minimums visible. +Place interval, batch limit, and all three warning thresholds in Advanced settings, initially collapsed. +Keep advanced fields in the draft while collapsed. Show validation errors outside the collapse and reveal the affected field. +The run-events threshold counts events only. Dependent records have no independent retention window or floor. -Data & Logs mounts independent contributors for Office run retention -(`system:retention`) and [tool payload retention](tool-payload-retention.md) -(`system:tool-payload-retention`). Both use the shared save/discard lifecycle. -Tool-payload activation can remain in preparation after Save while its selected -backup completes. It does not change the Office retention policy. +Remove the explicit `settingsControlClassName("h-11")` override from `NumberField`. +Use the shared sizing helper without a desktop height override. +Each numeric label has a focusable information button. Help covers meaning, limits, and zero-value behavior where supported. +Reuse the `SleepInhibitionInfoTooltip` interaction in `sleep-inhibition-settings.tsx`: Tooltip on fine pointers, Drawer via `useTouchDrawer` on touch. +The drawer has a label, close action, safe-area clearance, and focus return to its opener. +Keep a short visible statement: cleanup removes old finished history and related run details, while active work remains protected. +Helper icons contain secondary explanations; field labels and deletion consequences remain understandable without opening help. + +## Retention status + +Extract a pure status view-model helper and test it with the existing `RetentionStatus` shape. +The summary uses saved `status.settings`, never the unsaved draft. +The enabled badge means automatic deletion is configured; it does not imply a healthy last cleanup. +Do not show an invented next-run time or a persisted history. Last sweep data resets after server restart. + +Render these sections in order: + +1. Automatic cleanup enabled/disabled and the last recorded cleanup result/time. +2. Three stored-count summaries with translated labels, counts, and threshold states. +3. Expandable Cleanup details with per-category deletion/preview counts, provider attempts, run skill records, skips, and attribution. + +Derive outcome from all reported table errors and both swept-table preview/backlog flags. +Any error produces a visible error outcome, while retaining committed counts from successful categories. +A preview in one category must not mask deletion in another. Show a mixed outcome when both occur. +Show backlog separately from errors and preview. Preview counts never count as deleted records. +A deletion total sums the five reported committed deletion counts and uses the unit records, never runs. + +Preserve `not_computed`, fresh, and stale states independently per count. +A missing count shows unavailable, not zero. A stale count retains its last value and timestamp with a visible stale label. +Compare counts against saved thresholds using the backend's strict greater-than rule. Zero disables that threshold. +Stale values can say the last count exceeded a threshold but cannot claim current health. +Use one Updated timestamp only when all displayed measured counts have the same timestamp; otherwise keep per-count timestamps. +State that stored records include protected work and are not a deletion estimate. + +Warnings, unknown-status notices, backlog, and errors remain visible when details collapse. +Details preserve unknown status values/counts, top routine ID/share, skip count/time, and per-category errors. +Raw diagnostic values can remain in details; normal titles and labels use product language. +No added network call, count scan, health metric, or history table is required. + +## Temporary files and compaction + +Rename the temporary-artifacts section to Temporary Kandev files through locale keys. +In `storage-policy-card.tsx`, explain registered diagnostic bundles and utility working folders. +Remove the repeated age sentence. Keep a concise visible stale-age/quarantine consequence next to the cleanup action. +Help explains that shared temporary folders and unrelated package caches are excluded from this provider. +Preserve the scheduled opt-in switch, manual action, disabled reasons, confirmations, and quarantine behavior. + +Remove `max-w-3xl` from `ToolPayloadRetentionCard` and retain `min-w-0`. +The card fills the Database content row, while controls keep compact widths and wrap on phones. +Keep existing analysis, backup preparation, automatic compaction, manual compaction, and space-reuse explanations. ## Mobile design contract -The desktop outcome and the phone outcome are the same. Both surfaces provide -direct destinations for system data and storage maintenance. +The Settings index and full-height Settings surface remain the entry point and page scroll owner. +Header tabs move below the description. They remain inline because they choose the primary page content. +Phone retention status becomes a single-column list. Policy fields stack with labels, units, and 44px touch targets. +The primary settings action remains the existing floating Save changes control with safe-area clearance. +Help uses the shipped sleep-inhibition Drawer pattern; desktop uses hover/focus Tooltip. +No second mobile state model, hidden duplicate form, nested vertical scroll area, or new sidebar is introduced. -The existing Settings index and full-height Settings sheet are the nearest -mobile examples. They provide the route list, touch targets, and direct -navigation behavior. The change does not add another overlay. +## Localization and documentation -Each destination remains an inline settings page with the existing page scroll -owner. The split reduces content depth and preserves safe-area behavior. It -does not change touch controls inside either page. +All changed labels use `t()` or `Trans` with complete English, Portuguese, and Chinese catalogs. +Use `pnpm run i18n:zh-hant` for Traditional Chinese and verify the pseudo-locale. +Use locale-aware number/date formatting. Preserve internal IDs and wire keys. +Update `docs/public/operations.md` and applicable authentication navigation references when implementation ships. +Public docs remain current during this design-only change. -Desktop and phone share route definitions, discovery data, permissions, state, -and action handlers. Playwright coverage will open both destinations from the -correct settings navigation surface. Phone coverage will also check horizontal -containment and storage save behavior. +## Related decisions and contracts -## Compatibility - -The canonical `Data & Logs` path remains `/settings/system/data-storage`. -Existing `/settings/system/database`, `/settings/system/backups`, and -`/settings/system/logs` paths will continue to redirect there. - -`/settings/system/storage` will stop redirecting and will become a rendered -page. Saved last-page behavior will accept both canonical routes because both -remain in the static route table. - -Loading and error states remain local to the component that owns each API. -Changing one route does not change API availability or admin checks. - -## Verification - -Unit tests will cover route rendering, navigation labels, breadcrumb labels, -discovery ownership, and localized copy. Playwright tests will cover desktop, -phone, authenticated member, and container-backed storage paths. - -## Related decisions - -- [Separate System Data and Storage Pages](../../../decisions/2026-09-03-separate-system-data-storage-pages.md) -- [Centralize Navigation and Namespace Plugin Destinations](../../../decisions/2026-08-04-navigation-manifest-boundaries.md) -- [Install-wide storage maintenance](../../../decisions/0045-install-wide-storage-maintenance.md) -- [Settings route save coordinator](../../../decisions/0046-settings-route-save-coordinator.md) +- [Header tabs](../../../decisions/2026-09-15-settings-header-tabs.md) +- [Separate data/storage pages](../../../decisions/2026-09-03-separate-system-data-storage-pages.md) +- [Owned temporary files](../../../decisions/2026-08-08-owned-temp-artifact-cleanup.md) +- [Office retention operations](../../office/system-design/run-history-retention-operations.md) +- [Tool payload retention](tool-payload-retention.md) diff --git a/docs/specs/system-page/system-design/tool-payload-retention.md b/docs/specs/system-page/system-design/tool-payload-retention.md index a2da866b85..4dd41c62f1 100644 --- a/docs/specs/system-page/system-design/tool-payload-retention.md +++ b/docs/specs/system-page/system-design/tool-payload-retention.md @@ -9,16 +9,23 @@ requirements: # Tool Payload Retention System Design +## Presentation allocation + +The [settings storage tabs design](../../system-page/system-design/system-data-storage-pages.md) +defines the shipped page allocation. Office retention lives at Storage > Office retention. +Message compaction remains in Data & Logs > Database. +Policy, API, and persistence contracts remain unchanged. + ## Purpose and boundaries This capability belongs to system-page. It uses task persistence to remove selected payload fields and retains the original conversation structure. The [requirements](../requirements/tool-payload-retention.md) define the outcomes. -The existing `DataLogsSettings` mounts `DatabaseStatsCard`, -`RetentionSettingsCard`, `BackupsTable`, and `LogViewer`. Its existing retention -card controls Office run history through contributor `system:retention`. -The tool payload card uses a separate policy and save contributor. +The Database tab of `DataLogsSettings` mounts `DatabaseStatsCard`, +`ToolPayloadRetentionCard`, `BackupsTable`, and `LogViewer`. Office run history +is owned by the separate Storage > Office retention tab through contributor +`system:retention`. The tool payload card uses a separate policy and save contributor. The [Data & Logs route design](system-data-storage-pages.md) describes the route split and the separate save contributors for database retention settings. diff --git a/docs/specs/ui/requirements/settings-header-tabs.md b/docs/specs/ui/requirements/settings-header-tabs.md new file mode 100644 index 0000000000..d4b1140422 --- /dev/null +++ b/docs/specs/ui/requirements/settings-header-tabs.md @@ -0,0 +1,48 @@ +--- +status: active +system: ui +created: 2026-09-15 +owners: + - kandev +--- + +# Settings Header Tabs Requirements + +## Overview + +Settings pages can group related content without separate navigation rows. +UI owns this reusable interaction independently of the first consuming pages. +The [system-page contract](../../system-page/requirements/system-data-storage-pages.md) defines those pages and their content. + +## Requirements + +### REQ-UI-SETTINGS-HEADER-TABS-001: Consistent header navigation + +**Intent:** A user can recognize and operate the same tab control across settings pages. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-HEADER-TABS-001.1:** Desktop pages shall place tabs at the right of the title and description, inside the page header above its divider. +- **AC-UI-SETTINGS-HEADER-TABS-001.2:** Below 768px, tabs shall occupy a separate row below the description within the same header. The content shall retain one page scroll owner. +- **AC-UI-SETTINGS-HEADER-TABS-001.3:** Tabs shall share typography, spacing, selected treatment, and focus treatment. Overflow shall scroll within the tab strip without document horizontal overflow. +- **AC-UI-SETTINGS-HEADER-TABS-001.4:** Users shall operate tabs with pointer, keyboard, and touch. The active tab and associated panel shall have accessible names and selection relationships. +- **AC-UI-SETTINGS-HEADER-TABS-001.5:** Fine-pointer desktop tab targets shall use the standard 28px control height. Phone and coarse-pointer targets shall measure at least 44px. +- **AC-UI-SETTINGS-HEADER-TABS-001.6:** Pages without tabs shall preserve their current headers and actions. Long translated labels shall remain readable without overlapping the title or actions. + +### REQ-UI-SETTINGS-HEADER-TABS-002: Predictable selection and drafts + +**Intent:** Tab navigation preserves both the destination and unsaved settings. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-HEADER-TABS-002.1:** A direct URL or reload shall select the named available tab. An absent or invalid selection shall select the page default. +- **AC-UI-SETTINGS-HEADER-TABS-002.2:** A tab click shall preserve unsaved edits and save/discard availability across panels. It shall neither save nor discard automatically. +- **AC-UI-SETTINGS-HEADER-TABS-002.3:** Leaving the page shall preserve the existing unsaved-change guard, including save failure and continue-editing behavior. +- **AC-UI-SETTINGS-HEADER-TABS-002.4:** A settings-search target shall reveal its owning tab before focus and highlight. Repeating the same target request shall work. +- **AC-UI-SETTINGS-HEADER-TABS-002.5:** Browser history traversal shall restore the selection encoded in the destination URL. Inactive content shall not receive keyboard focus. + +## Out of scope + +- A migration of every settings page to tabs. +- Domain data, persistence, policy, or permissions owned by consuming pages. +- New nested sidebars or a separate mobile navigation overlay. diff --git a/docs/specs/ui/system-design/settings-header-tabs.md b/docs/specs/ui/system-design/settings-header-tabs.md new file mode 100644 index 0000000000..af6f387966 --- /dev/null +++ b/docs/specs/ui/system-design/settings-header-tabs.md @@ -0,0 +1,112 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-SETTINGS-HEADER-TABS-001 + - REQ-UI-SETTINGS-HEADER-TABS-002 +--- + +# Settings Header Tabs System Design + +## Purpose and boundaries + +The existing `SettingsPageHeader` in `components/settings/settings-typography.tsx` remains the header owner. +Add an optional `tabs` slot alongside its existing `actions` slot. +Add a reusable `SettingsTabs` composition in `components/settings/settings-tabs.tsx`. +These proposed exports reuse `@kandev/ui/tabs` rather than a new tab implementation. +Domain components retain data access, drafts, and action handlers. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| REQ-UI-SETTINGS-HEADER-TABS-001 | Header and interaction | +| REQ-UI-SETTINGS-HEADER-TABS-002 | URL selection and state lifetime | + +## Header and interaction + +`SettingsTabs` supplies controlled root, list, and panel composition around the shared Radix primitives. +Its input consists of stable tab IDs, translated labels, selected ID, and a change callback. +The page places the list in `SettingsPageHeader.tabs` and panels below the header divider, within one Tabs root. +The component accepts no domain-specific tab names, network clients, or settings fields. + +Use the shared default segmented treatment: muted track, distinct selected surface, and visible keyboard focus. + +### Visual-state refinement + +The [visual-state repair package](../../../plans/settings-tab-visual-states/plan.md) refines the shared settings treatment. +The shared primitive uses a faint translucent selected surface in dark mode and no inactive hover fill. +The compiled Tailwind `data-active` variant does match Radix `data-state="active"`; selector mismatch is not the cause. +For settings tabs, explicitly style `data-[state=active]` in `SettingsTabsList` trigger classes. +Keep the repair local to the shared settings component. A global primitive change requires a separate consumer audit. + +Use a low-contrast track with a thin theme border and a 3px inset around the segments. +The selected segment has a distinct theme surface, visible border, foreground text, and a small shadow. +Inactive segments use readable muted text. Hover adds a subtle surface and stronger text without a selected border or shadow. +Keyboard focus adds a ring independent of selection, because arrow keys move focus before activation. +Use theme tokens in light and dark modes. Do not hardcode screenshot colors. +Keep text weight stable to avoid width changes. Transition color, background, border, and shadow over 150ms. +Disable these transitions for reduced motion. Do not animate position or add a moving indicator. + +Keep desktop trigger height at 28px and phone/coarse-pointer targets at least 44px. +The track includes vertical padding and borders outside those targets rather than clipping them. +Override the primitive horizontal group height with auto height so touch targets fit inside the track. +Keep overflowing tabs left-aligned and horizontally scrollable. Focus and selected borders must remain visible. +No icons, extra labels, or new routing behavior are required. +The title/description occupies the left column. Tabs occupy the right column and align with the title. +Actions, when supplied, follow the tabs within the right-side group. +Below 768px, the title/description, tabs, and optional actions form successive rows. +Allow the right-side group to wrap below the title when translated content cannot fit. +Constrain the strip width and scroll its contents horizontally. Keep the selected trigger visible. +Use 28px desktop triggers and at least 44px phone/coarse-pointer targets, including the containing track. +Do not modify global tab styles for unrelated consumers. + +Use manual Radix activation: arrows/Home/End move focus, and Enter/Space activates a tab. +All tab panels have stable IDs and accessible associations. Inactive panels are hidden and inert. +The outer settings page remains the vertical scroll owner. The header is not newly sticky. + +## URL selection and state lifetime + +Add a shared `useSettingsTab` hook under `hooks/domains/settings/`. +It reads `tab` through `useSearchParams` from `lib/routing/client-router.ts`. +The page supplies allowed IDs, a default ID, and a target-to-tab mapping. +Unknown values fall back without changing settings. No local-storage preference is added. + +Tab clicks replace the current URL query entry with `router.replace(..., { scroll: false })`. +This preserves refresh and copied links without creating one browser-history entry per tab click. +Back/forward restores the tab encoded in each page entry. Existing dirty navigation guards still apply to traversal. +Preserve unrelated query parameters. Remove a stale settings-target hash when the user explicitly chooses another tab. +A recognized target takes precedence over a conflicting tab query so the requested control can become visible. + +The hook can use `runWithNavigationBlockerBypassed` only for a validated same-origin, same-path tab replacement. +The consumer must preserve its mounted draft owners across that replacement. +Do not bypass guards for cross-page discovery, redirects, or browser traversal. +The existing `SettingsSaveProvider` is keyed by pathname, so query changes preserve its identity. +Keep stateful panels mounted after first activation and use explicit hidden/inert state when inactive. +`forceMount` alone is insufficient because it can remove the primitive's automatic hiding. +Keep their save contributors registered. Pure viewers such as Logs mount only while active. +Do not create two responsive copies of domain components. + +For search, resolve the target before mounting/revealing content. +Listen for `SETTINGS_TARGET_REQUEST_EVENT` as well as initial fragments and location updates. +Mount the destination panel, then let `SettingsTarget` registration fulfill the pending focus request. +Each target ID has one registration. Hidden panels must not consume focus requests before activation. + +## Failure and compatibility + +Missing or failed domain data remains inside its panel. Navigation remains usable. +Save errors retain drafts and existing coordinator feedback even when the failing panel is inactive. +No new backend API, settings preference, or global navigation bypass is required. + +## Mobile contract + +The shipped Settings index and full-height Settings surface provide the entry point and scroll model. +The header uses inline navigation because tabs select primary content, not temporary choices. +Phone targets follow shared control sizing and safe-area behavior from the existing settings shell. +Desktop and phone share selected tab, drafts, and handlers. +Tests cover 767px/768px, the configured phone device, coarse-pointer tablets, long labels, and keyboard focus. + +## Related decisions + +- [Settings header tabs and maintenance grouping](../../../decisions/2026-09-15-settings-header-tabs.md) +- [Settings route save coordinator](../../../decisions/0046-settings-route-save-coordinator.md)