diff --git a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx
index b559730ba3..62705d3984 100644
--- a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx
+++ b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx
@@ -74,7 +74,8 @@ export function TimelineView() {
variant="outline"
onClick={scrollToToday}
className={clsx([
- "flex items-center gap-1",
+ "group",
+ "relative",
"absolute left-1/2 transform -translate-x-1/2",
"bg-white hover:bg-gray-50",
"border border-gray-200",
@@ -83,8 +84,17 @@ export function TimelineView() {
isScrolledPastToday ? "top-2" : "bottom-2",
])}
>
-
- Go to Today
+
+
+ Go to Today
+
+
)}
diff --git a/apps/desktop2/src/devtool/index.tsx b/apps/desktop2/src/devtool/index.tsx
index 2b0b44151d..aef3c661b0 100644
--- a/apps/desktop2/src/devtool/index.tsx
+++ b/apps/desktop2/src/devtool/index.tsx
@@ -1,9 +1,10 @@
+import { Link } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { useStores } from "tinybase/ui-react";
import { cn } from "@hypr/ui/lib/utils";
import { METRICS, type Store as PersistedStore, STORE_ID as STORE_ID_PERSISTED, UI } from "../store/tinybase/persisted";
-import { type SeedDefinition, seeds } from "./seed/index";
+import { SeedDefinition, seeds } from "./seed/index";
declare global {
interface Window {
@@ -16,7 +17,6 @@ declare global {
export function Devtool() {
const [open, setOpen] = useState(false);
- const [lastSeedId, setLastSeedId] = useState(null);
const autoSeedRef = useRef(false);
const stores = useStores();
@@ -43,7 +43,6 @@ export function Devtool() {
}
autoSeedRef.current = true;
seed.run(persistedStore);
- setLastSeedId(seed.id);
}, [humansCount, persistedStore]);
useEffect(() => {
@@ -58,7 +57,6 @@ export function Devtool() {
const target = id ? seeds.find(item => item.id === id) : seeds[0];
if (target) {
target.run(persistedStore);
- setLastSeedId(target.id);
}
},
seeds: seeds.map(({ id, label }) => ({ id, label })),
@@ -81,7 +79,6 @@ export function Devtool() {
return;
}
seed.run(persistedStore);
- setLastSeedId(seed.id);
},
[persistedStore],
);
@@ -97,7 +94,7 @@ export function Devtool() {
return (
<>
-
+
>
);
}
@@ -124,12 +121,7 @@ function DevtoolTrigger({ open, onToggle }: {
);
}
-function DevtoolDrawer({ open, lastSeedId, humansCount, onSeed }: {
- open: boolean;
- lastSeedId: string | null;
- humansCount: number;
- onSeed: (seed: SeedDefinition) => void;
-}) {
+function DevtoolDrawer({ open, onSeed }: { open: boolean; onSeed: (seed: SeedDefinition) => void }) {
return (
);
}
+
+function DevtoolSection({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function NavigationList() {
+ return (
+
+
+
+ );
+}
+
+function SeedList({ onSeed }: { onSeed: (seed: SeedDefinition) => void }) {
+ return (
+
+
+ {seeds.map(seed => onSeed(seed)} />)}
+
+
+ );
+}
+
+function SeedButton({ seed, onClick }: { seed: SeedDefinition; onClick: () => void }) {
+ return (
+
+ );
+}
diff --git a/apps/desktop2/src/routes/app/main/_layout.tsx b/apps/desktop2/src/routes/app/main/_layout.tsx
index e2e7d9a0d7..fff694ca5c 100644
--- a/apps/desktop2/src/routes/app/main/_layout.tsx
+++ b/apps/desktop2/src/routes/app/main/_layout.tsx
@@ -17,6 +17,22 @@ export const Route = createFileRoute("/app/main/_layout")({
function Component() {
const { persistedStore } = useRouteContext({ from: "__root__" });
+ const { registerOnClose } = useTabs();
+
+ useEffect(() => {
+ return registerOnClose((tab) => {
+ if (tab.type === "sessions" && persistedStore) {
+ const row = persistedStore.getRow("sessions", tab.id);
+ if (!row) {
+ return;
+ }
+
+ if (!row.title && !row.raw_md && !row.enhanced_md) {
+ persistedStore.delRow("sessions", tab.id);
+ }
+ }
+ });
+ }, [persistedStore, registerOnClose]);
return (
diff --git a/apps/desktop2/src/store/zustand/tabs.ts b/apps/desktop2/src/store/zustand/tabs.ts
deleted file mode 100644
index 2523fce277..0000000000
--- a/apps/desktop2/src/store/zustand/tabs.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-import { z } from "zod";
-import { create } from "zustand";
-
-import { TABLES } from "../tinybase/persisted";
-
-type TabHistory = {
- stack: Tab[];
- currentIndex: number;
-};
-
-type State = {
- currentTab: Tab | null;
- tabs: Tab[];
- history: Map;
- canGoBack: boolean;
- canGoNext: boolean;
-};
-
-type Actions =
- & TabUpdater
- & TabStateUpdater
- & TabNavigator;
-
-type TabUpdater = {
- setTabs: (tabs: Tab[]) => void;
- openCurrent: (tab: Tab) => void;
- openNew: (tab: Tab) => void;
- select: (tab: Tab) => void;
- close: (tab: Tab) => void;
- reorder: (tabs: Tab[]) => void;
-};
-
-type TabStateUpdater = {
- updateContactsTabState: (tab: Tab, state: Extract["state"]) => void;
- updateSessionTabState: (tab: Tab, state: Extract["state"]) => void;
-};
-
-type TabNavigator = {
- goBack: () => void;
- goNext: () => void;
-};
-
-type Store = State & Actions;
-
-const ACTIVE_TAB_SLOT_ID = "active-tab-history";
-
-const getSlotId = (tab: Tab): string => {
- return tab.active ? ACTIVE_TAB_SLOT_ID : `inactive-${uniqueIdfromTab(tab)}`;
-};
-
-const computeHistoryFlags = (
- history: Map,
- currentTab: Tab | null,
-): { canGoBack: boolean; canGoNext: boolean } => {
- if (!currentTab) {
- return { canGoBack: false, canGoNext: false };
- }
- const slotId = getSlotId(currentTab);
- const tabHistory = history.get(slotId);
- if (!tabHistory) {
- return { canGoBack: false, canGoNext: false };
- }
- return {
- canGoBack: tabHistory.currentIndex > 0,
- canGoNext: tabHistory.currentIndex < tabHistory.stack.length - 1,
- };
-};
-
-const pushHistory = (history: Map, tab: Tab): Map => {
- const newHistory = new Map(history);
- const slotId = getSlotId(tab);
- const existing = newHistory.get(slotId);
-
- if (existing) {
- const newStack = existing.stack.slice(0, existing.currentIndex + 1);
- newStack.push(tab);
- newHistory.set(slotId, { stack: newStack, currentIndex: newStack.length - 1 });
- } else {
- newHistory.set(slotId, { stack: [tab], currentIndex: 0 });
- }
-
- return newHistory;
-};
-
-const updateHistoryCurrent = (history: Map, tab: Tab): Map => {
- const newHistory = new Map(history);
- const slotId = getSlotId(tab);
- const existing = newHistory.get(slotId);
-
- if (existing && existing.currentIndex >= 0) {
- const newStack = [...existing.stack];
- newStack[existing.currentIndex] = tab;
- newHistory.set(slotId, { ...existing, stack: newStack });
- }
-
- return newHistory;
-};
-
-export const useTabs = create((set, get, _store) => ({
- currentTab: null,
- tabs: [],
- history: new Map(),
- canGoBack: false,
- canGoNext: false,
- setTabs: (tabs) => {
- const tabsWithDefaults = tabs.map(t => tabSchema.parse(t));
- const currentTab = tabsWithDefaults.find((t) => t.active) || null;
- const history = new Map();
-
- tabsWithDefaults.forEach((tab) => {
- if (tab.active) {
- history.set(getSlotId(tab), { stack: [tab], currentIndex: 0 });
- }
- });
-
- const flags = computeHistoryFlags(history, currentTab);
- set({ tabs: tabsWithDefaults, currentTab, history, ...flags });
- },
- openCurrent: (newTab) => {
- const { tabs, history } = get();
- const tabWithDefaults = tabSchema.parse(newTab);
- const activeTab = { ...tabWithDefaults, active: true };
- const existingTabIdx = tabs.findIndex((t) => t.active);
-
- const nextTabs = existingTabIdx === -1
- ? tabs
- .filter((t) => !isSameTab(t, tabWithDefaults))
- .map((t) => ({ ...t, active: false }))
- .concat([activeTab])
- : tabs
- .map((t, idx) =>
- idx === existingTabIdx
- ? activeTab
- : isSameTab(t, tabWithDefaults)
- ? null
- : { ...t, active: false }
- )
- .filter((t): t is Tab => t !== null);
-
- const nextHistory = pushHistory(history, activeTab);
- const flags = computeHistoryFlags(nextHistory, activeTab);
- set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags });
- },
- openNew: (tab) => {
- const { tabs, history } = get();
- const tabWithDefaults = tabSchema.parse(tab);
- const activeTab = { ...tabWithDefaults, active: true };
- const nextTabs = tabs
- .filter((t) => !isSameTab(t, tabWithDefaults))
- .map((t) => ({ ...t, active: false }))
- .concat([activeTab]);
- const nextHistory = pushHistory(history, activeTab);
- const flags = computeHistoryFlags(nextHistory, activeTab);
- set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags });
- },
- select: (tab) => {
- const { tabs, history } = get();
- const nextTabs = tabs.map((t) => ({ ...t, active: isSameTab(t, tab) }));
- const flags = computeHistoryFlags(history, tab);
- set({ tabs: nextTabs, currentTab: tab, ...flags });
- },
- close: (tab) => {
- const { tabs, history } = get();
- const remainingTabs = tabs.filter((t) => !isSameTab(t, tab));
-
- if (remainingTabs.length === 0) {
- return set({ tabs: [], currentTab: null, canGoBack: false, canGoNext: false });
- }
-
- const closedTabIndex = tabs.findIndex((t) => isSameTab(t, tab));
- const nextActiveIndex = closedTabIndex < remainingTabs.length
- ? closedTabIndex
- : remainingTabs.length - 1;
-
- const nextTabs = remainingTabs.map((t, idx) => ({ ...t, active: idx === nextActiveIndex }));
- const nextCurrentTab = nextTabs[nextActiveIndex];
-
- const nextHistory = new Map(history);
- nextHistory.delete(getSlotId(tab));
-
- const flags = computeHistoryFlags(nextHistory, nextCurrentTab);
- set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory, ...flags });
- },
- reorder: (tabs) => {
- const { history } = get();
- const currentTab = tabs.find((t) => t.active) || null;
- const flags = computeHistoryFlags(history, currentTab);
- set({ tabs, currentTab, ...flags });
- },
- updateSessionTabState: (tab, state) => {
- const { tabs, currentTab, history } = get();
- const nextTabs = tabs.map((t) =>
- isSameTab(t, tab) && t.type === "sessions"
- ? { ...t, state }
- : t
- );
- const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "sessions"
- ? { ...currentTab, state }
- : currentTab;
-
- const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab)
- ? updateHistoryCurrent(history, nextCurrentTab)
- : history;
-
- set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory });
- },
- updateContactsTabState: (tab, state) => {
- const { tabs, currentTab, history } = get();
- const nextTabs = tabs.map((t) =>
- isSameTab(t, tab) && t.type === "contacts"
- ? { ...t, state }
- : t
- );
-
- const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "contacts"
- ? { ...currentTab, state }
- : currentTab;
-
- const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab)
- ? updateHistoryCurrent(history, nextCurrentTab)
- : history;
-
- set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory });
- },
- goBack: () => {
- const { tabs, history, currentTab } = get();
- if (!currentTab) {
- return;
- }
-
- const slotId = getSlotId(currentTab);
- const tabHistory = history.get(slotId);
- if (!tabHistory || tabHistory.currentIndex === 0) {
- return;
- }
-
- const prevIndex = tabHistory.currentIndex - 1;
- const prevTab = tabHistory.stack[prevIndex];
-
- const nextTabs = tabs.map((t) => t.active ? prevTab : t);
-
- const nextHistory = new Map(history);
- nextHistory.set(slotId, { ...tabHistory, currentIndex: prevIndex });
-
- const flags = computeHistoryFlags(nextHistory, prevTab);
- set({ tabs: nextTabs, currentTab: prevTab, history: nextHistory, ...flags });
- },
- goNext: () => {
- const { tabs, history, currentTab } = get();
- if (!currentTab) {
- return;
- }
-
- const slotId = getSlotId(currentTab);
- const tabHistory = history.get(slotId);
- if (!tabHistory || tabHistory.currentIndex >= tabHistory.stack.length - 1) {
- return;
- }
-
- const nextIndex = tabHistory.currentIndex + 1;
- const nextTab = tabHistory.stack[nextIndex];
-
- const nextTabs = tabs.map((t) => t.active ? nextTab : t);
-
- const nextHistory = new Map(history);
- nextHistory.set(slotId, { ...tabHistory, currentIndex: nextIndex });
-
- const flags = computeHistoryFlags(nextHistory, nextTab);
- set({ tabs: nextTabs, currentTab: nextTab, history: nextHistory, ...flags });
- },
-}));
-
-const baseTabSchema = z.object({
- active: z.boolean(),
-});
-
-export const tabSchema = z.discriminatedUnion("type", [
- baseTabSchema.extend({
- type: z.literal("sessions" satisfies typeof TABLES[number]),
- id: z.string(),
- state: z.object({
- editor: z.enum(["raw", "enhanced", "transcript"]).default("raw"),
- }).default({ editor: "raw" }),
- }),
- baseTabSchema.extend({
- type: z.literal("contacts"),
- state: z.object({
- selectedOrganization: z.string().nullable().default(null),
- selectedPerson: z.string().nullable().default(null),
- }).default({
- selectedOrganization: null,
- selectedPerson: null,
- }),
- }),
- baseTabSchema.extend({
- type: z.literal("events" satisfies typeof TABLES[number]),
- id: z.string(),
- }),
- baseTabSchema.extend({
- type: z.literal("humans" satisfies typeof TABLES[number]),
- id: z.string(),
- }),
- baseTabSchema.extend({
- type: z.literal("organizations" satisfies typeof TABLES[number]),
- id: z.string(),
- }),
- baseTabSchema.extend({
- type: z.literal("folders" satisfies typeof TABLES[number]),
- id: z.string().nullable(),
- }),
-
- baseTabSchema.extend({
- type: z.literal("calendars"),
- month: z.coerce.date(),
- }),
- baseTabSchema.extend({
- type: z.literal("daily"),
- date: z.coerce.date(),
- }),
-]);
-
-export type Tab = z.infer;
-
-export const rowIdfromTab = (tab: Tab): string => {
- switch (tab.type) {
- case "sessions":
- return tab.id;
- case "events":
- return tab.id;
- case "humans":
- return tab.id;
- case "organizations":
- return tab.id;
- case "calendars":
- case "contacts":
- case "daily":
- throw new Error("invalid_resource");
- case "folders":
- if (!tab.id) {
- throw new Error("invalid_resource");
- }
- return tab.id;
- }
-};
-
-export const uniqueIdfromTab = (tab: Tab): string => {
- switch (tab.type) {
- case "sessions":
- return `sessions-${tab.id}`;
- case "events":
- return `events-${tab.id}`;
- case "humans":
- return `humans-${tab.id}`;
- case "organizations":
- return `organizations-${tab.id}`;
- case "calendars":
- return `calendars-${tab.month.getFullYear()}-${tab.month.getMonth()}`;
- case "contacts":
- return `contacts`;
- case "daily":
- return `daily-${tab.date.getFullYear()}-${tab.date.getMonth()}-${tab.date.getDate()}`;
- case "folders":
- return `folders-${tab.id ?? "all"}`;
- }
-};
-
-export const isSameTab = (a: Tab, b: Tab) => {
- return uniqueIdfromTab(a) === uniqueIdfromTab(b);
-};
diff --git a/apps/desktop2/src/store/zustand/tabs/basic.test.ts b/apps/desktop2/src/store/zustand/tabs/basic.test.ts
new file mode 100644
index 0000000000..f9b0ed73fe
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/basic.test.ts
@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { type Tab, useTabs } from ".";
+import { createContactsTab, createSessionTab, resetTabsStore } from "./test-utils";
+
+const isSessionsTab = (tab: Tab): tab is Extract => tab.type === "sessions";
+
+const expectTabs = (tabs: Tab[], expected: Array>) => {
+ expect(tabs).toHaveLength(expected.length);
+ expected.forEach((partial, index) => {
+ expect(tabs[index]).toMatchObject(partial);
+ });
+};
+
+describe("Basic Tab Actions", () => {
+ beforeEach(() => {
+ resetTabsStore();
+ });
+
+ test("setTabs normalizes input, sets current and history flags", () => {
+ const rawTabs = [
+ createSessionTab({ id: "a", active: false }),
+ createSessionTab({ id: "b", active: true, state: { editor: "enhanced" } }),
+ createContactsTab({ active: false }),
+ ];
+
+ useTabs.getState().setTabs(rawTabs);
+
+ const state = useTabs.getState();
+ expect(state.currentTab).toMatchObject({ id: "b", active: true });
+ expectTabs(state.tabs, [
+ { id: "a", active: false },
+ { id: "b", active: true },
+ { type: "contacts", active: false },
+ ]);
+ expect(state.history.get("active-tab-history")?.stack).toHaveLength(1);
+ expect(state.canGoBack).toBe(false);
+ expect(state.canGoNext).toBe(false);
+ });
+
+ test("openCurrent replaces duplicates of same tab and activates new instance", () => {
+ const duplicateA = createSessionTab({ id: "target", active: false });
+ const duplicateB = createSessionTab({ id: "target", active: false });
+ const other = createSessionTab({ id: "other", active: false });
+ useTabs.getState().setTabs([duplicateA, duplicateB, other]);
+
+ const newActive = createSessionTab({ id: "target", active: false });
+ useTabs.getState().openCurrent(newActive);
+
+ const state = useTabs.getState();
+ expectTabs(state.tabs, [
+ { id: "other", active: false },
+ { id: "target", active: true },
+ ]);
+ const stack = state.history.get("active-tab-history")?.stack;
+ expect(stack && stack[stack.length - 1]).toMatchObject({ id: "target" });
+ expect(state.canGoBack).toBe(false);
+ });
+
+ test("openCurrent closes existing active tab via lifecycle handlers", () => {
+ const handler = vi.fn();
+ const active = createSessionTab({ id: "first", active: false });
+ useTabs.getState().registerOnClose(handler);
+ useTabs.getState().openCurrent(active);
+
+ const next = createSessionTab({ id: "second", active: false });
+ useTabs.getState().openCurrent(next);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "first" }));
+ });
+
+ test("openNew appends unique active tab and closes duplicates", () => {
+ const duplicate = createSessionTab({ id: "dup", active: false });
+ const handler = vi.fn();
+ useTabs.getState().registerOnClose(handler);
+ useTabs.getState().setTabs([duplicate]);
+
+ useTabs.getState().openNew(createSessionTab({ id: "dup", active: false }));
+
+ const state = useTabs.getState();
+ expectTabs(state.tabs, [
+ { id: "dup", active: true },
+ ]);
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(state.history.get("active-tab-history")?.stack).toHaveLength(1);
+ });
+
+ test("select toggles active flag without changing history", () => {
+ const tabA = createSessionTab({ id: "a", active: true });
+ const tabB = createSessionTab({ id: "b", active: false });
+ useTabs.getState().setTabs([tabA, tabB]);
+
+ useTabs.getState().select(tabB);
+
+ const state = useTabs.getState();
+ if (!state.currentTab || !isSessionsTab(state.currentTab)) {
+ throw new Error("expected current tab to be a sessions tab");
+ }
+ expect(state.currentTab.id).toBe("b");
+ const target = state.tabs.find((t) => isSessionsTab(t) && t.id === "b");
+ expect(target?.active).toBe(true);
+ expectTabs(state.tabs, [
+ { id: "a", active: false },
+ { id: "b", active: true },
+ ]);
+ expect(state.history.get("active-tab-history")?.stack).toHaveLength(1);
+ });
+
+ test("close removes tab, picks fallback active, updates history", () => {
+ const active = createSessionTab({ id: "active", active: true });
+ const next = createSessionTab({ id: "next", active: false });
+ useTabs.getState().setTabs([active, next]);
+
+ useTabs.getState().close(active);
+
+ const state = useTabs.getState();
+ expectTabs(state.tabs, [
+ { id: "next", active: true },
+ ]);
+ expect(state.currentTab).toMatchObject({ id: "next" });
+ expect(state.history.size).toBe(0);
+ expect(state.canGoBack).toBe(false);
+ expect(state.canGoNext).toBe(false);
+ });
+
+ test("close last tab empties store", () => {
+ const only = createSessionTab({ id: "only", active: true });
+ useTabs.getState().setTabs([only]);
+
+ useTabs.getState().close(only);
+
+ const state = useTabs.getState();
+ expect(state.tabs).toHaveLength(0);
+ expect(state.currentTab).toBeNull();
+ expect(state.canGoBack).toBe(false);
+ expect(state.canGoNext).toBe(false);
+ });
+
+ test("reorder keeps current tab and flags consistent", () => {
+ const active = createSessionTab({ id: "active", active: true });
+ const other = createSessionTab({ id: "other", active: false });
+ useTabs.getState().setTabs([active, other]);
+
+ useTabs.getState().reorder([other, { ...active, active: true }]);
+
+ const state = useTabs.getState();
+ expectTabs(state.tabs, [
+ { id: "other", active: false },
+ { id: "active", active: true },
+ ]);
+ expect(state.currentTab).toMatchObject({ id: "active" });
+ expect(state.canGoBack).toBe(false);
+ });
+});
diff --git a/apps/desktop2/src/store/zustand/tabs/basic.ts b/apps/desktop2/src/store/zustand/tabs/basic.ts
new file mode 100644
index 0000000000..3d7fed63c1
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/basic.ts
@@ -0,0 +1,141 @@
+import type { StoreApi } from "zustand";
+
+import type { LifecycleState } from "./lifecycle";
+import type { NavigationState } from "./navigation";
+import type { Tab, TabHistory } from "./schema";
+import { isSameTab, tabSchema } from "./schema";
+import { computeHistoryFlags, getSlotId, notifyTabClose, notifyTabsClose, pushHistory } from "./utils";
+
+export type BasicState = {
+ currentTab: Tab | null;
+ tabs: Tab[];
+};
+
+export type BasicActions = {
+ setTabs: (tabs: Tab[]) => void;
+ openCurrent: (tab: Tab) => void;
+ openNew: (tab: Tab) => void;
+ select: (tab: Tab) => void;
+ close: (tab: Tab) => void;
+ reorder: (tabs: Tab[]) => void;
+};
+
+export const createBasicSlice = (
+ set: StoreApi["setState"],
+ get: StoreApi["getState"],
+): BasicState & BasicActions => ({
+ currentTab: null,
+ tabs: [],
+ setTabs: (tabs) => {
+ const tabsWithDefaults = tabs.map(t => tabSchema.parse(t));
+ const currentTab = tabsWithDefaults.find((t) => t.active) || null;
+ const history = new Map();
+
+ tabsWithDefaults.forEach((tab) => {
+ if (tab.active) {
+ history.set(getSlotId(tab), { stack: [tab], currentIndex: 0 });
+ }
+ });
+
+ const flags = computeHistoryFlags(history, currentTab);
+ set({ tabs: tabsWithDefaults, currentTab, history, ...flags } as Partial);
+ },
+ openCurrent: (newTab) => {
+ const { tabs, history, onCloseHandlers } = get();
+ const tabWithDefaults = tabSchema.parse(newTab);
+ const activeTab = { ...tabWithDefaults, active: true };
+ const existingTabIdx = tabs.findIndex((t) => t.active);
+
+ const tabsToClose: Tab[] = [];
+ if (existingTabIdx !== -1) {
+ tabsToClose.push(tabs[existingTabIdx]);
+ }
+ tabs.forEach((tab) => {
+ if (!tab.active && isSameTab(tab, tabWithDefaults)) {
+ tabsToClose.push(tab);
+ }
+ });
+
+ notifyTabsClose(onCloseHandlers, tabsToClose);
+
+ const nextTabs = existingTabIdx === -1
+ ? tabs
+ .filter((t) => !isSameTab(t, tabWithDefaults))
+ .map((t) => ({ ...t, active: false }))
+ .concat([activeTab])
+ : tabs
+ .map((t, idx) =>
+ idx === existingTabIdx
+ ? activeTab
+ : isSameTab(t, tabWithDefaults)
+ ? null
+ : { ...t, active: false }
+ )
+ .filter((t): t is Tab => t !== null);
+
+ const nextHistory = pushHistory(history, activeTab);
+ const flags = computeHistoryFlags(nextHistory, activeTab);
+ set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags } as Partial);
+ },
+ openNew: (tab) => {
+ const { tabs, history, onCloseHandlers } = get();
+ const tabWithDefaults = tabSchema.parse(tab);
+ const activeTab = { ...tabWithDefaults, active: true };
+ const tabsToClose = tabs.filter((t) => isSameTab(t, tabWithDefaults));
+ notifyTabsClose(onCloseHandlers, tabsToClose);
+ const nextTabs = tabs
+ .filter((t) => !isSameTab(t, tabWithDefaults))
+ .map((t) => ({ ...t, active: false }))
+ .concat([activeTab]);
+ const nextHistory = pushHistory(history, activeTab);
+ const flags = computeHistoryFlags(nextHistory, activeTab);
+ set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags } as Partial);
+ },
+ select: (tab) => {
+ const { tabs, history } = get();
+ const nextTabs = tabs.map((t) => ({ ...t, active: isSameTab(t, tab) }));
+ const flags = computeHistoryFlags(history, tab);
+ set({ tabs: nextTabs, currentTab: tab, ...flags } as Partial);
+ },
+ close: (tab) => {
+ const { tabs, history, onCloseHandlers } = get();
+ const remainingTabs = tabs.filter((t) => !isSameTab(t, tab));
+
+ notifyTabClose(onCloseHandlers, tab);
+
+ if (remainingTabs.length === 0) {
+ return set({
+ tabs: [] as Tab[],
+ currentTab: null,
+ history: new Map(),
+ canGoBack: false,
+ canGoNext: false,
+ } as Partial);
+ }
+
+ const closedTabIndex = tabs.findIndex((t) => isSameTab(t, tab));
+ const nextActiveIndex = closedTabIndex < remainingTabs.length
+ ? closedTabIndex
+ : remainingTabs.length - 1;
+
+ const nextTabs = remainingTabs.map((t, idx) => ({ ...t, active: idx === nextActiveIndex }));
+ const nextCurrentTab = nextTabs[nextActiveIndex];
+
+ const nextHistory = new Map(history);
+ nextHistory.delete(getSlotId(tab));
+
+ const flags = computeHistoryFlags(nextHistory, nextCurrentTab);
+ set({
+ tabs: nextTabs,
+ currentTab: nextCurrentTab,
+ history: nextHistory,
+ ...flags,
+ } as Partial);
+ },
+ reorder: (tabs) => {
+ const { history } = get();
+ const currentTab = tabs.find((t) => t.active) || null;
+ const flags = computeHistoryFlags(history, currentTab);
+ set({ tabs, currentTab, ...flags } as Partial);
+ },
+});
diff --git a/apps/desktop2/src/store/zustand/tabs/index.ts b/apps/desktop2/src/store/zustand/tabs/index.ts
new file mode 100644
index 0000000000..be9615f554
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/index.ts
@@ -0,0 +1,20 @@
+import { create } from "zustand";
+
+import { type BasicActions, type BasicState, createBasicSlice } from "./basic";
+import { createLifecycleSlice, type LifecycleActions, type LifecycleState } from "./lifecycle";
+import { createNavigationSlice, type NavigationActions, type NavigationState } from "./navigation";
+import { createStateUpdaterSlice, type StateBasicActions } from "./state";
+
+export type { Tab } from "./schema";
+export { isSameTab, rowIdfromTab, tabSchema, uniqueIdfromTab } from "./schema";
+
+type State = BasicState & NavigationState & LifecycleState;
+type Actions = BasicActions & StateBasicActions & NavigationActions & LifecycleActions;
+type Store = State & Actions;
+
+export const useTabs = create()((set, get) => ({
+ ...createBasicSlice(set, get),
+ ...createStateUpdaterSlice(set, get),
+ ...createNavigationSlice(set, get),
+ ...createLifecycleSlice(set, get),
+}));
diff --git a/apps/desktop2/src/store/zustand/tabs/lifecycle.test.ts b/apps/desktop2/src/store/zustand/tabs/lifecycle.test.ts
new file mode 100644
index 0000000000..fe75fb4601
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/lifecycle.test.ts
@@ -0,0 +1,84 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { type Tab, useTabs } from ".";
+
+describe("Tab Lifecycle", () => {
+ beforeEach(() => {
+ useTabs.setState({
+ currentTab: null,
+ tabs: [],
+ history: new Map(),
+ canGoBack: false,
+ canGoNext: false,
+ onCloseHandlers: new Set(),
+ });
+ });
+
+ test("registerOnClose triggers handler when close removes tab", () => {
+ const tab: Tab = {
+ type: "sessions",
+ id: "session-1",
+ active: true,
+ state: { editor: "raw" },
+ };
+
+ const handler = vi.fn();
+ useTabs.getState().openCurrent(tab);
+ const unregister = useTabs.getState().registerOnClose(handler);
+ useTabs.getState().close(tab);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "session-1", type: "sessions" }));
+
+ unregister();
+ expect(useTabs.getState().onCloseHandlers.size).toBe(0);
+ });
+
+ test("registerOnClose triggers handler when openCurrent replaces tab", () => {
+ const tab1: Tab = {
+ type: "sessions",
+ id: "session-1",
+ active: true,
+ state: { editor: "raw" },
+ };
+ const tab2: Tab = {
+ type: "sessions",
+ id: "session-2",
+ active: true,
+ state: { editor: "raw" },
+ };
+
+ const handler = vi.fn();
+ useTabs.getState().openCurrent(tab1);
+ useTabs.getState().registerOnClose(handler);
+ useTabs.getState().openCurrent(tab2);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "session-1", type: "sessions" }));
+ });
+
+ test("registerOnClose handler receives correct tab when multiple tabs close", () => {
+ const tab1: Tab = {
+ type: "sessions",
+ id: "session-1",
+ active: true,
+ state: { editor: "raw" },
+ };
+ const tab2: Tab = {
+ type: "sessions",
+ id: "session-2",
+ active: true,
+ state: { editor: "raw" },
+ };
+
+ const closedTabs: Tab[] = [];
+ const handler = vi.fn((tab: Tab) => closedTabs.push(tab));
+
+ useTabs.getState().registerOnClose(handler);
+ useTabs.getState().openCurrent(tab1);
+ useTabs.getState().openNew(tab2);
+ useTabs.getState().close(tab2);
+
+ expect(closedTabs).toHaveLength(1);
+ expect(closedTabs[0]).toMatchObject({ id: "session-2", type: "sessions" });
+ });
+});
diff --git a/apps/desktop2/src/store/zustand/tabs/lifecycle.ts b/apps/desktop2/src/store/zustand/tabs/lifecycle.ts
new file mode 100644
index 0000000000..186a184e22
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/lifecycle.ts
@@ -0,0 +1,30 @@
+import type { StoreApi } from "zustand";
+
+import type { Tab } from "./schema";
+
+export type LifecycleState = {
+ onCloseHandlers: Set<(tab: Tab) => void>;
+};
+
+export type LifecycleActions = {
+ registerOnClose: (handler: (tab: Tab) => void) => () => void;
+};
+
+export const createLifecycleSlice = (
+ set: StoreApi["setState"],
+ get: StoreApi["getState"],
+): LifecycleState & LifecycleActions => ({
+ onCloseHandlers: new Set(),
+ registerOnClose: (handler) => {
+ const { onCloseHandlers } = get();
+ const nextHandlers = new Set(onCloseHandlers);
+ nextHandlers.add(handler);
+ set({ onCloseHandlers: nextHandlers } as Partial);
+ return () => {
+ const { onCloseHandlers: currentHandlers } = get();
+ const nextHandlers = new Set(currentHandlers);
+ nextHandlers.delete(handler);
+ set({ onCloseHandlers: nextHandlers } as Partial);
+ };
+ },
+});
diff --git a/apps/desktop2/src/store/zustand/tabs.test.ts b/apps/desktop2/src/store/zustand/tabs/navigation.test.ts
similarity index 98%
rename from apps/desktop2/src/store/zustand/tabs.test.ts
rename to apps/desktop2/src/store/zustand/tabs/navigation.test.ts
index 517a6b1e78..fcc750300c 100644
--- a/apps/desktop2/src/store/zustand/tabs.test.ts
+++ b/apps/desktop2/src/store/zustand/tabs/navigation.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "vitest";
-import { type Tab, useTabs } from "./tabs";
+import { type Tab, useTabs } from ".";
describe("Tab History Navigation", () => {
beforeEach(() => {
diff --git a/apps/desktop2/src/store/zustand/tabs/navigation.ts b/apps/desktop2/src/store/zustand/tabs/navigation.ts
new file mode 100644
index 0000000000..d5dbc22fa7
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/navigation.ts
@@ -0,0 +1,71 @@
+import type { StoreApi } from "zustand";
+
+import type { BasicState } from "./basic";
+import type { TabHistory } from "./schema";
+import { computeHistoryFlags, getSlotId } from "./utils";
+
+export type NavigationState = {
+ history: Map;
+ canGoBack: boolean;
+ canGoNext: boolean;
+};
+
+export type NavigationActions = {
+ goBack: () => void;
+ goNext: () => void;
+};
+
+export const createNavigationSlice = (
+ set: StoreApi["setState"],
+ get: StoreApi["getState"],
+): NavigationState & NavigationActions => ({
+ history: new Map(),
+ canGoBack: false,
+ canGoNext: false,
+ goBack: () => {
+ const { tabs, history, currentTab } = get();
+ if (!currentTab) {
+ return;
+ }
+
+ const slotId = getSlotId(currentTab);
+ const tabHistory = history.get(slotId);
+ if (!tabHistory || tabHistory.currentIndex === 0) {
+ return;
+ }
+
+ const prevIndex = tabHistory.currentIndex - 1;
+ const prevTab = tabHistory.stack[prevIndex];
+
+ const nextTabs = tabs.map((t) => t.active ? prevTab : t);
+
+ const nextHistory = new Map(history);
+ nextHistory.set(slotId, { ...tabHistory, currentIndex: prevIndex });
+
+ const flags = computeHistoryFlags(nextHistory, prevTab);
+ set({ tabs: nextTabs, currentTab: prevTab, history: nextHistory, ...flags } as Partial);
+ },
+ goNext: () => {
+ const { tabs, history, currentTab } = get();
+ if (!currentTab) {
+ return;
+ }
+
+ const slotId = getSlotId(currentTab);
+ const tabHistory = history.get(slotId);
+ if (!tabHistory || tabHistory.currentIndex >= tabHistory.stack.length - 1) {
+ return;
+ }
+
+ const nextIndex = tabHistory.currentIndex + 1;
+ const nextTab = tabHistory.stack[nextIndex];
+
+ const nextTabs = tabs.map((t) => t.active ? nextTab : t);
+
+ const nextHistory = new Map(history);
+ nextHistory.set(slotId, { ...tabHistory, currentIndex: nextIndex });
+
+ const flags = computeHistoryFlags(nextHistory, nextTab);
+ set({ tabs: nextTabs, currentTab: nextTab, history: nextHistory, ...flags } as Partial);
+ },
+});
diff --git a/apps/desktop2/src/store/zustand/tabs/schema.ts b/apps/desktop2/src/store/zustand/tabs/schema.ts
new file mode 100644
index 0000000000..e726303a2d
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/schema.ts
@@ -0,0 +1,106 @@
+import { z } from "zod";
+
+import { TABLES } from "../../tinybase/persisted";
+
+const baseTabSchema = z.object({
+ active: z.boolean(),
+});
+
+export const tabSchema = z.discriminatedUnion("type", [
+ baseTabSchema.extend({
+ type: z.literal("sessions" satisfies typeof TABLES[number]),
+ id: z.string(),
+ state: z.object({
+ editor: z.enum(["raw", "enhanced", "transcript"]).default("raw"),
+ }).default({ editor: "raw" }),
+ }),
+ baseTabSchema.extend({
+ type: z.literal("contacts"),
+ state: z.object({
+ selectedOrganization: z.string().nullable().default(null),
+ selectedPerson: z.string().nullable().default(null),
+ }).default({
+ selectedOrganization: null,
+ selectedPerson: null,
+ }),
+ }),
+ baseTabSchema.extend({
+ type: z.literal("events" satisfies typeof TABLES[number]),
+ id: z.string(),
+ }),
+ baseTabSchema.extend({
+ type: z.literal("humans" satisfies typeof TABLES[number]),
+ id: z.string(),
+ }),
+ baseTabSchema.extend({
+ type: z.literal("organizations" satisfies typeof TABLES[number]),
+ id: z.string(),
+ }),
+ baseTabSchema.extend({
+ type: z.literal("folders" satisfies typeof TABLES[number]),
+ id: z.string().nullable(),
+ }),
+
+ baseTabSchema.extend({
+ type: z.literal("calendars"),
+ month: z.coerce.date(),
+ }),
+ baseTabSchema.extend({
+ type: z.literal("daily"),
+ date: z.coerce.date(),
+ }),
+]);
+
+export type Tab = z.infer;
+
+export type TabHistory = {
+ stack: Tab[];
+ currentIndex: number;
+};
+
+export const rowIdfromTab = (tab: Tab): string => {
+ switch (tab.type) {
+ case "sessions":
+ return tab.id;
+ case "events":
+ return tab.id;
+ case "humans":
+ return tab.id;
+ case "organizations":
+ return tab.id;
+ case "calendars":
+ case "contacts":
+ case "daily":
+ throw new Error("invalid_resource");
+ case "folders":
+ if (!tab.id) {
+ throw new Error("invalid_resource");
+ }
+ return tab.id;
+ }
+};
+
+export const uniqueIdfromTab = (tab: Tab): string => {
+ switch (tab.type) {
+ case "sessions":
+ return `sessions-${tab.id}`;
+ case "events":
+ return `events-${tab.id}`;
+ case "humans":
+ return `humans-${tab.id}`;
+ case "organizations":
+ return `organizations-${tab.id}`;
+ case "calendars":
+ return `calendars-${tab.month.getFullYear()}-${tab.month.getMonth()}`;
+ case "contacts":
+ return `contacts`;
+ case "daily":
+ return `daily-${tab.date.getFullYear()}-${tab.date.getMonth()}-${tab.date.getDate()}`;
+ case "folders":
+ return `folders-${tab.id ?? "all"}`;
+ }
+};
+
+export const isSameTab = (a: Tab, b: Tab) => {
+ return uniqueIdfromTab(a) === uniqueIdfromTab(b);
+};
diff --git a/apps/desktop2/src/store/zustand/tabs/state.test.ts b/apps/desktop2/src/store/zustand/tabs/state.test.ts
new file mode 100644
index 0000000000..9b0861bb6f
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/state.test.ts
@@ -0,0 +1,103 @@
+import { beforeEach, describe, expect, test } from "vitest";
+
+import { type Tab, useTabs } from ".";
+import { createContactsTab, createSessionTab, resetTabsStore } from "./test-utils";
+
+const expectTabState = (tab: Tab | null, partial: Partial) => {
+ expect(tab).not.toBeNull();
+ expect(tab).toMatchObject(partial);
+};
+
+describe("State Updater Actions", () => {
+ beforeEach(() => {
+ resetTabsStore();
+ });
+
+ describe("updateSessionTabState", () => {
+ test("updates matching session tab and current tab state", () => {
+ const tab = createSessionTab({ id: "s1", active: true });
+ useTabs.getState().setTabs([tab]);
+
+ useTabs.getState().updateSessionTabState(tab, { editor: "enhanced" });
+
+ const state = useTabs.getState();
+ expect(state.tabs[0]).toMatchObject({ id: "s1", state: { editor: "enhanced" } });
+ expectTabState(state.currentTab, { id: "s1", state: { editor: "enhanced" } });
+ const stack = state.history.get("active-tab-history")?.stack;
+ expect(stack && stack[stack.length - 1]).toMatchObject({ state: { editor: "enhanced" } });
+ });
+
+ test("updates only matching tab instances", () => {
+ const tab = createSessionTab({ id: "s", active: false });
+ const active = createSessionTab({ id: "active", active: true });
+ useTabs.getState().setTabs([tab, active]);
+
+ useTabs.getState().updateSessionTabState(tab, { editor: "enhanced" });
+
+ const state = useTabs.getState();
+ expect(state.tabs[0]).toMatchObject({ id: "s", state: { editor: "enhanced" } });
+ expect(state.tabs[1]).toMatchObject({ id: "active", state: { editor: "raw" } });
+ const stack = state.history.get("active-tab-history")?.stack;
+ expect(stack && stack[stack.length - 1]).toMatchObject({ id: "active", state: { editor: "raw" } });
+ });
+
+ test("no-op when tab types mismatch", () => {
+ const session = createSessionTab({ id: "s", active: true });
+ const contacts = createContactsTab();
+ useTabs.getState().setTabs([session, contacts]);
+
+ useTabs.getState().updateSessionTabState(contacts as Tab, { editor: "enhanced" } as any);
+
+ const state = useTabs.getState();
+ expect(state.tabs[0]).toMatchObject({ id: "s", state: { editor: "raw" } });
+ expect(state.tabs[1]).toMatchObject({ type: "contacts" });
+ });
+ });
+
+ describe("updateContactsTabState", () => {
+ const newContactsState = {
+ selectedOrganization: "org-1",
+ selectedPerson: "person-1",
+ } as const;
+
+ test("updates contacts tab and current tab state", () => {
+ const contacts = createContactsTab({ active: true });
+ useTabs.getState().setTabs([contacts]);
+
+ useTabs.getState().updateContactsTabState(contacts, newContactsState);
+
+ const state = useTabs.getState();
+ expect(state.tabs[0]).toMatchObject({ state: newContactsState });
+ expectTabState(state.currentTab, { state: newContactsState });
+ const stack = state.history.get("active-tab-history")?.stack;
+ expect(stack && stack[stack.length - 1]).toMatchObject({ state: newContactsState });
+ });
+
+ test("only matching contacts tab receives update", () => {
+ const contacts = createContactsTab({ active: false });
+ const session = createSessionTab({ id: "s", active: true });
+ useTabs.getState().setTabs([contacts, session]);
+
+ useTabs.getState().updateContactsTabState(contacts, newContactsState);
+
+ const state = useTabs.getState();
+ expect(state.tabs[0]).toMatchObject({ state: newContactsState });
+ expect(state.tabs[1]).toMatchObject({ state: { editor: "raw" } });
+ const stack = state.history.get("active-tab-history")?.stack;
+ expect(stack && stack[stack.length - 1]).toMatchObject({ id: "s" });
+ });
+
+ test("updates all contacts tabs sharing identity even when instance differs", () => {
+ const contacts = createContactsTab({ active: true });
+ const other = createContactsTab({ active: false });
+ useTabs.getState().setTabs([contacts, other]);
+
+ const otherInstance = createContactsTab({ active: true });
+ useTabs.getState().updateContactsTabState(otherInstance, newContactsState);
+
+ const state = useTabs.getState();
+ expect(state.tabs[0]).toMatchObject({ state: newContactsState });
+ expect(state.tabs[1]).toMatchObject({ state: newContactsState });
+ });
+ });
+});
diff --git a/apps/desktop2/src/store/zustand/tabs/state.ts b/apps/desktop2/src/store/zustand/tabs/state.ts
new file mode 100644
index 0000000000..bcc2480097
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/state.ts
@@ -0,0 +1,53 @@
+import type { StoreApi } from "zustand";
+
+import type { BasicState } from "./basic";
+import type { NavigationState } from "./navigation";
+import type { Tab } from "./schema";
+import { isSameTab } from "./schema";
+import { updateHistoryCurrent } from "./utils";
+
+export type StateBasicActions = {
+ updateContactsTabState: (tab: Tab, state: Extract["state"]) => void;
+ updateSessionTabState: (tab: Tab, state: Extract["state"]) => void;
+};
+
+export const createStateUpdaterSlice = (
+ set: StoreApi["setState"],
+ get: StoreApi["getState"],
+): StateBasicActions => ({
+ updateSessionTabState: (tab, state) => {
+ const { tabs, currentTab, history } = get();
+ const nextTabs = tabs.map((t) =>
+ isSameTab(t, tab) && t.type === "sessions"
+ ? { ...t, state }
+ : t
+ );
+ const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "sessions"
+ ? { ...currentTab, state }
+ : currentTab;
+
+ const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab)
+ ? updateHistoryCurrent(history, nextCurrentTab)
+ : history;
+
+ set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory } as Partial);
+ },
+ updateContactsTabState: (tab, state) => {
+ const { tabs, currentTab, history } = get();
+ const nextTabs = tabs.map((t) =>
+ isSameTab(t, tab) && t.type === "contacts"
+ ? { ...t, state }
+ : t
+ );
+
+ const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "contacts"
+ ? { ...currentTab, state }
+ : currentTab;
+
+ const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab)
+ ? updateHistoryCurrent(history, nextCurrentTab)
+ : history;
+
+ set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory } as Partial);
+ },
+});
diff --git a/apps/desktop2/src/store/zustand/tabs/test-utils.ts b/apps/desktop2/src/store/zustand/tabs/test-utils.ts
new file mode 100644
index 0000000000..8eaf076b7e
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/test-utils.ts
@@ -0,0 +1,48 @@
+import { type Tab, useTabs } from ".";
+
+type SessionTab = Extract;
+type ContactsTab = Extract;
+
+let tabCounter = 0;
+
+const nextId = (prefix: string) => `${prefix}-${++tabCounter}`;
+
+type SessionOverrides = Partial> & {
+ state?: Partial;
+};
+
+type ContactsOverrides = Partial> & {
+ state?: Partial;
+};
+
+export const createSessionTab = (overrides: SessionOverrides = {}): SessionTab => ({
+ type: "sessions",
+ id: overrides.id ?? nextId("session"),
+ active: overrides.active ?? false,
+ state: {
+ editor: "raw",
+ ...overrides.state,
+ },
+});
+
+export const createContactsTab = (overrides: ContactsOverrides = {}): ContactsTab => ({
+ type: "contacts",
+ active: overrides.active ?? false,
+ state: {
+ selectedOrganization: null,
+ selectedPerson: null,
+ ...overrides.state,
+ },
+});
+
+export const resetTabsStore = (): void => {
+ tabCounter = 0;
+ useTabs.setState(() => ({
+ currentTab: null,
+ tabs: [],
+ history: new Map(),
+ canGoBack: false,
+ canGoNext: false,
+ onCloseHandlers: new Set(),
+ }));
+};
diff --git a/apps/desktop2/src/store/zustand/tabs/utils.ts b/apps/desktop2/src/store/zustand/tabs/utils.ts
new file mode 100644
index 0000000000..72a4ca50b6
--- /dev/null
+++ b/apps/desktop2/src/store/zustand/tabs/utils.ts
@@ -0,0 +1,76 @@
+import type { Tab, TabHistory } from "./schema";
+import { uniqueIdfromTab } from "./schema";
+
+export const ACTIVE_TAB_SLOT_ID = "active-tab-history";
+
+export const getSlotId = (tab: Tab): string => {
+ return tab.active ? ACTIVE_TAB_SLOT_ID : `inactive-${uniqueIdfromTab(tab)}`;
+};
+
+export const notifyTabClose = (
+ handlers: Set<(tab: Tab) => void>,
+ tab: Tab,
+): void => {
+ handlers.forEach((handler) => {
+ try {
+ handler(tab);
+ } catch (error) {
+ console.error("tab onClose handler failed", error);
+ }
+ });
+};
+
+export const notifyTabsClose = (
+ handlers: Set<(tab: Tab) => void>,
+ tabs: Tab[],
+): void => {
+ tabs.forEach((tab) => notifyTabClose(handlers, tab));
+};
+
+export const computeHistoryFlags = (
+ history: Map,
+ currentTab: Tab | null,
+): { canGoBack: boolean; canGoNext: boolean } => {
+ if (!currentTab) {
+ return { canGoBack: false, canGoNext: false };
+ }
+ const slotId = getSlotId(currentTab);
+ const tabHistory = history.get(slotId);
+ if (!tabHistory) {
+ return { canGoBack: false, canGoNext: false };
+ }
+ return {
+ canGoBack: tabHistory.currentIndex > 0,
+ canGoNext: tabHistory.currentIndex < tabHistory.stack.length - 1,
+ };
+};
+
+export const pushHistory = (history: Map, tab: Tab): Map => {
+ const newHistory = new Map(history);
+ const slotId = getSlotId(tab);
+ const existing = newHistory.get(slotId);
+
+ if (existing) {
+ const newStack = existing.stack.slice(0, existing.currentIndex + 1);
+ newStack.push(tab);
+ newHistory.set(slotId, { stack: newStack, currentIndex: newStack.length - 1 });
+ } else {
+ newHistory.set(slotId, { stack: [tab], currentIndex: 0 });
+ }
+
+ return newHistory;
+};
+
+export const updateHistoryCurrent = (history: Map, tab: Tab): Map => {
+ const newHistory = new Map(history);
+ const slotId = getSlotId(tab);
+ const existing = newHistory.get(slotId);
+
+ if (existing && existing.currentIndex >= 0) {
+ const newStack = [...existing.stack];
+ newStack[existing.currentIndex] = tab;
+ newHistory.set(slotId, { ...existing, stack: newStack });
+ }
+
+ return newHistory;
+};