diff --git a/agent/apps/app-onboard/src/onboarding/Onboarding.tsx b/agent/apps/app-onboard/src/onboarding/Onboarding.tsx index 1fb58d08a..a86b073d6 100644 --- a/agent/apps/app-onboard/src/onboarding/Onboarding.tsx +++ b/agent/apps/app-onboard/src/onboarding/Onboarding.tsx @@ -39,7 +39,7 @@ const initialOnboardingState: BrowserOSOnboardingState = { sources: [], } -/** Maps Chromium importer status into the local three-step onboarding screen state. */ +/** Maps Chromium importer status into the local three-step onboarding screen state. A cancelled import returns to the picker. */ export function importPhaseFor(status: BrowserOSImportStatus): ImportPhase { if (status === 'importing') return 'importing' if (status === 'failed') return 'failed' @@ -174,6 +174,8 @@ export function Onboarding() { onImport={startImport} onRefresh={() => bridge.refreshSources()} onContinue={() => goTo(2)} + onCancel={() => bridge.cancelImport()} + onOpenPasswordManager={() => bridge.openPasswordManager()} /> )} {step === 2 && } diff --git a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts index 979752c68..f19278409 100644 --- a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts +++ b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts @@ -23,6 +23,8 @@ export type BrowserOSImportStatus = | 'succeeded' | 'failed' | 'completed' + /** The user stopped the import; anything already copied stays. */ + | 'cancelled' /** Native owns extension preparation independently of the browser-data importer. */ export type BrowserOSSetupState = 'idle' | 'preparing' | 'failed' | 'ready' @@ -33,6 +35,7 @@ export const BrowserOSOnboardingMessage = { START_IMPORT: 'browserosOnboardingStartImport', COMPLETE: 'browserosOnboardingComplete', RETRY_SETUP: 'browserosOnboardingRetrySetup', + CANCEL_IMPORT: 'browserosOnboardingCancelImport', } as const export type BrowserOSOnboardingMessage = @@ -47,6 +50,10 @@ export interface BrowserOSImportSource { isManaged: boolean supportedItems: BrowserOSImportItem[] recommendedItems: BrowserOSImportItem[] + /** Browser family, e.g. 'firefox', 'safari', 'chromium'. Absent from older native builds. */ + kind?: string + /** Saved passwords are not copied from this source; the step offers the CSV export route. */ + passwordsViaCsv?: boolean } export interface BrowserOSImportProgress { @@ -68,6 +75,7 @@ export type BrowserOSImportSourceResultStatus = | 'importing' | 'succeeded' | 'failed' + | 'cancelled' export interface BrowserOSImportSourceResult { sourceId: string diff --git a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts index a82944982..0523f3154 100644 --- a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts +++ b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts @@ -27,8 +27,15 @@ export interface BrowserOSOnboardingBridge { receiveState: (state: BrowserOSOnboardingState) => void, ): () => void startImport(request: BrowserOSStartImportRequest): void + /** Stops a running import. Items already copied stay in the profile. */ + cancelImport(): void + /** Opens the password manager, where an exported CSV is imported. */ + openPasswordManager(): void } +/** Where a CSV exported from another browser is imported. */ +export const PASSWORD_MANAGER_URL = 'chrome://password-manager/settings' + interface BrowserOSOnboardingBridgeOptions { chrome?: BrowserOSOnboardingChrome | null mockTiming?: 'delayed' | 'sync' @@ -75,10 +82,17 @@ function emitMockState(state: BrowserOSOnboardingState) { getHostWindow()?.browserosOnboarding?.receiveState(state) } +// Bumped by a mock cancel so progress scheduled for the stopped run is dropped. +let mockImportGeneration = 0 + function scheduleMockState(state: BrowserOSOnboardingState, delayMs: number) { const hostWindow = getHostWindow() const schedule = hostWindow?.setTimeout ?? globalThis.setTimeout - schedule(() => emitMockState(state), delayMs) + const generation = mockImportGeneration + schedule(() => { + if (generation !== mockImportGeneration) return + emitMockState(state) + }, delayMs) } function recommendedItemsFor(request: BrowserOSStartImportRequest) { @@ -302,5 +316,30 @@ export function createBrowserOSOnboardingBridge( } emitMockImport(request, mockIsSync) }, + cancelImport() { + if (currentState?.status !== 'importing') return + if (!isMock) { + chromeBridge.send(BrowserOSOnboardingMessage.CANCEL_IMPORT) + return + } + mockImportGeneration += 1 + const progress = currentState.progress + emitMockState( + createState( + 'cancelled', + progress ? { ...progress, currentItem: undefined } : undefined, + currentState.results?.map((result) => ({ + ...result, + status: 'cancelled' as const, + })), + ), + ) + }, + openPasswordManager() { + // window.open, not navigation: this page must stay where it is. In the + // first-run window Chromium hosts the opened page in its own popup; in a + // tab it opens beside this one. + getHostWindow()?.open(PASSWORD_MANAGER_URL, '_blank') + }, } } diff --git a/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx b/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx index e16a14c24..207d5bc82 100644 --- a/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx +++ b/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx @@ -8,21 +8,56 @@ */ import { t } from '@browseros/i18n' -import { RefreshCw } from '@browseros/icons' +import { RefreshCw, X } from '@browseros/icons' +import { Button } from '@/components/ui/button' +import type { BrowserOSImportItem } from '../browseros-onboarding-api' +import { importItemLabel } from '../onboarding-v2.helpers' interface ImportingProgressCardProps { currentItemLabel?: string progress: number sourceLabel?: string total: number + /** The items this import was asked for, in checklist order. */ + items?: readonly BrowserOSImportItem[] + completedItems?: readonly BrowserOSImportItem[] + currentItem?: BrowserOSImportItem + onCancel?: () => void } -/** Displays Chromium import progress while selected source data is copied. */ +type ItemState = 'done' | 'copying' | 'waiting' + +function itemStateFor( + item: BrowserOSImportItem, + completedItems: readonly BrowserOSImportItem[], + currentItem: BrowserOSImportItem | undefined, +): ItemState { + if (completedItems.includes(item)) return 'done' + if (item === currentItem) return 'copying' + return 'waiting' +} + +function itemStateLabel(state: ItemState): string { + if (state === 'done') return t('onboardItemStateDone') + if (state === 'copying') return t('onboardItemStateCopying') + return t('onboardItemStateWaiting') +} + +/** + * Displays import progress while selected source data is copied: an overall + * bar, then one bar per item type. The importer reports when each item starts + * and ends, not how far through one it is, so an item's bar is empty, moving + * (indeterminate) or full -- never a made-up percentage. + */ export function ImportingProgressCard({ currentItemLabel, progress, sourceLabel, total, + items = [], + completedItems = [], + currentItem, + onCancel, }: ImportingProgressCardProps) { const percent = total === 0 ? 0 : Math.min(100, (progress / total) * 100) return ( @@ -51,6 +86,56 @@ export function ImportingProgressCard({
{t('onboardItems', progress, total)}
+ {items.length > 0 && ( + + )} + {onCancel && ( +
+ +
+ )} ) } diff --git a/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts b/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts index bd1e0bd9f..98cad7ec3 100644 --- a/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts +++ b/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts @@ -65,6 +65,18 @@ export const MOCK_BROWSEROS_IMPORT_SOURCES: readonly BrowserOSImportSource[] = [ supportedItems: ['history', 'bookmarks', 'cookies', 'passwords'], recommendedItems: ['history', 'bookmarks', 'cookies'], }, + { + id: 'firefox-default', + displayName: 'Firefox - default-release', + browserName: 'Firefox', + profileName: 'default-release', + accountName: '', + isManaged: false, + kind: 'firefox', + passwordsViaCsv: true, + supportedItems: ['history', 'bookmarks', 'autofill'], + recommendedItems: ['history', 'bookmarks', 'autofill'], + }, ] export const DEFAULT_BROWSEROS_IMPORT_SOURCE_ID = diff --git a/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx b/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx index a4edd4195..4215f2a2c 100644 --- a/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx +++ b/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx @@ -13,6 +13,8 @@ import { ArrowRight, ChevronDown, Download, + ExternalLink, + KeyRound, Lock, RefreshCw, } from '@browseros/icons' @@ -54,6 +56,44 @@ interface ImportStepProps { onImport: () => void onRefresh: () => void onContinue: () => void + /** Stops a running import. */ + onCancel?: () => void + /** Opens the password manager for the CSV route. */ + onOpenPasswordManager?: () => void +} + +/** Sources whose saved passwords are not copied: say how to bring them anyway. */ +function PasswordsViaCsvNotice({ + browserName, + onOpenPasswordManager, +}: { + browserName: string + onOpenPasswordManager?: () => void +}) { + return ( +
+ +
+
+ {t('onboardPasswordsViaCsvTitle')} +
+

+ {t('onboardPasswordsViaCsvBody', browserName)} +

+ {onOpenPasswordManager && ( + + )} +
+
+ ) } function importButtonLabelFor( @@ -89,6 +129,8 @@ export function ImportStep({ onImport, onRefresh, onContinue, + onCancel, + onOpenPasswordManager, }: ImportStepProps) { const [permissionOpen, setPermissionOpen] = useState(false) const [showAllProfiles, setShowAllProfiles] = useState(false) @@ -274,6 +316,20 @@ export function ImportStep({ onToggle={toggleImportItem} /> )} + {selectedSource?.passwordsViaCsv && ( + + )} + {state.status === 'cancelled' && ( +
+ {t('onboardImportCancelled')} +
+ )} {state.error && (
{state.error.message} @@ -322,6 +378,10 @@ export function ImportStep({ progress={completedItems} sourceLabel={currentSourceLabel} total={totalItems} + items={checkedItems} + completedItems={importedItems} + currentItem={state.progress?.currentItem} + onCancel={onCancel} /> {needsPermission && } diff --git a/agent/apps/app/components/sidebar/SettingsSidebar.tsx b/agent/apps/app/components/sidebar/SettingsSidebar.tsx index e9b472f49..9d2295e14 100644 --- a/agent/apps/app/components/sidebar/SettingsSidebar.tsx +++ b/agent/apps/app/components/sidebar/SettingsSidebar.tsx @@ -12,6 +12,8 @@ import { Activity, ArrowLeft, Bot, + Brain, + KeyRound, MessageSquare, Palette, Server, @@ -81,6 +83,8 @@ const primarySettingsSections: NavSection[] = [ to: '/settings/customization', icon: Palette, }, + { name: t('stNavMemory'), to: '/settings/memory', icon: Brain }, + { name: t('apwTitle'), to: '/settings/passwords', icon: KeyRound }, { name: t('stNavRedrobMcp'), to: '/settings/mcp', icon: Server }, ], }, diff --git a/agent/apps/app/entrypoints/app/App.tsx b/agent/apps/app/entrypoints/app/App.tsx index d202ffbd1..f475eb2ed 100644 --- a/agent/apps/app/entrypoints/app/App.tsx +++ b/agent/apps/app/entrypoints/app/App.tsx @@ -14,15 +14,18 @@ import { SettingsSidebarLayout } from '@/components/layout/SettingsSidebarLayout import { SidebarLayout } from '@/components/layout/SidebarLayout' import { diagnosticsCopy } from '@/lib/diagnostics/diagnostics-copy' import { AgentCommandHome } from '@/screens/agent-command/AgentCommandHome' +import { AgentPasswordsPage } from '@/screens/agent-passwords/AgentPasswordsPage' import { AISettingsPage } from '@/screens/ai-settings/AISettingsPage' import { ConnectMCP } from '@/screens/connect-mcp/ConnectMCP' import { CustomizationPage } from '@/screens/customization/CustomizationPage' import { LlmHubPage } from '@/screens/llm-hub/LlmHubPage' import { MCPSettingsPage } from '@/screens/mcp-settings/MCPSettingsPage' +import { MemorySettingsPage } from '@/screens/memory-settings/MemorySettingsPage' import { NewTabChat } from '@/screens/newtab/index/NewTabChat' import { NewTabLayout } from '@/screens/newtab/layout/NewTabLayout' import { Personalize } from '@/screens/newtab/personalize/Personalize' import { OnboardingAiPage } from '@/screens/onboarding-ai/OnboardingAiPage' +import { QuickWindow } from '@/screens/quick-window/QuickWindow' import { ScheduledTasksPage } from '@/screens/scheduled-tasks/ScheduledTasksPage' const OptionsRedirect: FC = () => { @@ -35,6 +38,7 @@ const OptionsRedirect: FC = () => { 'connect-mcp': '/connect-apps', mcp: '/settings/mcp', customization: '/settings/customization', + memory: '/settings/memory', search: '/settings/ai', scheduled: '/scheduled', } @@ -65,6 +69,8 @@ export const App: FC = () => { } /> } /> } /> + } /> + } /> } @@ -109,6 +115,9 @@ export const App: FC = () => { /> } /> + {/* CAP-5: the page inside the native quick window. No layout: the window is the frame. */} + } /> + } /> diff --git a/agent/apps/app/lib/agent-passwords/agent-passwords.ts b/agent/apps/app/lib/agent-passwords/agent-passwords.ts new file mode 100644 index 000000000..b57604ea5 --- /dev/null +++ b/agent/apps/app/lib/agent-passwords/agent-passwords.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The agent sign-in settings (CAP-9), over chrome.redrobPrivate. See docs/CREDENTIALS.md. + * + * Nothing here can read a password, by construction: the browser's settings tree is a policy, a list of + * origins and an audit trail of usernames and outcomes. The passwords stay in the browser's own password + * manager, which `openPasswordManager` opens. + * + * When redrobPrivate is missing (a dev build in stock Chromium, the UI capture harness) `available()` is + * false and the page says so rather than rendering controls that change nothing. + */ + +export type AgentCredentialPolicy = 'always' | 'whileUnlocked' | 'never' + +export type AgentCredentialSite = { origin: string; allow: boolean } + +export type AgentCredentialOutcome = + | 'filled' + | 'noSavedLogin' + | 'refused' + | 'locked' + | 'severalLogins' + | 'noSigninForm' + | 'needsVerification' + | 'failed' + +export type AgentCredentialRecord = { + timeMs: number + origin: string + username: string + taskId: string + outcome: string +} + +export type AgentCredentialWait = { + taskId: string + prompt: string + origin: string + needsUnlock: boolean +} + +export type AgentCredentialSettings = { + policy: AgentCredentialPolicy + unlockMinutes: number + unlocked: boolean + offTheRecord: boolean + sites: AgentCredentialSite[] + history: AgentCredentialRecord[] + waiting: AgentCredentialWait[] +} + +type Api = { + getAgentCredentialSettings(cb: (s: AgentCredentialSettings) => void): void + setAgentCredentialPolicy(policy: AgentCredentialPolicy): void + setAgentCredentialUnlockMinutes(minutes: number): void + setAgentCredentialSite( + origin: string, + allow: boolean, + cb: (ok: boolean) => void, + ): void + removeAgentCredentialSite(origin: string, cb: (ok: boolean) => void): void + unlockAgentCredentials(cb: (ok: boolean) => void): void + lockAgentCredentials(): void + resumeCredentialTask(taskId: string, cb: (ok: boolean) => void): void + clearAgentCredentialHistory(): void + openPasswordManager(): void +} + +function api(): Api | null { + const candidate = ( + globalThis as { chrome?: { redrobPrivate?: Partial } } + ).chrome?.redrobPrivate + return typeof candidate?.getAgentCredentialSettings === 'function' + ? (candidate as Api) + : null +} + +export const available = (): boolean => api() !== null + +function need(): Api { + const browser = api() + if (!browser) throw new Error('redrobPrivate is not available') + return browser +} + +export const getSettings = (): Promise => + new Promise((resolve) => need().getAgentCredentialSettings(resolve)) + +export const setPolicy = (policy: AgentCredentialPolicy): void => + need().setAgentCredentialPolicy(policy) + +export const setUnlockMinutes = (minutes: number): void => + need().setAgentCredentialUnlockMinutes(minutes) + +export const setSite = (origin: string, allow: boolean): Promise => + new Promise((resolve) => + need().setAgentCredentialSite(origin, allow, resolve), + ) + +export const removeSite = (origin: string): Promise => + new Promise((resolve) => + need().removeAgentCredentialSite(origin, resolve), + ) + +/** Opens the operating system's re-authentication over this page; resolves whether it succeeded. */ +export const unlock = (): Promise => + new Promise((resolve) => need().unlockAgentCredentials(resolve)) + +export const lock = (): void => need().lockAgentCredentials() + +export const resumeTask = (taskId: string): Promise => + new Promise((resolve) => need().resumeCredentialTask(taskId, resolve)) + +export const clearHistory = (): void => need().clearAgentCredentialHistory() + +export const openPasswordManager = (): void => need().openPasswordManager() + +/** + * What the add-site field accepts: a site address, with or without its scheme (a bare host means https), + * reduced to its origin, which is the key the browser stores. The browser re-checks; this is only so the + * user hears about a typo before the round trip. + */ +export function toOrigin(input: string): string | null { + const text = input.trim() + if (!text) return null + try { + // Joined at run time (a join a minifier does not fold): the build check refuses any literal remote URL + // in the bundle, and `https://` followed by anything is one. + const https = ['https:', '', ''].join('/') + const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : https + text) + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null + if (!url.hostname.includes('.') && url.hostname !== 'localhost') return null + return url.origin + } catch { + return null + } +} diff --git a/agent/apps/app/lib/memory/memory-api.ts b/agent/apps/app/lib/memory/memory-api.ts new file mode 100644 index 000000000..6ea732a53 --- /dev/null +++ b/agent/apps/app/lib/memory/memory-api.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The memory half of chrome.redrobPrivate, typed and promised. + * + * Memory is a folder of markdown files the browser owns (docs/MEMORY.md). This page never reaches the + * disk: every read and write goes through the browser, and every write the page makes is recorded as a + * "user" run there, snapshotted first, so it can be reverted like the agent's own runs. + */ + +export type MemoryFile = { path: string; bytes: number; modifiedMs: number } + +export type MemoryRunKind = 'extract' | 'consolidate' | 'user' | 'revert' + +export type MemoryRun = { + id: string + kind: MemoryRunKind + startedMs: number + durationMs: number + tokens: number + files: { path: string; added: number; removed: number }[] + finished: boolean + revertible: boolean +} + +export type MemorySettings = { + enabled: boolean + retentionDays: number + historyInput: boolean + files: MemoryFile[] + runs: MemoryRun[] + lastConsolidationMs: number +} + +type MemoryApi = { + getSettings(callback: (settings: { memory: MemorySettings }) => void): void + setMemoryEnabled(enabled: boolean): void + setMemoryRetentionDays(days: number): void + setMemoryHistoryInput(enabled: boolean): void + forgetAllMemory(): void + removeMemory(path: string, callback: (ok: boolean) => void): void + readMemoryFile(path: string, callback: (content?: string) => void): void + writeMemoryFile( + path: string, + content: string, + callback: (ok: boolean) => void, + ): void + revertMemoryRun(runId: string, callback: (ok: boolean) => void): void + runMemoryConsolidation(callback: (ok: boolean) => void): void +} + +/** The API, or null outside the browser (a dev build in stock Chromium, the UI capture harness). */ +export function memoryApi(): MemoryApi | null { + const candidate = ( + globalThis as { chrome?: { redrobPrivate?: Partial } } + ).chrome?.redrobPrivate + return typeof candidate?.readMemoryFile === 'function' && + typeof candidate.getSettings === 'function' + ? (candidate as MemoryApi) + : null +} + +function need(): MemoryApi { + const api = memoryApi() + if (!api) throw new Error('redrobPrivate memory API is unavailable') + return api +} + +export const readMemorySettings = (): Promise => + new Promise((resolve) => need().getSettings((s) => resolve(s.memory))) + +export const readMemoryFile = (path: string): Promise => + new Promise((resolve) => need().readMemoryFile(path, resolve)) + +export const writeMemoryFile = ( + path: string, + content: string, +): Promise => + new Promise((resolve) => need().writeMemoryFile(path, content, resolve)) + +export const deleteMemoryFile = (path: string): Promise => + new Promise((resolve) => need().removeMemory(path, resolve)) + +export const revertMemoryRun = (runId: string): Promise => + new Promise((resolve) => need().revertMemoryRun(runId, resolve)) + +export const runMemoryConsolidation = (): Promise => + new Promise((resolve) => need().runMemoryConsolidation(resolve)) + +export const setMemoryEnabled = (enabled: boolean) => + need().setMemoryEnabled(enabled) +export const setMemoryRetentionDays = (days: number) => + need().setMemoryRetentionDays(days) +export const setMemoryHistoryInput = (enabled: boolean) => + need().setMemoryHistoryInput(enabled) +export const forgetAllMemory = () => need().forgetAllMemory() diff --git a/agent/apps/app/lib/redrob/quick-window.ts b/agent/apps/app/lib/redrob/quick-window.ts new file mode 100644 index 000000000..3111742ba --- /dev/null +++ b/agent/apps/app/lib/redrob/quick-window.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The quick window's half of redrobPrivate (CAP-5). + * + * The quick window is a native always-on-top window the browser opens on a global shortcut; this + * extension's `#/quick` route is the page inside it. Starting a task from it goes through + * redrobPrivate.startQuickTask, which opens the SAME `#/home/chat?q=…&mode=agent` route the new tab and + * the address bar's agent mode open, in the last active browser window, and closes the quick window. + * + * Every call degrades when redrobPrivate is absent (a dev build in stock Chromium, the UI capture + * harness): the page then opens the chat route itself and closes with window.close(). + */ + +export type QuickWindowShortcut = + | 'primary_shift_space' + | 'alt_shift_space' + | 'off' + +export const QUICK_WINDOW_SHORTCUTS: readonly QuickWindowShortcut[] = [ + 'primary_shift_space', + 'alt_shift_space', + 'off', +] + +type QuickWindowApi = { + getQuickWindowShortcut(callback: (preset: string) => void): void + setQuickWindowShortcut(preset: string): void + startQuickTask(prompt: string): void + closeQuickWindow(): void +} + +function api(): QuickWindowApi | null { + const candidate = ( + globalThis as { chrome?: { redrobPrivate?: Partial } } + ).chrome?.redrobPrivate + return typeof candidate?.startQuickTask === 'function' && + typeof candidate.closeQuickWindow === 'function' && + typeof candidate.getQuickWindowShortcut === 'function' && + typeof candidate.setQuickWindowShortcut === 'function' + ? (candidate as QuickWindowApi) + : null +} + +const isShortcut = (value: unknown): value is QuickWindowShortcut => + QUICK_WINDOW_SHORTCUTS.includes(value as QuickWindowShortcut) + +export function isQuickWindowApiAvailable(): boolean { + return api() !== null +} + +export async function getQuickWindowShortcut(): Promise { + const browser = api() + if (!browser) return 'primary_shift_space' + return new Promise((resolve) => + browser.getQuickWindowShortcut((preset) => + resolve(isShortcut(preset) ? preset : 'off'), + ), + ) +} + +export function setQuickWindowShortcut(preset: QuickWindowShortcut): void { + api()?.setQuickWindowShortcut(preset) +} + +/** The shortcut as a person reads it on this platform. */ +export function shortcutLabel( + preset: QuickWindowShortcut, + isMac: boolean, +): string | null { + switch (preset) { + case 'primary_shift_space': + return isMac ? '⌘ ⇧ Space' : 'Ctrl + Shift + Space' + case 'alt_shift_space': + return isMac ? '⌥ ⇧ Space' : 'Alt + Shift + Space' + default: + return null + } +} + +/** Opens `url` (an extension page) in the last focused normal window, or a new one. */ +async function openInLastWindow(url: string): Promise { + try { + const last = await chrome.windows.getLastFocused({ + windowTypes: ['normal'], + }) + if (last?.id !== undefined) { + await chrome.tabs.create({ windowId: last.id, url, active: true }) + await chrome.windows.update(last.id, { focused: true }) + return + } + } catch { + // No normal window: fall through and make one. + } + await chrome.windows.create({ url, focused: true }) +} + +export function closeQuickWindow(): void { + const browser = api() + if (browser) { + browser.closeQuickWindow() + } else { + window.close() + } +} + +/** Hands `prompt` to the agent: the same chat route the new tab opens. */ +export async function startQuickTask(prompt: string): Promise { + const text = prompt.trim() + if (!text) return + const browser = api() + if (browser) { + browser.startQuickTask(text) + return + } + const search = new URLSearchParams({ q: text, mode: 'agent' }) + await openInLastWindow(chrome.runtime.getURL(`/app.html#/home/chat?${search}`)) + window.close() +} + +/** Reopens an earlier conversation in the browser and closes the quick window. */ +export async function openConversation(conversationId: string): Promise { + const url = chrome.runtime.getURL( + `/app.html#/home/chat?conversationId=${encodeURIComponent(conversationId)}`, + ) + await openInLastWindow(url) + closeQuickWindow() +} diff --git a/agent/apps/app/screens/agent-passwords/AgentPasswordsPage.tsx b/agent/apps/app/screens/agent-passwords/AgentPasswordsPage.tsx new file mode 100644 index 000000000..3effbc4e9 --- /dev/null +++ b/agent/apps/app/screens/agent-passwords/AgentPasswordsPage.tsx @@ -0,0 +1,455 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Settings > Passwords and the agent (CAP-9). See docs/CREDENTIALS.md. + * + * Four things, in the order a user needs them: WHEN the agent may sign in (the policy and the unlock + * window), what is WAITING on them right now, the per-site list, and what the agent DID (the AI sign-in + * history). The passwords themselves are not on this page and cannot be: the browser answers this page + * with origins, usernames and outcomes only, and the store lives in the password manager this page opens. + */ + +import { t } from '@browseros/i18n' +import { Trash2 } from '@browseros/icons' +import { + type FC, + type FormEvent, + useCallback, + useEffect, + useId, + useState, +} from 'react' +import { + SettingsGroup, + SettingsHeader, + SettingsRow, +} from '@/components/settings/SettingsLayout' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Input } from '@/components/ui/input' +import { + type AgentCredentialPolicy, + type AgentCredentialSettings, + available, + clearHistory, + getSettings, + lock, + openPasswordManager, + removeSite, + resumeTask, + setPolicy, + setSite, + setUnlockMinutes, + toOrigin, + unlock, +} from '@/lib/agent-passwords/agent-passwords' + +const POLICIES = [ + { value: 'always', label: 'apwPolicyAlways', hint: 'apwPolicyAlwaysHint' }, + { + value: 'whileUnlocked', + label: 'apwPolicyWhileUnlocked', + hint: 'apwPolicyWhileUnlockedHint', + }, + { value: 'never', label: 'apwPolicyNever', hint: 'apwPolicyNeverHint' }, +] as const satisfies readonly { + value: AgentCredentialPolicy + label: Parameters[0] + hint: Parameters[0] +}[] + +const UNLOCK_CHOICES = [5, 15, 30, 60, 240] + +const OUTCOME_LABELS: Record[0]> = { + filled: 'apwOutcomeFilled', + noSavedLogin: 'apwOutcomeNoSavedLogin', + refused: 'apwOutcomeRefused', + locked: 'apwOutcomeLocked', + severalLogins: 'apwOutcomeSeveralLogins', + noSigninForm: 'apwOutcomeNoSigninForm', + needsVerification: 'apwOutcomeNeedsVerification', + failed: 'apwOutcomeFailed', +} + +/** The unlock state changes by the clock, not by an event, so the page re-reads it while it is open. */ +const REFRESH_MS = 15_000 + +export const AgentPasswordsPage: FC = () => { + const [settings, setSettings] = useState( + null, + ) + const [unlockError, setUnlockError] = useState(false) + const [confirmClear, setConfirmClear] = useState(false) + const reload = useCallback(async () => { + if (!available()) return + setSettings(await getSettings()) + }, []) + + useEffect(() => { + void reload() + const timer = setInterval(() => void reload(), REFRESH_MS) + const onFocus = () => void reload() + window.addEventListener('focus', onFocus) + return () => { + clearInterval(timer) + window.removeEventListener('focus', onFocus) + } + }, [reload]) + + const onUnlock = async () => { + setUnlockError(false) + const ok = await unlock() + if (!ok) setUnlockError(true) + await reload() + } + + return ( +
+ + {settings?.offTheRecord && ( +

{t('apwOffTheRecord')}

+ )} + {settings && !settings.offTheRecord && ( + <> + + {settings.waiting.length > 0 && ( + + {settings.waiting.map((wait) => ( + + + + ))} + + )} + + + + {settings.history.length > 0 && ( +
+ +
+ )} +
+ + )} + {settings && ( + + + + + + )} + + + + + {t('apwHistoryClearConfirm')} + + + {t('spCancel')} + { + clearHistory() + await reload() + }} + > + {t('apwHistoryClear')} + + + + +
+ ) +} + +const PolicyGroup: FC<{ + settings: AgentCredentialSettings + unlockError: boolean + onUnlock: () => Promise + onChanged: () => Promise +}> = ({ settings, unlockError, onUnlock, onChanged }) => { + const policyId = useId() + const minutesId = useId() + const minuteChoices = UNLOCK_CHOICES.includes(settings.unlockMinutes) + ? UNLOCK_CHOICES + : [...UNLOCK_CHOICES, settings.unlockMinutes].sort((a, b) => a - b) + return ( + + +
+ {POLICIES.map(({ value, label, hint }) => ( + + ))} +
+
+ {settings.policy === 'whileUnlocked' && ( + <> + + + + + {settings.unlocked ? ( + + ) : ( + + )} + + + )} +
+ ) +} + +const SitesGroup: FC<{ + settings: AgentCredentialSettings + onChanged: () => Promise +}> = ({ settings, onChanged }) => { + const inputId = useId() + const [draft, setDraft] = useState('') + const [allowDraft, setAllowDraft] = useState(false) + const [invalid, setInvalid] = useState(false) + + const onAdd = async (event: FormEvent) => { + event.preventDefault() + const origin = toOrigin(draft) + if (!origin || !(await setSite(origin, allowDraft))) { + setInvalid(true) + return + } + setInvalid(false) + setDraft('') + await onChanged() + } + + return ( + +

+ {t('apwSitesHint')} +

+ {settings.sites.length === 0 && ( +

+ {t('apwSitesEmpty')} +

+ )} + {settings.sites.map((site) => ( + +
+ + +
+
+ ))} +
+ + setDraft(event.target.value)} + /> + + + {invalid && ( +

+ {t('apwSiteInvalid')} +

+ )} +
+
+ ) +} + +const HistoryTable: FC<{ settings: AgentCredentialSettings }> = ({ + settings, +}) => { + if (settings.history.length === 0) { + return ( +

+ {t('apwHistoryEmpty')} +

+ ) + } + return ( +
+ + + + + + + + + + + + {settings.history.map((record, index) => ( + + + + + + + + ))} + +
{t('apwHistoryTime')}{t('apwHistorySite')}{t('apwHistoryUser')}{t('apwHistoryTask')}{t('apwHistoryResult')}
{new Date(record.timeMs).toLocaleString()}{record.origin}{record.username || t('apwNoUsername')}{record.taskId} + {OUTCOME_LABELS[record.outcome] + ? t(OUTCOME_LABELS[record.outcome]) + : record.outcome} +
+
+ ) +} diff --git a/agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx b/agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx index 4cd12090b..81caaf33b 100644 --- a/agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx +++ b/agent/apps/app/screens/ai-settings/BrowserOsAiPane.tsx @@ -33,6 +33,7 @@ import { AddProviderDialogs, useAddProvider } from './add-provider.hooks' import { ConfiguredTargetsList } from './ConfiguredTargetsList' import { useCodingAgents } from './coding-agents.hooks' import { useDefaultChatTarget } from './default-chat-target.hooks' +import { EngineSubscriptions } from './EngineSubscriptions' import { NewProviderDialog } from './NewProviderDialog' /** @@ -160,6 +161,8 @@ export const BrowserOsAiPane: FC = () => {

+ +

diff --git a/agent/apps/app/screens/ai-settings/EngineSubscriptions.tsx b/agent/apps/app/screens/ai-settings/EngineSubscriptions.tsx new file mode 100644 index 000000000..a957fff09 --- /dev/null +++ b/agent/apps/app/screens/ai-settings/EngineSubscriptions.tsx @@ -0,0 +1,411 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@browseros/i18n' +import { type FC, useCallback, useEffect, useMemo, useState } from 'react' +import { + SettingsGroup, + SettingsRow, +} from '@/components/settings/SettingsLayout' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' + +/** + * Subscriptions and providers, as the ENGINE reports them (SUB-1..4), and the agent-task setting that + * keeps work running after the last window closes (CAP-4). + * + * Nothing here is a list of its own. The browser asks the engine (redrobPrivate.getEngineAccount) which + * providers it knows, which are connected and how each can be connected, and renders that -- a second + * list in the page would be a second truth. A subscription is connected with the ENGINE's own OAuth: the + * browser opens the provider's page in a tab, the engine keeps the result. An API key sits behind the + * subscription options and goes one way, into the engine's store; nothing reads it back. + * + * Usage: the engine has no usage endpoint, so no number is shown. For Redrob the page links to Redrob + * Console, which is where spend is reported, and says so; for any other provider it says the engine does + * not report it. A figure with no stated source would be worse than none. + */ + +type AuthMethod = { index: number; type: string; methodLabel: string } +type EngineProvider = { + id: string + name: string + source: string + connected: boolean + consoleManaged: boolean + modelCount: number + methods: AuthMethod[] +} +type EngineAccount = { + engineReady: boolean + providers: EngineProvider[] + consoleOrg: string +} +type SignInStart = { error?: string; instructions: string; needsCode: boolean } + +type EngineApi = { + getEngineAccount(cb: (account: EngineAccount) => void): void + beginEngineSignIn( + providerId: string, + method: number, + cb: (start: SignInStart) => void, + ): void + finishEngineSignIn( + providerId: string, + method: number, + code: string, + cb: (error?: string) => void, + ): void + setEngineProviderKey( + providerId: string, + key: string, + cb: (error?: string) => void, + ): void + disconnectEngineProvider(providerId: string, cb: (ok: boolean) => void): void + getKeepTasksRunning(cb: (value: boolean) => void): void + setKeepTasksRunning(enabled: boolean): void +} + +function api(): EngineApi | null { + const candidate = ( + globalThis as { chrome?: { redrobPrivate?: Partial } } + ).chrome?.redrobPrivate + return typeof candidate?.getEngineAccount === 'function' + ? (candidate as EngineApi) + : null +} + +/** Where Redrob Console reports spend. The engine's own retry messages point at the same page. */ +const CONSOLE_USAGE_URL = 'https://console.redrob.ai/billing' +const REDROB_PROVIDER_ID = 'redrob' +const SEARCH_LIMIT = 20 + +function sourceLabel(source: string): string { + switch (source) { + case 'env': + return t('engSourceEnv') + case 'config': + return t('engSourceConfig') + case 'api': + return t('engSourceStore') + case 'launcher': + return t('engSourceLauncher') + case 'custom': + return t('engSourceBuiltIn') + default: + return t('engSourceUnknown') + } +} + +type Flow = + | { kind: 'idle' } + | { kind: 'starting' } + | { kind: 'waiting'; method: number; instructions: string } + | { kind: 'code'; method: number; instructions: string; code: string } + | { kind: 'error'; message: string } + +const ProviderRow: FC<{ + provider: EngineProvider + onChanged: () => void +}> = ({ provider, onChanged }) => { + const [flow, setFlow] = useState({ kind: 'idle' }) + const [showKey, setShowKey] = useState(false) + const [key, setKey] = useState('') + const [busy, setBusy] = useState(false) + const browser = api() + const oauth = provider.methods.filter((m) => m.type === 'oauth') + const keyMethod = provider.methods.find((m) => m.type === 'api') + + const finish = (method: number, code: string) => { + if (!browser) return + browser.finishEngineSignIn(provider.id, method, code, (error) => { + if (error) { + setFlow({ kind: 'error', message: error }) + return + } + setFlow({ kind: 'idle' }) + onChanged() + }) + } + + const begin = (method: number) => { + if (!browser) return + setFlow({ kind: 'starting' }) + browser.beginEngineSignIn(provider.id, method, (start) => { + if (start.error) { + setFlow({ kind: 'error', message: start.error }) + return + } + if (start.needsCode) { + setFlow({ + kind: 'code', + method, + instructions: start.instructions, + code: '', + }) + return + } + setFlow({ kind: 'waiting', method, instructions: start.instructions }) + finish(method, '') + }) + } + + const saveKey = () => { + if (!browser || !key.trim()) return + setBusy(true) + browser.setEngineProviderKey(provider.id, key, (error) => { + setBusy(false) + // The field is cleared either way: the key is not kept in the page once it has been sent. + setKey('') + if (error) { + setFlow({ kind: 'error', message: error }) + return + } + setShowKey(false) + onChanged() + }) + } + + const disconnect = () => { + if (!browser) return + setBusy(true) + browser.disconnectEngineProvider(provider.id, (ok) => { + setBusy(false) + if (!ok) { + setFlow({ kind: 'error', message: t('engDisconnectFailed') }) + return + } + onChanged() + }) + } + + const status = provider.connected + ? t('engConnectedFrom', sourceLabel(provider.source)) + : t('engNotConnected') + + return ( + + + {status} + {provider.consoleManaged ? ` · ${t('engConsoleManaged')}` : ''} + {provider.modelCount > 0 + ? ` · ${t('engModelCount', provider.modelCount)}` + : ''} + + {provider.connected && + (provider.id === REDROB_PROVIDER_ID ? ( + + {t('engUsageFromConsole')}{' '} + + {t('engOpenConsoleUsage')} + + + ) : ( + {t('engUsageNotReported')} + ))} + {flow.kind === 'waiting' && ( + + {flow.instructions || t('engFinishInTab')} + + )} + {flow.kind === 'code' && ( + + {flow.instructions || t('engPasteCode')} + + setFlow({ ...flow, code: e.target.value })} + /> + + + + )} + {flow.kind === 'error' && ( + + {flow.message} + + )} + {showKey && keyMethod && ( + + setKey(e.target.value)} + /> + + + )} + + } + > +
+ {/* Subscriptions first; a key is the fallback behind them (SUB-3). */} + {oauth.map((method) => ( + + ))} + {keyMethod && !showKey && ( + + )} + {provider.connected && provider.source === 'api' && ( + + )} +
+
+ ) +} + +const KeepTasksRunning: FC = () => { + const browser = api() + const [value, setValue] = useState(null) + useEffect(() => { + browser?.getKeepTasksRunning(setValue) + }, [browser]) + if (!browser || value === null) return null + return ( + + + { + setValue(next) + browser.setKeepTasksRunning(next) + }} + /> + + + ) +} + +export const EngineSubscriptions: FC = () => { + const browser = api() + const [account, setAccount] = useState(null) + const [query, setQuery] = useState('') + + const load = useCallback(() => { + browser?.getEngineAccount(setAccount) + }, [browser]) + + useEffect(load, [load]) + // An engine that is still starting answers "not ready"; ask once more shortly rather than leaving the + // section empty until the user reloads. + useEffect(() => { + if (!account || account.engineReady) return + const timer = setTimeout(load, 2000) + return () => clearTimeout(timer) + }, [account, load]) + + const connected = useMemo( + () => account?.providers.filter((p) => p.connected) ?? [], + [account], + ) + const available = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return [] + return (account?.providers ?? []) + .filter( + (p) => + !p.connected && + p.methods.length > 0 && + (p.name.toLowerCase().includes(q) || p.id.includes(q)), + ) + .slice(0, SEARCH_LIMIT) + }, [account, query]) + + // Without the private API (a dev build in stock Chromium) there is no engine to ask. + if (!browser) return null + + return ( +
+ + {!account ? ( + {null} + ) : !account.engineReady ? ( + + + + ) : ( + <> + + {null} + + {connected.length === 0 && ( + {null} + )} + {connected.map((p) => ( + + ))} + + setQuery(e.target.value)} + /> + + {available.map((p) => ( + + ))} + + )} + + +
+ ) +} diff --git a/agent/apps/app/screens/customization/CustomizationPage.tsx b/agent/apps/app/screens/customization/CustomizationPage.tsx index d41eb8385..f42c156ae 100644 --- a/agent/apps/app/screens/customization/CustomizationPage.tsx +++ b/agent/apps/app/screens/customization/CustomizationPage.tsx @@ -11,6 +11,7 @@ import { t } from '@browseros/i18n' import type { FC } from 'react' import { SettingsHeader } from '@/components/settings/SettingsLayout' import { AppearanceSettings } from './AppearanceSettings' +import { QuickWindowSettingsCard } from './QuickWindowSettingsCard' import { ToolbarSettingsCard } from './ToolbarSettingsCard' export const CustomizationPage: FC = () => { @@ -19,6 +20,7 @@ export const CustomizationPage: FC = () => { +

) } diff --git a/agent/apps/app/screens/customization/QuickWindowSettingsCard.tsx b/agent/apps/app/screens/customization/QuickWindowSettingsCard.tsx new file mode 100644 index 000000000..e9d59fc51 --- /dev/null +++ b/agent/apps/app/screens/customization/QuickWindowSettingsCard.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@browseros/i18n' +import { type FC, useEffect, useState } from 'react' +import { + SettingsGroup, + SettingsRow, +} from '@/components/settings/SettingsLayout' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + getQuickWindowShortcut, + isQuickWindowApiAvailable, + QUICK_WINDOW_SHORTCUTS, + type QuickWindowShortcut, + setQuickWindowShortcut, + shortcutLabel, +} from '@/lib/redrob/quick-window' +import { useIsMac } from '@/lib/useIsMac' + +/** + * The quick window's global shortcut (CAP-5): one of a few presets, or off. + * + * Presets rather than a key recorder, deliberately: the browser registers this combination with the OS, + * and a free choice would let it capture a key every other application needs. The browser accepts only + * these ids (redrobPrivate.setQuickWindowShortcut) and maps them to Ctrl, or Command on macOS. + */ +export const QuickWindowSettingsCard: FC = () => { + const isMac = useIsMac() ?? false + const [preset, setPreset] = useState(null) + const available = isQuickWindowApiAvailable() + + useEffect(() => { + if (!available) return + let live = true + void getQuickWindowShortcut().then((value) => { + if (live) setPreset(value) + }) + return () => { + live = false + } + }, [available]) + + // Without the browser API there is no window to configure, and a control that changes nothing would + // be the one thing worse than no row. + if (!available) return null + + return ( + + + + + + ) +} diff --git a/agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx b/agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx new file mode 100644 index 000000000..d7b3c07c5 --- /dev/null +++ b/agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx @@ -0,0 +1,382 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { formatRelative, t } from '@browseros/i18n' +import { type FC, useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { + SettingsGroup, + SettingsHeader, + SettingsRow, +} from '@/components/settings/SettingsLayout' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + deleteMemoryFile, + forgetAllMemory, + type MemoryRun, + type MemoryRunKind, + type MemorySettings, + memoryApi, + readMemoryFile, + readMemorySettings, + revertMemoryRun, + runMemoryConsolidation, + setMemoryEnabled, + setMemoryHistoryInput, + setMemoryRetentionDays, + writeMemoryFile, +} from '@/lib/memory/memory-api' + +const RETENTION = [ + { days: 30, label: 'mem30Days' }, + { days: 90, label: 'mem90Days' }, + { days: 0, label: 'memForever' }, +] as const + +const KIND_LABEL: Record[0]> = { + extract: 'memKindExtract', + consolidate: 'memKindConsolidate', + user: 'memKindUser', + revert: 'memKindRevert', +} + +/** One file open in the editor: what was loaded, and what is in the textarea now. */ +type Open = { path: string; loaded: string; draft: string } + +const RunRow: FC<{ + run: MemoryRun + busy: boolean + onRevert: (run: MemoryRun) => void +}> = ({ run, busy, onRevert }) => { + const meta = [ + formatRelative(run.startedMs), + run.finished + ? t('memSeconds', Math.max(1, Math.round(run.durationMs / 1000))) + : t('memRunning'), + run.tokens > 0 ? t('memTokens', run.tokens) : null, + ].filter(Boolean) + return ( +
+
+ {t(KIND_LABEL[run.kind])} +

{meta.join(' · ')}

+ {run.files.length === 0 ? ( +

{t('memNoChanges')}

+ ) : ( +
    + {run.files.map((file) => ( +
  • + {file.path} +{file.added} −{file.removed} +
  • + ))} +
+ )} +
+
+ {run.revertible && run.finished && ( + + )} +
+
+ ) +} + +export const MemorySettingsPage: FC = () => { + const [settings, setSettings] = useState(null) + const [open, setOpen] = useState(null) + const [busy, setBusy] = useState(false) + const [confirmForget, setConfirmForget] = useState(false) + const available = memoryApi() !== null + + const reload = useCallback(async () => { + if (!memoryApi()) return + setSettings(await readMemorySettings()) + }, []) + + useEffect(() => { + reload() + }, [reload]) + + const guarded = async (action: () => Promise | void) => { + setBusy(true) + try { + await action() + } catch { + toast.error(t('memUpdateFailed')) + } finally { + setBusy(false) + await reload() + } + } + + const openFile = (path: string) => + guarded(async () => { + const content = await readMemoryFile(path) + if (content === undefined) { + toast.error(t('memLoadFailed')) + return + } + setOpen({ path, loaded: content, draft: content }) + }) + + const saveFile = () => + guarded(async () => { + if (!open) return + if (await writeMemoryFile(open.path, open.draft)) { + setOpen({ ...open, loaded: open.draft }) + toast.success(t('memSaved')) + } else { + toast.error(t('memSaveFailed')) + } + }) + + const deleteFile = (path: string) => + guarded(async () => { + if (await deleteMemoryFile(path)) { + if (open?.path === path) setOpen(null) + toast.success(t('memDeleted')) + } else { + toast.error(t('memUpdateFailed')) + } + }) + + const revert = (run: MemoryRun) => + guarded(async () => { + if (await revertMemoryRun(run.id)) { + // The open file may be one the revert changed; reading it again beats showing a stale draft. + setOpen(null) + toast.success(t('memReverted')) + } else { + toast.error(t('memRevertFailed')) + } + }) + + const consolidate = () => + guarded(async () => { + if (await runMemoryConsolidation()) { + toast.success(t('memConsolidateStarted')) + } else { + toast.error(t('memConsolidateFailed')) + } + }) + + if (!available) { + return ( +
+ +
+ ) + } + + return ( +
+ + {t('memConsolidateNow')} + + } + /> + + + + + guarded(() => setMemoryEnabled(checked)) + } + /> + + + + + + + guarded(() => setMemoryHistoryInput(checked)) + } + /> + + + + + {!settings || settings.files.length === 0 ? ( +
+

{t('memFilesEmpty')}

+
+ ) : ( + settings.files.map((file) => ( +
+
+ {file.path} +

+ {t('memBytes', file.bytes)} ·{' '} + {formatRelative(file.modifiedMs)} +

+
+
+ + +
+
+ )) + )} + {open && ( +
+ {open.path} +