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:
1 parent
2790d88186
commit
72cf57ce3a
27 files changed
+235
-38
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
@@ -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";
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user