Q184: the page, its sections, filters and selections survive page switches and restarts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 07:46:21 -04:00
1 parent 2790d88186
commit 72cf57ce3a
27 files changed
+235 -38

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType<InputOf<K>> } = {
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,
+9
View File
@@ -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);
@@ -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'));
`;
+2
View File
@@ -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;
+2
View File
@@ -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,
+20
View File
@@ -0,0 +1,20 @@
import type { DatabaseSync } from "node:sqlite";
export function loadViewState(db: DatabaseSync): Record<string, unknown> {
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<string, unknown> : {};
}
/** 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));
}
+1
View File
@@ -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),
+9 -2
View File
@@ -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<RouteId>("home");
const [routeChoice, setRoute] = useState<RouteId | null>(null);
const [focus, setFocus] = useState<Focus | null>(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 <StartupScreen failure={syncFailure} onRetry={() => 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) {
+3 -1
View File
@@ -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<RiskFilter>("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<RiskFilter>(`${orgId}:approvals.filter`, "all", oneOf(["all", "low", "medium", "high"] as const), focusedApproval ? "all" : undefined);
const [editingId, setEditingId] = useState<string | null>(
focusedApproval && snapshot.approvals.some((item) => item.id === focusedApproval && item.state === "pending") ? focusedApproval : null,
);
+2 -1
View File
@@ -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<string | null>(null);
const [selectedSpaceId, setSelectedSpaceId] = usePersistentState<string | null>(`${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.
+12 -4
View File
@@ -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<Section>({ kind: "all" });
const [view, setView] = useState<ViewMode>("list");
const [scanFilter, setScanFilter] = useState<FileScanState | "all">("all");
const [section, setSection] = usePersistentState<Section>(`${orgId}:files.section`, { kind: "all" }, isSection);
const [view, setView] = usePersistentState<ViewMode>(`${orgId}:files.view`, "list", oneOf(["grid", "list"] as const));
const [scanFilter, setScanFilter] = usePersistentState<FileScanState | "all">(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string");
const departments = collectDepartments(files, agents);
const filesByAgent = collectAgentFiles(files, agents);
+3 -2
View File
@@ -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<LibraryTab>("documents");
const [tab, setTab] = usePersistentState<LibraryTab>(`${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]);
+6 -3
View File
@@ -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<StatusFilter>("all");
const [selectedId, setSelectedId] = useState<string | null>(focus?.kind === "run" ? focus.id : null);
// A linked run is never hidden by a filter left from before.
const [filter, setFilter] = usePersistentState<StatusFilter>(`${orgId}:runs.filter`, "all", oneOf(STATUS_FILTERS), focus?.kind === "run" ? "all" : undefined);
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:runs.selected`, null, isIdOrNull, focus?.kind === "run" ? focus.id : undefined);
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
+6 -3
View File
@@ -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<SettingsTab>("general");
// A workspace link opens General, where workspaces are, whatever section was left open.
const [tab, setTab] = usePersistentState<SettingsTab>(`${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]);
+2 -1
View File
@@ -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<string | null>(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]);
+9 -5
View File
@@ -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<View>("board");
const [view, setView] = usePersistentState<View>(`${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<TaskPriority | "all">("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const [priorityFilter, setPriorityFilter] = usePersistentState<TaskPriority | "all">(`${orgId}:work.priority`, "all", oneOf(["all", "low", "normal", "high"] as const));
const [assigneeFilter, setAssigneeFilter] = usePersistentState<string>(`${orgId}:work.assignee`, "all", (value): value is string => typeof value === "string");
const [detailTaskId, setDetailTaskId] = useState<string | null>(focus?.kind === "task" ? focus.id : null);
const [scope, setScope] = useState<Focus | null>(focus && (focus.kind === "goal" || focus.kind === "project") ? focus : null);
const [scope, setScope] = usePersistentState<Focus | null>(`${orgId}:work.scope`, null, isScope, focus && (focus.kind === "goal" || focus.kind === "project") ? focus : undefined);
const [actionError, setActionError] = useState<string | null>(null);
// The board, its counts and its filters show real work; chat replies and goal checkpoints show on Messages, Home and Runs.
@@ -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<string | null>(null);
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${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<number | null>(null);
@@ -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<EntryState>("active");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = usePersistentState<EntryState>(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const));
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${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<KnowledgeScopeType>("organization");
+3 -12
View File
@@ -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;
+2
View File
@@ -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];
+37
View File
@@ -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<string, unknown> | null = null;
export function seedViewState(initial: Record<string, unknown>): void {
if (saved === null) saved = { ...initial };
}
export function readViewState<T>(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<T>(key: string, fallback: T, isValid: (value: unknown) => value is T, override?: T): [T, (next: T) => void] {
const [value, setValue] = useState<T>(() => override ?? readViewState(key, isValid) ?? fallback);
const set = (next: T) => {
setValue(next);
writeViewState(key, next);
};
return [value, set];
}
export const oneOf = <T extends string>(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";
+2
View File
@@ -126,6 +126,8 @@ export interface DesktopApi {
relaunchApp(): Promise<DesktopResult<null>>;
/** Whether typed input would be lost if the app closed now; closing asks first while it is. */
reportUnsaved(input: { unsaved: boolean }): Promise<DesktopResult<null>>;
/** Remembers one piece of where the operator was; null forgets it. */
saveViewState(input: { key: string; value: unknown }): Promise<DesktopResult<null>>;
saveOnboardingDraft(input: { draft: OnboardingDraft | null }): Promise<DesktopResult<null>>;
completeSetup(input: { orgId: string }): Promise<DesktopResult<AppSnapshot>>;
/** Every agent session running now, in every company: what a restart interrupts. */
+1
View File
@@ -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",
+2
View File
@@ -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<string, unknown>;
/** True while the operator has paused the autonomous run engine. */
autonomousPaused: boolean;
integrationKeys: IntegrationKeyStatus[];
+67
View File
@@ -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<string, unknown> } }> } }).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);
});
+1 -1
View File
@@ -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();
});
+21
View File
@@ -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" });
});
});