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
@@ -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";
|
||||
Reference in new issue
Block a user