diff --git a/src/main/app/ipc-contracts/index.ts b/src/main/app/ipc-contracts/index.ts index 7b31c57b..7d412e1d 100644 --- a/src/main/app/ipc-contracts/index.ts +++ b/src/main/app/ipc-contracts/index.ts @@ -15,6 +15,7 @@ export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType> } = { loadSnapshot: noInput, relaunchApp: noInput, reportUnsaved: z.strictObject({ unsaved: z.boolean() }), + saveViewState: z.strictObject({ key: z.string().min(1).max(200), value: z.json().refine((value) => JSON.stringify(value).length <= 4_000, "View state value is too large.") }), runningSessionCount: noInput, pickDirectory: noInput, listConnectorCatalog: noInput, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 4ab9c153..b6f5a5b4 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -43,6 +43,7 @@ import { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../servic import { setProviderModel } from "../services/provider-default-model"; import { completeSetup, saveOnboardingDraft } from "../services/onboarding-progress"; import { setUnsavedInput } from "./unsaved-guard"; +import { saveViewState } from "../services/view-state"; import { providerDisconnectImpact } from "../services/provider-disconnect-impact"; export interface IpcDeps { @@ -152,6 +153,14 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); handle("completeSetup", (input) => mutate(() => completeSetup(deps.connection.db, input.orgId))); + handle("saveViewState", (input) => { + try { + saveViewState(deps.connection.db, input.key, input.value); + return ok(null); + } catch (error) { + return fail("view_state_failed", error instanceof Error ? error.message : String(error)); + } + }); handle("reportUnsaved", (input) => { setUnsavedInput(input.unsaved); return ok(null); diff --git a/src/main/database/migrations/106-view-state.ts b/src/main/database/migrations/106-view-state.ts new file mode 100644 index 00000000..aa967c6a --- /dev/null +++ b/src/main/database/migrations/106-view-state.ts @@ -0,0 +1,7 @@ +// Where the operator was (page, section, filters, selected records), so switching away or restarting returns there. +export const viewStateSchema = ` + alter table app_state add column view_state_json text; + + insert into schema_migrations(version, name, applied_at) + values (106, 'view_state', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index f714621e..6824ded5 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -104,6 +104,7 @@ import { goalDueDateSchema } from "./migrations/102-goal-due-date"; import { workVerdictsSchema } from "./migrations/103-work-verdicts"; import { workLanguageSchema } from "./migrations/104-work-language"; import { onboardingProgressSchema } from "./migrations/105-onboarding-progress"; +import { viewStateSchema } from "./migrations/106-view-state"; interface Migration { version: number; @@ -217,6 +218,7 @@ const MIGRATIONS: Migration[] = [ { version: 103, name: "work_verdicts", sql: workVerdictsSchema }, { version: 104, name: "work_language", sql: workLanguageSchema }, { version: 105, name: "onboarding_progress", sql: onboardingProgressSchema }, + { version: 106, name: "view_state", sql: viewStateSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index a322d170..a4fa041c 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -1,3 +1,4 @@ +import { loadViewState } from "./view-state"; import { loadOnboardingDraft } from "./onboarding-progress"; import type { DatabaseSync, StatementSync } from "node:sqlite"; import type { AppSnapshot } from "../../shared/domain"; @@ -88,6 +89,7 @@ export class SnapshotService { activeOrgId, uiPreferences: this.resolveUiPreferences(), onboardingDraft: loadOnboardingDraft(this.db), + viewState: loadViewState(this.db), autonomousPaused: this.resolveAutonomousPaused(), integrationKeys: new IntegrationKeyStore(this.db).statuses(), organizations, diff --git a/src/main/services/view-state.ts b/src/main/services/view-state.ts new file mode 100644 index 00000000..62f7627c --- /dev/null +++ b/src/main/services/view-state.ts @@ -0,0 +1,20 @@ +import type { DatabaseSync } from "node:sqlite"; + +export function loadViewState(db: DatabaseSync): Record { + const row = db.prepare("select view_state_json from app_state where id = 1").get() as { view_state_json: string | null } | undefined; + return row?.view_state_json ? JSON.parse(row.view_state_json) as Record : {}; +} + +/** Remembers one piece of where the operator was; null forgets it. */ +export function saveViewState(db: DatabaseSync, key: string, value: unknown): void { + const path = `$.${JSON.stringify(key)}`; + db.prepare(` + insert into app_state(id, view_state_json, updated_at) values (1, json_object(), ?) + on conflict(id) do nothing + `).run(new Date().toISOString()); + if (value === null) { + db.prepare("update app_state set view_state_json = json_remove(coalesce(view_state_json, json_object()), ?) where id = 1").run(path); + return; + } + db.prepare("update app_state set view_state_json = json_set(coalesce(view_state_json, json_object()), ?, json(?)) where id = 1").run(path, JSON.stringify(value)); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b43f3e2f..cf2e3a26 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ const api: DesktopApi = { loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot), relaunchApp: () => invoke(IPC_CHANNELS.relaunchApp), reportUnsaved: (input) => invoke(IPC_CHANNELS.reportUnsaved, input), + saveViewState: (input) => invoke(IPC_CHANNELS.saveViewState, input), saveOnboardingDraft: (input) => invoke(IPC_CHANNELS.saveOnboardingDraft, input), completeSetup: (input) => invoke(IPC_CHANNELS.completeSetup, input), runningSessionCount: () => invoke(IPC_CHANNELS.runningSessionCount), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index e9dabc63..eef48c44 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,3 +1,4 @@ +import { oneOf, readViewState, seedViewState, writeViewState } from "../shared/view-state"; import { LeaveGuardContext } from "./leave-guard"; import { t } from "../i18n/i18n"; import { Button } from "../shared/ui"; @@ -6,7 +7,8 @@ import { ErrorBoundary } from "../shared/ErrorBoundary"; import { StartupScreen } from "./StartupScreen"; import type { Focus } from "../pages/types"; import { useEffect, useMemo, useState } from "react"; -import { Shell, type RouteId } from "../shared/Shell"; +import { Shell } from "../shared/Shell"; +import { ROUTE_IDS, type RouteId } from "../shared/routes"; import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui"; import { Onboarding } from "./Onboarding"; import { useSnapshot } from "./useSnapshot"; @@ -22,9 +24,11 @@ import { RunsPage } from "../pages/RunsPage"; import { SettingsPage } from "../pages/SettingsPage"; import { OfficePage } from "../pages/OfficePage"; +const isRoute = oneOf(ROUTE_IDS); + export function App() { const { snapshot, syncFailure, actions } = useSnapshot(); - const [route, setRoute] = useState("home"); + const [routeChoice, setRoute] = useState(null); const [focus, setFocus] = useState(null); // Leaving a page with input that would be lost asks first. const [leaving, setLeaving] = useState<{ proceed: () => void } | null>(null); @@ -35,6 +39,7 @@ export function App() { // Opening a page for an object remembers it; plain navigation forgets it. const navigate = (next: RouteId, nextFocus?: Focus) => requestLeave(() => { setRoute(next); + writeViewState("route", next); setFocus(nextFocus ?? null); }); const avatarDirectory = useMemo(() => { @@ -76,6 +81,8 @@ export function App() { // A failed manual retry is already recorded in syncFailure, which the screen shows. return void actions.reload().catch(() => undefined)} />; } + seedViewState(snapshot.viewState); + const route = routeChoice ?? readViewState("route", isRoute) ?? "home"; // A company exists from Launch on, but its setup is unfinished until the connectors step is done; that is stored, so a restart resumes it. const unfinished = snapshot.organizations.find((organization) => organization.setupCompletedAt === null) ?? null; if (snapshot.organizations.length === 0 || creatingOrg || unfinished) { diff --git a/src/renderer/src/pages/ApprovalsPage.tsx b/src/renderer/src/pages/ApprovalsPage.tsx index 44d69b28..158859ba 100644 --- a/src/renderer/src/pages/ApprovalsPage.tsx +++ b/src/renderer/src/pages/ApprovalsPage.tsx @@ -1,3 +1,4 @@ +import { oneOf, usePersistentState } from "../shared/view-state"; import { approvalKindLabel } from "../i18n/labels"; import { decideEach, type BatchResult } from "./approvals/decide-each"; import { DECIDED_PAGE, decidedWindow } from "./approvals/decided-window"; @@ -33,8 +34,9 @@ const RISK_FILTERS: ReadonlyArray<{ id: RiskFilter; label: string }> = [ export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps) { const orgId = activeOrgId(snapshot); const employee = requireActiveEmployee(snapshot); - const [filter, setFilter] = useState("all"); const focusedApproval = focus?.kind === "approval" ? focus.id : null; + // A linked request is never hidden by a filter left from before. + const [filter, setFilter] = usePersistentState(`${orgId}:approvals.filter`, "all", oneOf(["all", "low", "medium", "high"] as const), focusedApproval ? "all" : undefined); const [editingId, setEditingId] = useState( focusedApproval && snapshot.approvals.some((item) => item.id === focusedApproval && item.state === "pending") ? focusedApproval : null, ); diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index e7bbaec7..6b3fd668 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -1,3 +1,4 @@ +import { isIdOrNull, usePersistentState } from "../shared/view-state"; import { useLeaveGuard } from "../app/leave-guard"; import { setDrafts, useDrafts } from "./chat/draft-store"; import { useCallback, useMemo, useRef, useState, type FormEvent } from "react"; @@ -66,7 +67,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { }), [spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id, ceoAgentId], ); - const [selectedSpaceId, setSelectedSpaceId] = useState(null); + const [selectedSpaceId, setSelectedSpaceId] = usePersistentState(`${orgId}:chat.space`, null, isIdOrNull); const [createOpen, setCreateOpen] = useState(false); const requestLeave = useLeaveGuard(); // Text and attachment are kept per conversation, outside the page, so switching conversations or pages loses neither. diff --git a/src/renderer/src/pages/FilesPage.tsx b/src/renderer/src/pages/FilesPage.tsx index e4dbc3c5..8cb1da20 100644 --- a/src/renderer/src/pages/FilesPage.tsx +++ b/src/renderer/src/pages/FilesPage.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +import { oneOf, usePersistentState } from "../shared/view-state"; +import { useMemo } from "react"; import { Button, Icon, Segmented } from "../shared/ui"; import { TopbarActions } from "../shared/TopbarActions"; import { FILE_SCAN_STATE, type FileObjectRecord, type FileScanState } from "../../../shared/domain"; @@ -20,6 +21,13 @@ import { type ViewMode, } from "./files/file-helpers"; +const isSection = (value: unknown): value is Section => { + if (typeof value !== "object" || value === null) return false; + const section = value as { kind?: unknown; value?: unknown }; + if (section.kind === "all" || section.kind === "recent" || section.kind === "pinned") return true; + return (section.kind === "department" || section.kind === "agent") && typeof section.value === "string"; +}; + export function FilesPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); const employees = snapshot.employees.filter((employee) => employee.orgId === orgId); @@ -30,9 +38,9 @@ export function FilesPage({ snapshot, actions }: PageProps) { () => snapshot.fileAccessGrants.filter((grant) => grant.orgId === orgId), [snapshot.fileAccessGrants, orgId], ); - const [section, setSection] = useState
({ kind: "all" }); - const [view, setView] = useState("list"); - const [scanFilter, setScanFilter] = useState("all"); + const [section, setSection] = usePersistentState
(`${orgId}:files.section`, { kind: "all" }, isSection); + const [view, setView] = usePersistentState(`${orgId}:files.view`, "list", oneOf(["grid", "list"] as const)); + const [scanFilter, setScanFilter] = usePersistentState(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string"); const departments = collectDepartments(files, agents); const filesByAgent = collectAgentFiles(files, agents); diff --git a/src/renderer/src/pages/LibraryPage.tsx b/src/renderer/src/pages/LibraryPage.tsx index e8f2d883..ec517829 100644 --- a/src/renderer/src/pages/LibraryPage.tsx +++ b/src/renderer/src/pages/LibraryPage.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +import { oneOf, usePersistentState } from "../shared/view-state"; +import { useMemo } from "react"; import { DocumentsPanel } from "./library/DocumentsPanel"; import { KnowledgePanel } from "./library/KnowledgePanel"; import type { PageProps } from "./types"; @@ -15,7 +16,7 @@ const TABS: Array<{ id: LibraryTab; label: string }> = [ export function LibraryPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); const employee = requireActiveEmployee(snapshot); - const [tab, setTab] = useState("documents"); + const [tab, setTab] = usePersistentState(`${orgId}:library.tab`, "documents", oneOf(["documents", "knowledge"] as const)); const documents = useMemo(() => inOrg(snapshot.documents, orgId), [snapshot.documents, orgId]); const entries = useMemo(() => inOrg(snapshot.knowledgeEntries, orgId), [snapshot.knowledgeEntries, orgId]); const tasks = useMemo(() => inOrg(snapshot.tasks, orgId), [snapshot.tasks, orgId]); diff --git a/src/renderer/src/pages/RunsPage.tsx b/src/renderer/src/pages/RunsPage.tsx index 95503c06..79149dca 100644 --- a/src/renderer/src/pages/RunsPage.tsx +++ b/src/renderer/src/pages/RunsPage.tsx @@ -1,3 +1,4 @@ +import { isIdOrNull, oneOf, usePersistentState } from "../shared/view-state"; import { requestStateLabel, runTriggerLabel } from "../i18n/labels"; import { RunRequestList } from "./runs/RunRequestList"; import { useRunRequests } from "./runs/use-run-requests"; @@ -8,7 +9,8 @@ import type { PageProps } from "./types"; import { activeOrgId, formatRelative } from "./types"; import { t } from "../i18n/i18n"; -type StatusFilter = "all" | "queued" | "running" | "completed" | "failed"; +const STATUS_FILTERS = ["all", "queued", "running", "completed", "failed"] as const; +type StatusFilter = (typeof STATUS_FILTERS)[number]; const FILTERS: Array<{ id: StatusFilter; label: string }> = [ { id: "all", label: t("All") }, { id: "queued", label: t("Queued") }, @@ -61,8 +63,9 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) { const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]); const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]); const providers = useMemo(() => snapshot.providers.filter((p) => p.orgId === orgId), [snapshot.providers, orgId]); - const [filter, setFilter] = useState("all"); - const [selectedId, setSelectedId] = useState(focus?.kind === "run" ? focus.id : null); + // A linked run is never hidden by a filter left from before. + const [filter, setFilter] = usePersistentState(`${orgId}:runs.filter`, "all", oneOf(STATUS_FILTERS), focus?.kind === "run" ? "all" : undefined); + const [selectedId, setSelectedId] = usePersistentState(`${orgId}:runs.selected`, null, isIdOrNull, focus?.kind === "run" ? focus.id : undefined); const [search, setSearch] = useState(""); const filtered = useMemo(() => { diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 300883a4..ccbc9e95 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ +import { oneOf, usePersistentState } from "../shared/view-state"; import { useLeaveGuard } from "../app/leave-guard"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { ProvidersPanel } from "./settings/ProvidersPanel"; import { WorkspacePanel } from "./settings/WorkspacePanel"; import { AdvancedPanel } from "./settings/AdvancedPanel"; @@ -8,7 +9,8 @@ import { t } from "../i18n/i18n"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types"; -type SettingsTab = "general" | "providers" | "integrations" | "advanced"; +const SETTINGS_TABS = ["general", "providers", "integrations", "advanced"] as const; +type SettingsTab = (typeof SETTINGS_TABS)[number]; interface NavSpec { id: SettingsTab; @@ -26,7 +28,8 @@ export function SettingsPage({ snapshot, actions, focus }: PageProps) { const orgId = activeOrgId(snapshot); const organization = requireActiveOrganization(snapshot); const employee = requireActiveEmployee(snapshot); - const [tab, setTab] = useState("general"); + // A workspace link opens General, where workspaces are, whatever section was left open. + const [tab, setTab] = usePersistentState(`${orgId}:settings.tab`, "general", oneOf(SETTINGS_TABS), focus?.kind === "workspace" ? "general" : undefined); const requestLeave = useLeaveGuard(); const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index 516e0e62..103031f8 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -1,3 +1,4 @@ +import { oneOf, usePersistentState } from "../shared/view-state"; import { agentModel } from "../../../shared/agent-model"; import { useMemo, useState, type FormEvent } from "react"; import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui"; @@ -24,7 +25,7 @@ export function TeamPage({ snapshot, actions }: PageProps) { const [hireProviderId, setHireProviderId] = useState(""); const [hireModelId, setHireModelId] = useState(""); const [editAgentId, setEditAgentId] = useState(null); - const [filter, setFilter] = useState<"all" | "human" | "agent">("all"); + const [filter, setFilter] = usePersistentState<"all" | "human" | "agent">(`${orgId}:team.filter`, "all", oneOf(["all", "human", "agent"] as const)); const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]); const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index 2336d331..f45fae2d 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -1,3 +1,4 @@ +import { oneOf, usePersistentState } from "../shared/view-state"; import { useMemo, useState } from "react"; import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui"; import { MenuDivider, MenuLabel, MenuRow, Popover } from "../shared/Popover"; @@ -25,7 +26,10 @@ import type { PageProps } from "./types"; import { activeOrgId, requireActiveOrganization, type Focus } from "./types"; import { t } from "../i18n/i18n"; -type View = "board" | "list" | "progress"; +const VIEWS = ["board", "list", "progress"] as const; +type View = (typeof VIEWS)[number]; +const isScope = (value: unknown): value is Focus | null => + value === null || (typeof value === "object" && (["goal", "project"] as unknown[]).includes((value as Focus).kind) && typeof (value as Focus).id === "string"); const COLUMNS: ReadonlyArray<{ id: TaskStatus; label: string; tone: string }> = [ { id: TASK_STATUS.BACKLOG, label: t("Backlog"), tone: "var(--text-4)" }, @@ -42,14 +46,14 @@ const VIEW_OPTIONS: ReadonlyArray<{ id: View; label: string }> = [ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) { const orgId = activeOrgId(snapshot); - const [view, setView] = useState("board"); + const [view, setView] = usePersistentState(`${orgId}:work.view`, "board", oneOf(VIEWS)); const [newTaskOpen, setNewTaskOpen] = useState(false); const [newGoalOpen, setNewGoalOpen] = useState(false); const [newProjectOpen, setNewProjectOpen] = useState(false); - const [priorityFilter, setPriorityFilter] = useState("all"); - const [assigneeFilter, setAssigneeFilter] = useState("all"); + const [priorityFilter, setPriorityFilter] = usePersistentState(`${orgId}:work.priority`, "all", oneOf(["all", "low", "normal", "high"] as const)); + const [assigneeFilter, setAssigneeFilter] = usePersistentState(`${orgId}:work.assignee`, "all", (value): value is string => typeof value === "string"); const [detailTaskId, setDetailTaskId] = useState(focus?.kind === "task" ? focus.id : null); - const [scope, setScope] = useState(focus && (focus.kind === "goal" || focus.kind === "project") ? focus : null); + const [scope, setScope] = usePersistentState(`${orgId}:work.scope`, null, isScope, focus && (focus.kind === "goal" || focus.kind === "project") ? focus : undefined); const [actionError, setActionError] = useState(null); // The board, its counts and its filters show real work; chat replies and goal checkpoints show on Messages, Home and Runs. diff --git a/src/renderer/src/pages/library/DocumentsPanel.tsx b/src/renderer/src/pages/library/DocumentsPanel.tsx index 29541c02..679d655c 100644 --- a/src/renderer/src/pages/library/DocumentsPanel.tsx +++ b/src/renderer/src/pages/library/DocumentsPanel.tsx @@ -1,3 +1,4 @@ +import { isIdOrNull, usePersistentState } from "../../shared/view-state"; import { DOCUMENT_STATUS_VALUES, documentStatusLabel } from "../../i18n/labels"; import { useEffect, useState, type FormEvent } from "react"; import type { DocumentRecord, TaskRecord } from "../../../../shared/domain"; @@ -21,7 +22,7 @@ export function DocumentsPanel({ tasks: TaskRecord[]; actions: SnapshotActions; }) { - const [selectedId, setSelectedId] = useState(null); + const [selectedId, setSelectedId] = usePersistentState(`${orgId}:library.document`, null, isIdOrNull); const selected = documents.find((doc) => doc.id === selectedId) ?? null; const [loaded, setLoaded] = useState<{ documentId: string; versions: DocumentVersionRecord[] } | null>(null); const [shownVersion, setShownVersion] = useState(null); diff --git a/src/renderer/src/pages/library/KnowledgePanel.tsx b/src/renderer/src/pages/library/KnowledgePanel.tsx index ce79fd10..dc6444a1 100644 --- a/src/renderer/src/pages/library/KnowledgePanel.tsx +++ b/src/renderer/src/pages/library/KnowledgePanel.tsx @@ -1,3 +1,4 @@ +import { isIdOrNull, oneOf, usePersistentState } from "../../shared/view-state"; import { useEffect, useState, type FormEvent } from "react"; import type { AgentRecord, GoalRecord, KnowledgeEntryRecord, ProjectRecord, TaskRecord } from "../../../../shared/domain"; import type { KnowledgeRevisionRecord, KnowledgeScopeType } from "../../../../shared/commands"; @@ -28,8 +29,8 @@ export function KnowledgePanel({ actions: SnapshotActions; }) { const now = new Date().toISOString(); - const [filter, setFilter] = useState("active"); - const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = usePersistentState(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const)); + const [selectedId, setSelectedId] = usePersistentState(`${orgId}:library.entry`, null, isIdOrNull); const [loaded, setLoaded] = useState<{ entryId: string; revisions: KnowledgeRevisionRecord[] } | null>(null); const [dialog, setDialog] = useState<"new" | "revise" | "delete" | null>(null); const [scopeType, setScopeType] = useState("organization"); diff --git a/src/renderer/src/shared/Shell.tsx b/src/renderer/src/shared/Shell.tsx index fda0948f..2e3747ac 100644 --- a/src/renderer/src/shared/Shell.tsx +++ b/src/renderer/src/shared/Shell.tsx @@ -1,3 +1,4 @@ +import type { RouteId } from "./routes"; import { shortcutLabel } from "../i18n/labels"; import { isBusinessTask } from "../pages/work/task-helpers"; import { unreadBySpace } from "../pages/chat/chat-helpers"; @@ -12,18 +13,8 @@ import { SearchDialog } from "./SearchDialog"; import { TopbarSlotContext } from "./topbar-context"; import { t } from "../i18n/i18n"; -export type RouteId = - | "home" - | "team" - | "work" - | "chat" - | "files" - | "library" - | "runs" - | "approvals" - | "connectors" - | "office" - | "settings"; +export type { RouteId }; + interface NavSpec { id: RouteId; diff --git a/src/renderer/src/shared/routes.ts b/src/renderer/src/shared/routes.ts new file mode 100644 index 00000000..b159fd0c --- /dev/null +++ b/src/renderer/src/shared/routes.ts @@ -0,0 +1,2 @@ +export const ROUTE_IDS = ["home", "team", "work", "chat", "files", "library", "runs", "approvals", "connectors", "office", "settings"] as const; +export type RouteId = (typeof ROUTE_IDS)[number]; diff --git a/src/renderer/src/shared/view-state.ts b/src/renderer/src/shared/view-state.ts new file mode 100644 index 00000000..c87db435 --- /dev/null +++ b/src/renderer/src/shared/view-state.ts @@ -0,0 +1,37 @@ +import { useState } from "react"; + +/** Where the operator was, by key; seeded once from the first snapshot and written through to the profile. */ +let saved: Record | null = null; + +export function seedViewState(initial: Record): void { + if (saved === null) saved = { ...initial }; +} + +export function readViewState(key: string, isValid: (value: unknown) => value is T): T | undefined { + if (saved === null) throw new Error("View state was read before the first snapshot."); + const value = saved[key]; + return isValid(value) ? value : undefined; +} + +export function writeViewState(key: string, value: unknown): void { + if (saved === null) throw new Error("View state was written before the first snapshot."); + saved[key] = value; + void window.agentCompany.saveViewState({ key, value }).then((result) => { + if (!result.ok) console.error(`[view-state] ${key} was not remembered: ${result.error.message}`); + }); +} + +/** State that survives page switches and restarts; a stored value that no longer fits falls back. `override` wins when a page is opened for an object. */ +export function usePersistentState(key: string, fallback: T, isValid: (value: unknown) => value is T, override?: T): [T, (next: T) => void] { + const [value, setValue] = useState(() => override ?? readViewState(key, isValid) ?? fallback); + const set = (next: T) => { + setValue(next); + writeViewState(key, next); + }; + return [value, set]; +} + +export const oneOf = (values: readonly T[]) => (value: unknown): value is T => + typeof value === "string" && (values as readonly string[]).includes(value); + +export const isIdOrNull = (value: unknown): value is string | null => value === null || typeof value === "string"; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 61599d61..bf1ee2c6 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -126,6 +126,8 @@ export interface DesktopApi { relaunchApp(): Promise>; /** Whether typed input would be lost if the app closed now; closing asks first while it is. */ reportUnsaved(input: { unsaved: boolean }): Promise>; + /** Remembers one piece of where the operator was; null forgets it. */ + saveViewState(input: { key: string; value: unknown }): Promise>; saveOnboardingDraft(input: { draft: OnboardingDraft | null }): Promise>; completeSetup(input: { orgId: string }): Promise>; /** Every agent session running now, in every company: what a restart interrupts. */ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index c5475824..74405731 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -2,6 +2,7 @@ export const IPC_CHANNELS = { loadSnapshot: "agentcompany:load-snapshot", relaunchApp: "agentcompany:relaunch-app", reportUnsaved: "agentcompany:report-unsaved", + saveViewState: "agentcompany:save-view-state", saveOnboardingDraft: "agentcompany:save-onboarding-draft", completeSetup: "agentcompany:complete-setup", runningSessionCount: "agentcompany:running-session-count", diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index af4eaf8c..41312bcf 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -73,6 +73,8 @@ export interface AppSnapshot { uiPreferences: UiPreferences; /** The setup wizard left unfinished, resumed on the next launch. */ onboardingDraft: OnboardingDraft | null; + /** Where the operator was: page, sections, filters and selected records, by key. */ + viewState: Record; /** True while the operator has paused the autonomous run engine. */ autonomousPaused: boolean; integrationKeys: IntegrationKeyStatus[]; diff --git a/tests/e2e/view-state.spec.ts b/tests/e2e/view-state.spec.ts new file mode 100644 index 00000000..ec59454d --- /dev/null +++ b/tests/e2e/view-state.spec.ts @@ -0,0 +1,67 @@ +import { _electron as electron, expect, test } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture"; +import { closeApp, onboard } from "./onboard"; + +test("the page, its section and its view survive page switches and a restart", async () => { + const profileDir = join(tmpdir(), `agentcompany-new-e2e-view-${randomUUID()}`); + mkdirSync(profileDir, { recursive: true }); + const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-view-builtin-${randomUUID()}`)); + const launch = () => electron.launch({ + args: [resolve("out/main/index.js")], + env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir }, + }); + + let app = await launch(); + let page = await app.firstWindow(); + await onboard(page, "View Co"); + await page.getByRole("button", { name: "Work" }).click(); + await page.locator(".segment", { hasText: "List" }).click(); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Providers" }).click(); + // Coming back to a page returns to where it was left. + await page.getByRole("button", { name: "Work" }).click(); + await expect(page.locator(".segment.active")).toHaveText("List"); + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByRole("button", { name: "Providers" })).toHaveClass(/active/); + // A link to a workspace still lands on General, where the workspace is shown. + const folder = join(tmpdir(), `agentcompany-new-e2e-view-ws-${randomUUID()}`); + mkdirSync(folder, { recursive: true }); + await page.evaluate(async (localPath) => { + const api = (window as unknown as { agentCompany: { + loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; workspaces: Array<{ id: string; name: string }> } }>; + createWorkspace: (input: unknown) => Promise<{ ok: boolean }>; + createTask: (input: unknown) => Promise<{ ok: boolean }>; + } }).agentCompany; + const orgId = (await api.loadSnapshot()).data.activeOrgId; + await api.createWorkspace({ orgId, name: "Linked repo", localPath, projectId: null }); + const workspaceId = (await api.loadSnapshot()).data.workspaces.find((workspace) => workspace.name === "Linked repo")!.id; + await api.createTask({ + orgId, title: "Uses the linked repo", description: "d", status: "backlog", assigneeAgentId: null, risk: "low", + projectId: null, goalId: null, workspaceId, priority: "normal", requiresFilesystem: false, + }); + }, folder); + await page.getByRole("button", { name: "Work" }).click(); + await page.getByRole("button", { name: /Uses the linked repo/ }).first().click(); + await page.getByRole("button", { name: "Open workspace Linked repo" }).click(); + await expect(page.getByRole("button", { name: "General" })).toHaveClass(/active/); + await expect(page.getByRole("group", { name: "Linked repo" })).toBeVisible(); + await page.getByRole("button", { name: "Providers" }).click(); + await expect.poll(() => page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { viewState: Record } }> } }).agentCompany; + return (await api.loadSnapshot()).data.viewState.route; + })).toBe("settings"); + await closeApp(app); + + // A restart opens the same page and section, and the view the work was left in. + app = await launch(); + page = await app.firstWindow(); + await expect(page.getByRole("button", { name: "Providers" })).toHaveClass(/active/); + await expect(page.getByRole("button", { name: "Settings" })).toHaveAttribute("aria-current", "page"); + await page.getByRole("button", { name: "Work" }).click(); + await expect(page.locator(".segment.active")).toHaveText("List"); + await closeApp(app); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 07eaf6e2..a27fc224 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -35,7 +35,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106]); connection.close(); }); diff --git a/tests/unit/view-state.test.ts b/tests/unit/view-state.test.ts new file mode 100644 index 00000000..f4d87201 --- /dev/null +++ b/tests/unit/view-state.test.ts @@ -0,0 +1,21 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { loadViewState, saveViewState } from "../../src/main/services/view-state"; + +describe("view state", () => { + test("keeps each key's latest value, including keys with dots and colons, and forgets on null", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-view-state-${randomUUID()}.db`)); + const db = connection.db; + expect(loadViewState(db)).toEqual({}); + saveViewState(db, "route", "work"); + saveViewState(db, "org_1:work.view", "list"); + saveViewState(db, "org_1:work.scope", { kind: "goal", id: "gol_1" }); + saveViewState(db, "route", "settings"); + expect(loadViewState(db)).toEqual({ route: "settings", "org_1:work.view": "list", "org_1:work.scope": { kind: "goal", id: "gol_1" } }); + saveViewState(db, "org_1:work.scope", null); + expect(loadViewState(db)).toEqual({ route: "settings", "org_1:work.view": "list" }); + }); +});