Add org switcher with create, switch, and delete

The sidebar org button was inert: clicking it did nothing. Add a real
dropdown menu listing every organization, a "New organization" entry
that re-enters onboarding without resetting the database, and a per-org
delete affordance with inline confirmation. Active org is now persisted
in a single-row app_state table so the snapshot keeps a stable
selection across restarts and the renderer drives switching via two new
IPC channels (setActiveOrganization, deleteOrganization).

- migration 006 adds app_state(id=1, active_org_id, updated_at)
- snapshot resolves active org from app_state with the first-org fallback
- OrganizationService gains setActive + delete + bootstrap upsert
- new OrgSwitcher renderer component keeps Shell.tsx lean
This commit is contained in:
ZacharyZhang-NY committed 2026-05-21 21:30:00 -04:00
1 parent 060fddf852
commit f2ac1be5de
14 files changed
+385 -16

No files matched your search

@@ -67,6 +67,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
});
ipcMain.handle(IPC_CHANNELS.createOrganization, (_event, input) => mutate(() => organization.createOrganization(input)));
ipcMain.handle(IPC_CHANNELS.setActiveOrganization, (_event, input) => mutate(() => organization.setActiveOrganization(input.orgId)));
ipcMain.handle(IPC_CHANNELS.deleteOrganization, (_event, input) => mutate(() => organization.deleteOrganization(input.orgId)));
ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input)));
ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input)));
ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
@@ -0,0 +1,14 @@
export const appStateSchema = `
create table if not exists app_state (
id integer primary key check (id = 1),
active_org_id text references organizations(id) on delete set null,
updated_at text not null
);
insert into app_state(id, active_org_id, updated_at)
select 1, (select id from organizations order by created_at asc limit 1), datetime('now')
where not exists (select 1 from app_state where id = 1);
insert into schema_migrations(version, name, applied_at)
values (6, 'app_state', datetime('now'));
`;
@@ -4,6 +4,7 @@ import { collaborationProviderPolicySchema } from "./migrations/002-collaboratio
import { onboardingExecutionSchema } from "./migrations/003-onboarding";
import { filesPinnedAndScheduledPosts } from "./migrations/004-files-posts";
import { chatReadState } from "./migrations/005-chat-read-state";
import { appStateSchema } from "./migrations/006-app-state";
const MIGRATIONS: Array<{ version: number; sql: string }> = [
{ version: 1, sql: initialEnterpriseSchema },
@@ -11,6 +12,7 @@ const MIGRATIONS: Array<{ version: number; sql: string }> = [
{ version: 3, sql: onboardingExecutionSchema },
{ version: 4, sql: filesPinnedAndScheduledPosts },
{ version: 5, sql: chatReadState },
{ version: 6, sql: appStateSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -74,6 +74,11 @@ export class OrganizationService {
}
this.policy.seedOrganizationPolicies(orgId, now);
this.db.prepare(`
insert into app_state(id, active_org_id, updated_at)
values (1, ?, ?)
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
`).run(orgId, now);
this.audit.record({
orgId,
entityType: "organization",
@@ -86,6 +91,41 @@ export class OrganizationService {
return orgId;
}
setActiveOrganization(orgId: string): void {
const row = this.db.prepare("select id from organizations where id = ?").get(orgId) as { id: string } | undefined;
if (!row) throw new Error("Organization was not found.");
const now = new Date().toISOString();
this.db.prepare(`
insert into app_state(id, active_org_id, updated_at)
values (1, ?, ?)
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
`).run(orgId, now);
this.audit.record({
orgId,
entityType: "app_state",
entityId: "active_org",
actorType: "employee",
actorId: null,
action: "switched",
payload: { orgId },
});
}
deleteOrganization(orgId: string): void {
const row = this.db.prepare("select id from organizations where id = ?")
.get(orgId) as { id: string } | undefined;
if (!row) throw new Error("Organization was not found.");
this.db.prepare("delete from organizations where id = ?").run(orgId);
const fallback = this.db.prepare("select id from organizations order by created_at asc limit 1")
.get() as { id: string } | undefined;
const now = new Date().toISOString();
this.db.prepare(`
insert into app_state(id, active_org_id, updated_at)
values (1, ?, ?)
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
`).run(fallback?.id ?? null, now);
}
createAgent(input: CreateAgentInput): string {
const id = createId("agt");
const now = new Date().toISOString();
@@ -34,7 +34,7 @@ export class SnapshotService {
autoApproveHires: toBool(org.autoApproveHires),
reviewDeliverables: toBool(org.reviewDeliverables),
}));
const activeOrgId = organizations[0]?.id ?? null;
const activeOrgId = this.resolveActiveOrgId(organizations.map((org) => org.id));
const agents = list<AppSnapshot["agents"][number]>(this.db, "agents", "created_at asc")
.map((agent) => ({ ...agent, heartbeatEnabled: toBool(agent.heartbeatEnabled) }));
const tasks = list<AppSnapshot["tasks"][number]>(this.db, "tasks", "created_at asc")
@@ -82,4 +82,12 @@ export class SnapshotService {
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
};
}
private resolveActiveOrgId(known: string[]): string | null {
const row = this.db.prepare("select active_org_id from app_state where id = 1")
.get() as { active_org_id: string | null } | undefined;
const stored = row?.active_org_id ?? null;
if (stored && known.includes(stored)) return stored;
return known[0] ?? null;
}
}
+2
View File
@@ -10,6 +10,8 @@ const api: DesktopApi = {
loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot),
bootstrapOnboarding: (input) => invoke(IPC_CHANNELS.bootstrapOnboarding, input),
createOrganization: (input) => invoke(IPC_CHANNELS.createOrganization, input),
setActiveOrganization: (input) => invoke(IPC_CHANNELS.setActiveOrganization, input),
deleteOrganization: (input) => invoke(IPC_CHANNELS.deleteOrganization, input),
createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input),
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
+18 -3
View File
@@ -14,12 +14,20 @@ import { SettingsPage } from "../pages/SettingsPage";
export function App() {
const { snapshot, actions } = useSnapshot();
const [route, setRoute] = useState<RouteId>("home");
const [creatingOrg, setCreatingOrg] = useState(false);
if (!snapshot) {
return <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
}
if (snapshot.organizations.length === 0) {
return <Onboarding snapshot={snapshot} actions={actions} />;
if (snapshot.organizations.length === 0 || creatingOrg) {
return (
<Onboarding
snapshot={snapshot}
actions={actions}
onCancel={creatingOrg ? () => setCreatingOrg(false) : null}
onCompleted={() => setCreatingOrg(false)}
/>
);
}
const pageProps = { snapshot, actions };
@@ -35,7 +43,14 @@ export function App() {
}[route];
return (
<Shell snapshot={snapshot} route={route} setRoute={setRoute}>
<Shell
snapshot={snapshot}
route={route}
setRoute={setRoute}
onSwitchOrg={(orgId) => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId }))}
onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))}
onNewOrg={() => setCreatingOrg(true)}
>
{page}
</Shell>
);
@@ -30,7 +30,17 @@ const STEPS: Array<{ id: Step; label: string }> = [
{ id: "mission", label: "Goal" },
];
export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actions: SnapshotActions }) {
export function Onboarding({
snapshot,
actions,
onCancel,
onCompleted,
}: {
snapshot: AppSnapshot;
actions: SnapshotActions;
onCancel: (() => void) | null;
onCompleted: () => void;
}) {
const apiProviders = useMemo(
() => snapshot.providerCatalog.filter((entry) => entry.authType === PROVIDER_AUTH_TYPE.API_KEY),
[snapshot.providerCatalog],
@@ -102,6 +112,7 @@ export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actio
return;
}
await actions.reload();
onCompleted();
};
return (
@@ -111,6 +122,11 @@ export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actio
<div className="row" style={{ gap: 8 }}>
<div className="brand-mark">A</div>
<div style={{ fontWeight: 540 }}>AgentCompany</div>
{onCancel && (
<button type="button" className="button ghost" style={{ marginLeft: "auto" }} onClick={onCancel}>
Cancel
</button>
)}
</div>
<h1 className="page-h1" style={{ fontSize: 34, marginTop: 32, letterSpacing: "-0.02em" }}>Start your AI company.</h1>
<p className="page-sub" style={{ maxWidth: 460, fontSize: 14, lineHeight: 1.55 }}>
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from "react";
import type { AppSnapshot, OrganizationRecord } from "../../../shared/domain";
import { Icon } from "./Icon";
interface OrgSwitcherProps {
snapshot: AppSnapshot;
activeOrgId: string;
onSelect: (orgId: string) => void;
onNew: () => void;
onDelete: (orgId: string) => void;
}
export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }: OrgSwitcherProps) {
const [open, setOpen] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const active = snapshot.organizations.find((org) => org.id === activeOrgId);
if (!active) throw new Error("Active organization missing from snapshot.");
useEffect(() => {
if (!open) return;
function onDocClick(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
setConfirmingDelete(null);
}
}
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
const initial = active.name.trim().charAt(0).toUpperCase() || "A";
const orgs = snapshot.organizations;
const handleSelect = (orgId: string) => {
setOpen(false);
setConfirmingDelete(null);
if (orgId !== activeOrgId) onSelect(orgId);
};
const handleDelete = (orgId: string) => {
setOpen(false);
setConfirmingDelete(null);
onDelete(orgId);
};
return (
<div ref={containerRef} style={{ position: "relative" }}>
<button
type="button"
className="sidebar-org"
aria-label={`${active.name} — switch organization`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<span className="brand-mark">{initial}</span>
<span className="brand-name">{active.name}</span>
<Icon name="chevronDown" size={12} color="var(--text-3)" />
</button>
{open && (
<div role="menu" className="org-menu">
<div className="org-menu-section-label">Organizations</div>
{orgs.map((org) => (
<OrgRow
key={org.id}
org={org}
active={org.id === activeOrgId}
onSelect={() => handleSelect(org.id)}
confirming={confirmingDelete === org.id}
onAskDelete={() => setConfirmingDelete(org.id)}
onCancelDelete={() => setConfirmingDelete(null)}
onConfirmDelete={() => handleDelete(org.id)}
canDelete={orgs.length > 1}
/>
))}
<div className="org-menu-divider" />
<button
type="button"
role="menuitem"
className="org-menu-action"
onClick={() => {
setOpen(false);
setConfirmingDelete(null);
onNew();
}}
>
<Icon name="plus" size={13} color="var(--text-2)" />
<span>New organization</span>
</button>
</div>
)}
</div>
);
}
function OrgRow({
org,
active,
onSelect,
confirming,
onAskDelete,
onCancelDelete,
onConfirmDelete,
canDelete,
}: {
org: OrganizationRecord;
active: boolean;
onSelect: () => void;
confirming: boolean;
onAskDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
canDelete: boolean;
}) {
const initial = org.name.trim().charAt(0).toUpperCase() || "A";
if (confirming) {
return (
<div className="org-menu-row confirm">
<span className="brand-mark sm">{initial}</span>
<div className="org-menu-confirm-text">
<div>Delete {org.name}?</div>
<div className="org-menu-confirm-hint">All agents, work, chats, files removed.</div>
</div>
<button type="button" className="org-menu-confirm-yes" onClick={onConfirmDelete}>Delete</button>
<button type="button" className="org-menu-confirm-no" onClick={onCancelDelete}>Cancel</button>
</div>
);
}
return (
<button
type="button"
role="menuitem"
className={`org-menu-row ${active ? "active" : ""}`.trim()}
onClick={onSelect}
>
<span className="brand-mark sm">{initial}</span>
<span className="org-menu-name">{org.name}</span>
{active && <Icon name="check" size={12} color="var(--text-1)" />}
{!active && canDelete && (
<span
role="button"
aria-label={`Delete ${org.name}`}
className="org-menu-delete"
onClick={(event) => {
event.stopPropagation();
onAskDelete();
}}
>
<Icon name="x" size={12} color="var(--text-3)" />
</span>
)}
</button>
);
}
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import { Avatar, Icon, IconButton } from "./ui";
import type { IconName } from "./Icon";
import { OrgSwitcher } from "./OrgSwitcher";
export type RouteId =
| "home"
@@ -34,12 +35,18 @@ export function Shell({
route,
setRoute,
actions,
onSwitchOrg,
onDeleteOrg,
onNewOrg,
children,
}: {
snapshot: AppSnapshot;
route: RouteId;
setRoute: (route: RouteId) => void;
actions?: ReactNode;
onSwitchOrg: (orgId: string) => void;
onDeleteOrg: (orgId: string) => void;
onNewOrg: () => void;
children: ReactNode;
}) {
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
@@ -48,21 +55,17 @@ export function Shell({
if (!employee) throw new Error("Active employee is missing.");
const badges = computeBadges(snapshot, organization.id);
const activeLabel = labelFor(route);
const orgInitial = organization.name.trim().charAt(0).toUpperCase() || "A";
return (
<div className="app-shell">
<aside className="sidebar">
<button
type="button"
className="sidebar-org"
aria-label={`${organization.name} — switch organization`}
title={organization.name}
>
<span className="brand-mark">{orgInitial}</span>
<span className="brand-name">{organization.name}</span>
<Icon name="chevronDown" size={12} color="var(--text-3)" />
</button>
<OrgSwitcher
snapshot={snapshot}
activeOrgId={organization.id}
onSelect={onSwitchOrg}
onNew={onNewOrg}
onDelete={onDeleteOrg}
/>
<button type="button" className="search-bar" aria-label="Search">
<Icon name="search" size={13} />
<span>Search</span>
@@ -150,6 +150,104 @@ a { color: inherit; }
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.org-menu {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 30;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 220px;
}
.org-menu-section-label {
font-size: 11px;
color: var(--text-3);
font-weight: 540;
letter-spacing: 0.02em;
padding: 4px 8px 4px;
}
.org-menu-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-2);
font-size: 13px;
text-align: left;
width: 100%;
}
.org-menu-row:hover { background: var(--hover); color: var(--text-1); }
.org-menu-row.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
.org-menu-row.confirm {
background: var(--amber-soft);
flex-wrap: wrap;
padding: 8px;
}
.org-menu-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.org-menu-delete {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
cursor: pointer;
opacity: 0;
}
.org-menu-row:hover .org-menu-delete { opacity: 1; }
.org-menu-delete:hover { background: var(--panel-3); }
.org-menu-confirm-text { flex: 1; min-width: 0; font-size: 12.5px; color: var(--text-1); }
.org-menu-confirm-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.org-menu-confirm-yes {
height: 24px;
padding: 0 8px;
border: 0;
border-radius: var(--r-sm);
background: var(--red);
color: #fff;
font-size: 12px;
font-weight: 540;
}
.org-menu-confirm-no {
height: 24px;
padding: 0 8px;
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
background: var(--panel);
color: var(--text-2);
font-size: 12px;
}
.org-menu-divider { height: 1px; background: var(--border-soft); margin: 4px 0; }
.org-menu-action {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-2);
font-size: 13px;
text-align: left;
width: 100%;
}
.org-menu-action:hover { background: var(--hover); color: var(--text-1); }
.brand-mark.sm {
width: 18px;
height: 18px;
font-size: 10px;
border-radius: 4px;
}
.sidebar-spacer { flex: 1; }
.sidebar-foot {
padding: 10px 8px 4px;
+8
View File
@@ -6,6 +6,14 @@ import type {
TaskStatus,
} from "./domain";
export interface SetActiveOrganizationInput {
orgId: string;
}
export interface DeleteOrganizationInput {
orgId: string;
}
export interface CreateOrganizationInput {
organizationName: string;
description?: string;
@@ -15,12 +15,14 @@ import type {
CreateSocialAccountInput,
CreateTaskInput,
DecideApprovalInput,
DeleteOrganizationInput,
ImportFileInput,
ImportSkillDirectoryInput,
LaunchSocialLoginInput,
ResolveAgentSkillsInput,
SaveProviderCredentialInput,
SendMessageInput,
SetActiveOrganizationInput,
SetFilePinnedInput,
SetScheduledPostStateInput,
UpdateTaskStatusInput,
@@ -33,6 +35,8 @@ export interface RendererEvent {
export interface DesktopApi {
loadSnapshot(): Promise<DesktopResult<AppSnapshot>>;
bootstrapOnboarding(input: BootstrapOnboardingInput): Promise<DesktopResult<BootstrapOnboardingSummary>>;
setActiveOrganization(input: SetActiveOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
deleteOrganization(input: DeleteOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
createOrganization(input: CreateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
+2
View File
@@ -1,6 +1,8 @@
export const IPC_CHANNELS = {
loadSnapshot: "agentcompany:load-snapshot",
bootstrapOnboarding: "agentcompany:bootstrap-onboarding",
setActiveOrganization: "agentcompany:set-active-organization",
deleteOrganization: "agentcompany:delete-organization",
createOrganization: "agentcompany:create-organization",
createAgent: "agentcompany:create-agent",
createTask: "agentcompany:create-task",