From f8645d8d3bae27d1667f276fac3ddda63a26f467 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:48:01 +0000 Subject: [PATCH 01/12] feat(contacts): refactor contacts with unified selection model and pin/ordering support Co-Authored-By: john@hyprnote.com --- .../devtool/seed/shared/organization.ts | 1 + .../main/body/advanced-search/index.tsx | 4 +- .../main/body/calendar/calendar-view.tsx | 531 +++++++++++++- .../main/body/contacts/contacts-list.tsx | 657 ++++++++++++++++++ .../components/main/body/contacts/index.tsx | 82 +-- .../main/body/contacts/organizations.tsx | 148 +++- .../components/main/body/contacts/people.tsx | 3 + .../components/main/body/contacts/shared.tsx | 79 ++- .../src/components/main/body/empty/index.tsx | 59 +- .../src/components/main/body/index.tsx | 2 +- .../src/components/main/body/search.tsx | 6 +- .../metadata/participants/chip.tsx | 2 +- .../components/main/sidebar/profile/index.tsx | 3 +- .../components/main/sidebar/search/item.tsx | 4 +- .../settings/general/permissions.tsx | 10 + .../persister/human/transform.test.ts | 10 + .../tinybase/persister/human/transform.ts | 7 + .../persister/organization/transform.test.ts | 48 ++ .../persister/organization/transform.ts | 11 +- apps/desktop/src/store/tinybase/store/main.ts | 8 + apps/desktop/src/store/zustand/tabs/schema.ts | 5 +- .../src/store/zustand/tabs/state.test.ts | 5 +- .../src/store/zustand/tabs/test-utils.ts | 3 +- packages/store/src/tinybase.ts | 4 + packages/store/src/zod.ts | 4 + plugins/windows/js/bindings.gen.ts | 3 +- plugins/windows/src/tab/state.rs | 13 +- 27 files changed, 1585 insertions(+), 127 deletions(-) create mode 100644 apps/desktop/src/components/main/body/contacts/contacts-list.tsx diff --git a/apps/desktop/src/components/devtool/seed/shared/organization.ts b/apps/desktop/src/components/devtool/seed/shared/organization.ts index 5ce1fa687e..c988176675 100644 --- a/apps/desktop/src/components/devtool/seed/shared/organization.ts +++ b/apps/desktop/src/components/devtool/seed/shared/organization.ts @@ -9,5 +9,6 @@ export const createOrganization = () => ({ data: { user_id: DEFAULT_USER_ID, name: faker.company.name(), + pinned: false, } satisfies Organization, }); diff --git a/apps/desktop/src/components/main/body/advanced-search/index.tsx b/apps/desktop/src/components/main/body/advanced-search/index.tsx index c863a0d440..89cf4aa1e7 100644 --- a/apps/desktop/src/components/main/body/advanced-search/index.tsx +++ b/apps/desktop/src/components/main/body/advanced-search/index.tsx @@ -68,12 +68,12 @@ function SearchView({ tab }: { tab: Extract }) { } else if (type === "human") { openNew({ type: "contacts", - state: { selectedPerson: id, selectedOrganization: null }, + state: { selected: { type: "person", id } }, }); } else if (type === "organization") { openNew({ type: "contacts", - state: { selectedOrganization: id, selectedPerson: null }, + state: { selected: { type: "organization", id } }, }); } }, diff --git a/apps/desktop/src/components/main/body/calendar/calendar-view.tsx b/apps/desktop/src/components/main/body/calendar/calendar-view.tsx index f76d73401b..02c3ac47d7 100644 --- a/apps/desktop/src/components/main/body/calendar/calendar-view.tsx +++ b/apps/desktop/src/components/main/body/calendar/calendar-view.tsx @@ -1,3 +1,4 @@ +import { platform } from "@tauri-apps/plugin-os"; import { addDays, addMonths, @@ -17,13 +18,33 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@hypr/ui/components/ui/accordion"; import { Button } from "@hypr/ui/components/ui/button"; import { ButtonGroup } from "@hypr/ui/components/ui/button-group"; -import { cn } from "@hypr/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@hypr/ui/components/ui/popover"; +import { safeParseDate } from "@hypr/utils"; +import { cn, TZDate } from "@hypr/utils"; -import { DayCell } from "./day-cell"; -import { useCalendarData, useNow, useWeekStartsOn } from "./hooks"; -import { CalendarSidebarContent } from "./sidebar"; +import { useConfigValue } from "../../../../config/use-config"; +import { useEvent, useIgnoredEvents } from "../../../../hooks/tinybase"; +import { usePermission } from "../../../../hooks/usePermissions"; +import * as main from "../../../../store/tinybase/store/main"; +import { getOrCreateSessionForEventId } from "../../../../store/tinybase/store/sessions"; +import { useTabs } from "../../../../store/zustand/tabs"; +import { AppleCalendarSelection } from "../../../settings/calendar/configure/apple/calendar-selection"; +import { SyncProvider } from "../../../settings/calendar/configure/apple/context"; +import { AccessPermissionRow } from "../../../settings/calendar/configure/apple/permission"; +import { PROVIDERS } from "../../../settings/calendar/shared"; +import { EventDisplay } from "../sessions/outer-header/metadata"; const WEEKDAY_HEADERS_SUN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const WEEKDAY_HEADERS_MON = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; @@ -56,6 +77,104 @@ function useVisibleCols(ref: React.RefObject) { return cols; } +function useTimezone() { + return useConfigValue("timezone") || undefined; +} + +function toTz(date: Date | string, tz?: string): Date { + const d = typeof date === "string" ? new Date(date) : date; + return tz ? new TZDate(d, tz) : d; +} + +function useNow() { + const tz = useTimezone(); + const [now, setNow] = useState(() => toTz(new Date(), tz)); + + useEffect(() => { + const interval = setInterval(() => { + setNow(toTz(new Date(), tz)); + }, 60000); + return () => clearInterval(interval); + }, [tz]); + + return now; +} + +function getSystemWeekStart(): 0 | 1 { + const locale = navigator.language || "en-US"; + try { + const options = new Intl.Locale(locale); + const info = (options as any).getWeekInfo?.() ?? (options as any).weekInfo; + if (info?.firstDay === 1) return 1; + } catch {} + return 0; +} + +function useWeekStartsOn(): 0 | 1 { + const value = useConfigValue("week_start"); + return useMemo(() => { + if (value === "monday") return 1; + if (value === "sunday") return 0; + return getSystemWeekStart(); + }, [value]); +} + +type CalendarData = { + eventIdsByDate: Record; + sessionIdsByDate: Record; +}; + +function useCalendarData(): CalendarData { + const tz = useTimezone(); + + const eventsTable = main.UI.useResultTable( + main.QUERIES.timelineEvents, + main.STORE_ID, + ); + const sessionsTable = main.UI.useResultTable( + main.QUERIES.timelineSessions, + main.STORE_ID, + ); + const { isIgnored } = useIgnoredEvents(); + + return useMemo(() => { + const eventIdsByDate: Record = {}; + const sessionIdsByDate: Record = {}; + + if (eventsTable) { + for (const [eventId, row] of Object.entries(eventsTable)) { + if (!row.title) continue; + const raw = safeParseDate(row.started_at); + if (!raw) continue; + const day = format(toTz(raw, tz), "yyyy-MM-dd"); + if (isIgnored(row.tracking_id_event, row.recurrence_series_id, day)) + continue; + (eventIdsByDate[day] ??= []).push(eventId); + } + + for (const ids of Object.values(eventIdsByDate)) { + ids.sort((a, b) => { + const aAllDay = eventsTable[a]?.is_all_day ? 0 : 1; + const bAllDay = eventsTable[b]?.is_all_day ? 0 : 1; + return aAllDay - bAllDay; + }); + } + } + + if (sessionsTable) { + for (const [sessionId, row] of Object.entries(sessionsTable)) { + if (row.event_json || !row.title) continue; + const raw = safeParseDate(row.created_at); + if (!raw) continue; + const key = format(toTz(raw, tz), "yyyy-MM-dd"); + (sessionIdsByDate[key] ??= []).push(sessionId); + } + } + + return { eventIdsByDate, sessionIdsByDate }; + }, [eventsTable, sessionsTable, tz, isIgnored]); +} + export function CalendarView() { const now = useNow(); const weekStartsOn = useWeekStartsOn(); @@ -233,3 +352,407 @@ export function CalendarView() { ); } + +function useVisibleItemCount( + ref: React.RefObject, + totalItems: number, +) { + const [maxVisible, setMaxVisible] = useState(totalItems); + + useEffect(() => { + const el = ref.current; + if (!el || totalItems === 0) return; + + const compute = () => { + const available = el.clientHeight; + const children = Array.from(el.children) as HTMLElement[]; + if (children.length === 0 || available <= 0) return; + + const chipH = children[0].offsetHeight; + if (chipH === 0) return; + + const gap = parseFloat(getComputedStyle(el).rowGap) || 0; + + const allH = totalItems * chipH + Math.max(0, totalItems - 1) * gap; + if (allH <= available) { + setMaxVisible((prev) => (prev === totalItems ? prev : totalItems)); + return; + } + + const overflowH = chipH; + let count = 0; + let used = 0; + + while (count < totalItems) { + const next = chipH + (count > 0 ? gap : 0); + const remaining = totalItems - count - 1; + const moreSpace = remaining > 0 ? overflowH + gap : 0; + if (used + next + moreSpace > available) break; + used += next; + count++; + } + + const result = Math.max(1, count); + setMaxVisible((prev) => (prev === result ? prev : result)); + }; + + compute(); + const observer = new ResizeObserver(compute); + observer.observe(el); + return () => observer.disconnect(); + }, [ref, totalItems]); + + return maxVisible; +} + +function DayCell({ + day, + isCurrentMonth, + calendarData, +}: { + day: Date; + isCurrentMonth: boolean; + calendarData: CalendarData; +}) { + const dateKey = format(day, "yyyy-MM-dd"); + const eventIds = calendarData.eventIdsByDate[dateKey] ?? []; + const sessionIds = calendarData.sessionIdsByDate[dateKey] ?? []; + + const now = useNow(); + const itemsRef = useRef(null); + const totalItems = eventIds.length + sessionIds.length; + const maxVisible = useVisibleItemCount(itemsRef, totalItems); + const today = format(day, "yyyy-MM-dd") === format(now, "yyyy-MM-dd"); + + const visibleEvents = eventIds.slice(0, maxVisible); + const remainingSlots = Math.max(0, maxVisible - visibleEvents.length); + const visibleSessions = sessionIds.slice(0, remainingSlots); + const shownCount = visibleEvents.length + visibleSessions.length; + const overflow = totalItems - shownCount; + + return ( +
+
+
+ {format(day, "d")} +
+
+
+ {visibleEvents.map((eventId) => ( + + ))} + {visibleSessions.map((sessionId) => ( + + ))} + {overflow > 0 && ( + + + + + e.stopPropagation()} + > +
+ {format(day, "MMM d, yyyy")} +
+
+ {eventIds.map((eventId) => ( + + ))} + {sessionIds.map((sessionId) => ( + + ))} +
+
+
+ )} +
+
+ ); +} + +function useCalendarColor(calendarId: string | null): string | null { + const calendar = main.UI.useRow("calendars", calendarId ?? "", main.STORE_ID); + if (!calendarId) return null; + return calendar?.color ? String(calendar.color) : null; +} + +function EventChip({ eventId }: { eventId: string }) { + const tz = useTimezone(); + const event = main.UI.useResultRow( + main.QUERIES.timelineEvents, + eventId, + main.STORE_ID, + ); + const calendarColor = useCalendarColor( + (event?.calendar_id as string) ?? null, + ); + + if (!event || !event.title) { + return null; + } + + const isAllDay = !!event.is_all_day; + const color = calendarColor ?? "#888"; + + const startedAt = event.started_at + ? format(toTz(event.started_at as string, tz), "h:mm a") + : null; + + return ( + + + {isAllDay ? ( + + ) : ( + + )} + + e.stopPropagation()} + > + + + + ); +} + +function EventPopoverContent({ eventId }: { eventId: string }) { + const event = useEvent(eventId); + const store = main.UI.useStore(main.STORE_ID); + const openNew = useTabs((state) => state.openNew); + const tz = useTimezone(); + + const eventRow = main.UI.useResultRow( + main.QUERIES.timelineEvents, + eventId, + main.STORE_ID, + ); + + const handleOpen = useCallback(() => { + if (!store) return; + const title = (eventRow?.title as string) || "Untitled"; + const sessionId = getOrCreateSessionForEventId(store, eventId, title, tz); + openNew({ type: "sessions", id: sessionId }); + }, [store, eventId, eventRow?.title, openNew, tz]); + + if (!event) { + return null; + } + + return ( +
+ + +
+ ); +} + +function SessionChip({ sessionId }: { sessionId: string }) { + const tz = useTimezone(); + const session = main.UI.useResultRow( + main.QUERIES.timelineSessions, + sessionId, + main.STORE_ID, + ); + + if (!session || !session.title) { + return null; + } + + const createdAt = session.created_at + ? format(toTz(session.created_at as string, tz), "h:mm a") + : null; + + return ( + + + + + e.stopPropagation()} + > + + + + ); +} + +function SessionPopoverContent({ sessionId }: { sessionId: string }) { + const session = main.UI.useResultRow( + main.QUERIES.timelineSessions, + sessionId, + main.STORE_ID, + ); + const openNew = useTabs((state) => state.openNew); + const tz = useTimezone(); + + const handleOpen = useCallback(() => { + openNew({ type: "sessions", id: sessionId }); + }, [openNew, sessionId]); + + if (!session) { + return null; + } + + const createdAt = session.created_at + ? format(toTz(session.created_at as string, tz), "MMM d, yyyy h:mm a") + : null; + + return ( +
+
+ {session.title as string} +
+
+ {createdAt &&
{createdAt}
} + +
+ ); +} + +function CalendarSidebarContent() { + const isMacos = platform() === "macos"; + const calendar = usePermission("calendar"); + + const visibleProviders = PROVIDERS.filter( + (p) => p.platform === "all" || (p.platform === "macos" && isMacos), + ); + + return ( + + {visibleProviders.map((provider) => + provider.disabled ? ( +
+ {provider.icon} + {provider.displayName} + {provider.badge && ( + + {provider.badge} + + )} +
+ ) : ( + + +
+ {provider.icon} + + {provider.displayName} + + {provider.badge && ( + + {provider.badge} + + )} +
+
+ + {provider.id === "apple" && ( +
+ {calendar.status !== "authorized" && ( +
+ +
+ )} + {calendar.status === "authorized" && ( + + + + )} +
+ )} +
+
+ ), + )} +
+ ); +} diff --git a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx new file mode 100644 index 0000000000..41e55b1fe8 --- /dev/null +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -0,0 +1,657 @@ +import { Building2, CornerDownLeft, Pin } from "lucide-react"; +import { Reorder } from "motion/react"; +import React, { useCallback, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import type { ContactsSelection } from "@hypr/plugin-windows"; +import { cn } from "@hypr/utils"; + +import * as main from "../../../../store/tinybase/store/main"; +import { ColumnHeader, getInitials, type SortOption } from "./shared"; + +type ContactItem = + | { kind: "person"; id: string } + | { kind: "organization"; id: string }; + +export function ContactsListColumn({ + selected, + setSelected, +}: { + selected: ContactsSelection | null; + setSelected: (value: ContactsSelection | null) => void; +}) { + const [showNewPerson, setShowNewPerson] = useState(false); + const [showNewOrg, setShowNewOrg] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [sortOption, setSortOption] = useState("alphabetical"); + const [showSearch, setShowSearch] = useState(false); + + useHotkeys( + "mod+f", + () => setShowSearch(true), + { preventDefault: true, enableOnFormTags: true }, + [setShowSearch], + ); + + const allHumans = main.UI.useTable("humans", main.STORE_ID); + const allOrgs = main.UI.useTable("organizations", main.STORE_ID); + const store = main.UI.useStore(main.STORE_ID); + + const alphabeticalHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); + const reverseAlphabeticalHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "name", + true, + 0, + undefined, + main.STORE_ID, + ); + const newestHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "created_at", + true, + 0, + undefined, + main.STORE_ID, + ); + const oldestHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "created_at", + false, + 0, + undefined, + main.STORE_ID, + ); + + const alphabeticalOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); + const reverseAlphabeticalOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "name", + true, + 0, + undefined, + main.STORE_ID, + ); + const newestOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "created_at", + true, + 0, + undefined, + main.STORE_ID, + ); + const oldestOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "created_at", + false, + 0, + undefined, + main.STORE_ID, + ); + + const sortedHumanIds = + sortOption === "alphabetical" + ? alphabeticalHumanIds + : sortOption === "reverse-alphabetical" + ? reverseAlphabeticalHumanIds + : sortOption === "newest" + ? newestHumanIds + : oldestHumanIds; + + const sortedOrgIds = + sortOption === "alphabetical" + ? alphabeticalOrgIds + : sortOption === "reverse-alphabetical" + ? reverseAlphabeticalOrgIds + : sortOption === "newest" + ? newestOrgIds + : oldestOrgIds; + + const { pinnedHumanIds, unpinnedHumanIds } = useMemo(() => { + const pinned = sortedHumanIds.filter((id) => allHumans[id]?.pinned); + const unpinned = sortedHumanIds.filter((id) => !allHumans[id]?.pinned); + + const sortedPinned = [...pinned].sort((a, b) => { + const orderA = + (allHumans[a]?.pin_order as number | undefined) ?? Infinity; + const orderB = + (allHumans[b]?.pin_order as number | undefined) ?? Infinity; + return orderA - orderB; + }); + + return { pinnedHumanIds: sortedPinned, unpinnedHumanIds: unpinned }; + }, [sortedHumanIds, allHumans]); + + const { pinnedOrgIds, unpinnedOrgIds } = useMemo(() => { + const pinned = sortedOrgIds.filter((id) => allOrgs[id]?.pinned); + const unpinned = sortedOrgIds.filter((id) => !allOrgs[id]?.pinned); + + const sortedPinned = [...pinned].sort((a, b) => { + const orderA = (allOrgs[a]?.pin_order as number | undefined) ?? Infinity; + const orderB = (allOrgs[b]?.pin_order as number | undefined) ?? Infinity; + return orderA - orderB; + }); + + return { pinnedOrgIds: sortedPinned, unpinnedOrgIds: unpinned }; + }, [sortedOrgIds, allOrgs]); + + const { pinnedItems, nonPinnedItems } = useMemo(() => { + const q = searchValue.toLowerCase().trim(); + + const filterHuman = (id: string) => { + if (!q) return true; + const human = allHumans[id]; + const name = (human?.name ?? "").toLowerCase(); + const email = (human?.email ?? "").toLowerCase(); + return name.includes(q) || email.includes(q); + }; + + const filterOrg = (id: string) => { + if (!q) return true; + const name = (allOrgs[id]?.name ?? "").toLowerCase(); + return name.includes(q); + }; + + const allPinned = [ + ...pinnedHumanIds.filter(filterHuman).map((id) => ({ + kind: "person" as const, + id, + pin_order: (allHumans[id]?.pin_order as number | undefined) ?? Infinity, + })), + ...pinnedOrgIds.filter(filterOrg).map((id) => ({ + kind: "organization" as const, + id, + pin_order: (allOrgs[id]?.pin_order as number | undefined) ?? Infinity, + })), + ] + .sort((a, b) => a.pin_order - b.pin_order) + .map(({ kind, id }) => ({ kind, id })); + + const unpinnedOrgs: ContactItem[] = unpinnedOrgIds + .filter(filterOrg) + .map((id) => ({ kind: "organization" as const, id })); + + const unpinnedPeople: ContactItem[] = unpinnedHumanIds + .filter(filterHuman) + .map((id) => ({ kind: "person" as const, id })); + + return { + pinnedItems: allPinned, + nonPinnedItems: [...unpinnedOrgs, ...unpinnedPeople], + }; + }, [ + pinnedHumanIds, + unpinnedHumanIds, + pinnedOrgIds, + unpinnedOrgIds, + allOrgs, + allHumans, + searchValue, + ]); + + const handleReorderPinned = useCallback( + (newOrder: string[]) => { + if (!store) return; + store.transaction(() => { + newOrder.forEach((id, index) => { + const item = pinnedItems.find((i) => i.id === id); + if (item?.kind === "person") { + store.setCell("humans", id, "pin_order", index); + } else if (item?.kind === "organization") { + store.setCell("organizations", id, "pin_order", index); + } + }); + }); + }, + [store, pinnedItems], + ); + + const handleAdd = useCallback(() => { + setShowNewPerson(true); + }, []); + + const isActive = (item: ContactItem) => { + if (!selected) return false; + return selected.type === item.kind && selected.id === item.id; + }; + + return ( +
+ +
+
+ {showNewPerson && ( + { + setShowNewPerson(false); + setSelected({ type: "person", id: humanId }); + }} + onCancel={() => setShowNewPerson(false)} + /> + )} + {showNewOrg && ( + setShowNewOrg(false)} + onCancel={() => setShowNewOrg(false)} + /> + )} + {pinnedItems.length > 0 && ( + i.id)} + onReorder={handleReorderPinned} + className="flex flex-col" + > + {pinnedItems.map((item) => ( + + {item.kind === "person" ? ( + + setSelected({ type: "person", id: item.id }) + } + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + /> + )} + + ))} + + )} + {pinnedItems.length > 0 && nonPinnedItems.length > 0 && ( +
+ )} + {nonPinnedItems.map((item) => + item.kind === "person" ? ( + setSelected({ type: "person", id: item.id })} + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + /> + ), + )} +
+
+
+ ); +} + +function PersonItem({ + humanId, + active, + onClick, +}: { + humanId: string; + active: boolean; + onClick: () => void; +}) { + const person = main.UI.useRow("humans", humanId, main.STORE_ID); + const isPinned = Boolean(person.pinned); + const personName = String(person.name ?? ""); + const personEmail = String(person.email ?? ""); + + const store = main.UI.useStore(main.STORE_ID); + + const handleTogglePin = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!store) return; + + const currentPinned = store.getCell("humans", humanId, "pinned"); + if (currentPinned) { + store.setPartialRow("humans", humanId, { + pinned: false, + pin_order: undefined, + }); + } else { + const allHumans = store.getTable("humans"); + const allOrgs = store.getTable("organizations"); + const maxHumanOrder = Object.values(allHumans).reduce((max, h) => { + const order = (h.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + const maxOrgOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + store.setPartialRow("humans", humanId, { + pinned: true, + pin_order: Math.max(maxHumanOrder, maxOrgOrder) + 1, + }); + } + }, + [store, humanId], + ); + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn([ + "group w-full text-left px-3 py-2 rounded-md text-sm border hover:bg-neutral-100 transition-colors flex items-center gap-2 bg-white overflow-hidden", + active ? "border-neutral-500 bg-neutral-100" : "border-transparent", + ])} + > +
+ + {getInitials(personName || personEmail)} + +
+
+
+ {personName || personEmail || "Unnamed"} +
+ {personEmail && personName && ( +
{personEmail}
+ )} +
+ +
+ ); +} + +function OrganizationItem({ + organizationId, + active, + onClick, +}: { + organizationId: string; + active: boolean; + onClick: () => void; +}) { + const organization = main.UI.useRow( + "organizations", + organizationId, + main.STORE_ID, + ); + const isPinned = Boolean(organization.pinned); + const store = main.UI.useStore(main.STORE_ID); + + const handleTogglePin = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!store) return; + + const currentPinned = store.getCell( + "organizations", + organizationId, + "pinned", + ); + if (currentPinned) { + store.setPartialRow("organizations", organizationId, { + pinned: false, + pin_order: undefined, + }); + } else { + const allOrgs = store.getTable("organizations"); + const allHumans = store.getTable("humans"); + const maxOrgOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + const maxHumanOrder = Object.values(allHumans).reduce((max, h) => { + const order = (h.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + store.setPartialRow("organizations", organizationId, { + pinned: true, + pin_order: Math.max(maxOrgOrder, maxHumanOrder) + 1, + }); + } + }, + [store, organizationId], + ); + + if (!organization) { + return null; + } + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn([ + "group w-full text-left px-3 py-2 rounded-md text-sm border hover:bg-neutral-100 transition-colors flex items-center gap-2 overflow-hidden", + active ? "border-neutral-500 bg-neutral-100" : "border-transparent", + ])} + > +
+ +
+
+
{organization.name}
+
+ +
+ ); +} + +function NewPersonForm({ + onSave, + onCancel, +}: { + onSave: (humanId: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const userId = main.UI.useValue("user_id", main.STORE_ID); + + const createHuman = main.UI.useSetRowCallback( + "humans", + (p: { name: string; humanId: string }) => p.humanId, + (p: { name: string; humanId: string }) => ({ + user_id: userId || "", + created_at: new Date().toISOString(), + name: p.name, + email: "", + org_id: "", + job_title: "", + linkedin_username: "", + memo: "", + pinned: false, + }), + [userId], + main.STORE_ID, + ); + + const handleAdd = () => { + const humanId = crypto.randomUUID(); + createHuman({ humanId, name: name.trim() }); + setName(""); + onSave(humanId); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + } + if (e.key === "Escape") { + onCancel(); + } + }; + + return ( +
+
+
+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add person" + className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" + autoFocus + /> + {name.trim() && ( + + )} +
+
+
+ ); +} + +function NewOrgForm({ + onSave, + onCancel, +}: { + onSave: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const userId = main.UI.useValue("user_id", main.STORE_ID); + + const handleAdd = main.UI.useAddRowCallback( + "organizations", + () => ({ + user_id: userId || "", + name: name.trim(), + created_at: new Date().toISOString(), + }), + [name, userId], + main.STORE_ID, + () => { + setName(""); + onSave(); + }, + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + } + if (e.key === "Escape") { + onCancel(); + } + }; + + return ( +
+
+
+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add organization" + className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" + autoFocus + /> + {name.trim() && ( + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/components/main/body/contacts/index.tsx b/apps/desktop/src/components/main/body/contacts/index.tsx index f7ae11489f..763bf4efa1 100644 --- a/apps/desktop/src/components/main/body/contacts/index.tsx +++ b/apps/desktop/src/components/main/body/contacts/index.tsx @@ -2,6 +2,7 @@ import { Contact2Icon } from "lucide-react"; import { useCallback, useEffect } from "react"; import { useShallow } from "zustand/shallow"; +import type { ContactsSelection } from "@hypr/plugin-windows"; import { ResizableHandle, ResizablePanel, @@ -12,10 +13,9 @@ import * as main from "../../../../store/tinybase/store/main"; import { type Tab, useTabs } from "../../../../store/zustand/tabs"; import { StandardTabWrapper } from "../index"; import { type TabItem, TabItemBase } from "../shared"; +import { ContactsListColumn } from "./contacts-list"; import { DetailsColumn } from "./details"; import { OrganizationDetailsColumn } from "./organization-details"; -import { OrganizationsColumn } from "./organizations"; -import { PeopleColumn, useSortedHumanIds } from "./people"; export const TabItemContact: TabItem> = ({ tab, @@ -67,26 +67,11 @@ function ContactView({ tab }: { tab: Extract }) { })), ); - const { selectedOrganization, selectedPerson } = tab.state; + const selected = tab.state.selected; - const setSelectedOrganization = useCallback( - (value: string | null) => { - updateContactsTabState(tab, { - ...tab.state, - selectedOrganization: value, - // Clear selected person when selecting an organization - selectedPerson: value ? null : tab.state.selectedPerson, - }); - }, - [updateContactsTabState, tab], - ); - - const setSelectedPerson = useCallback( - (value: string | null) => { - updateContactsTabState(tab, { - ...tab.state, - selectedPerson: value, - }); + const setSelected = useCallback( + (value: ContactsSelection | null) => { + updateContactsTabState(tab, { selected: value }); }, [updateContactsTabState, tab], ); @@ -108,8 +93,9 @@ function ContactView({ tab }: { tab: Extract }) { (id: string) => { invalidateResource("humans", id); deletePersonFromStore(id); + setSelected(null); }, - [invalidateResource, deletePersonFromStore], + [invalidateResource, deletePersonFromStore, setSelected], ); const deleteOrganizationFromStore = main.UI.useDelRowCallback( @@ -122,52 +108,44 @@ function ContactView({ tab }: { tab: Extract }) { (id: string) => { invalidateResource("organizations" as const, id); deleteOrganizationFromStore(id); + setSelected(null); }, - [invalidateResource, deleteOrganizationFromStore], + [invalidateResource, deleteOrganizationFromStore, setSelected], ); - // Get the list of humanIds to auto-select the first person (only when no org is selected) - const { humanIds } = useSortedHumanIds(selectedOrganization); + const allHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); - // Auto-select first person on load if no person is selected and no org is selected useEffect(() => { - if (!selectedOrganization && !selectedPerson && humanIds.length > 0) { - setSelectedPerson(humanIds[0]); + if (!selected && allHumanIds.length > 0) { + setSelected({ type: "person", id: allHumanIds[0] }); } - }, [humanIds, selectedPerson, selectedOrganization, setSelectedPerson]); - - const isViewingOrgDetails = selectedOrganization && !selectedPerson; + }, [allHumanIds, selected, setSelected]); return ( - - - - - - + + - - {selectedOrganization && !selectedPerson ? ( - // Show organization details when org is selected but no person is selected + + {selected?.type === "organization" ? ( + setSelected({ type: "person", id: personId }) + } /> ) : ( - // Show person details when a person is selected or no org is selected diff --git a/apps/desktop/src/components/main/body/contacts/organizations.tsx b/apps/desktop/src/components/main/body/contacts/organizations.tsx index c9690810fa..266b6ab04f 100644 --- a/apps/desktop/src/components/main/body/contacts/organizations.tsx +++ b/apps/desktop/src/components/main/body/contacts/organizations.tsx @@ -1,5 +1,6 @@ -import { Building2, CornerDownLeft, User } from "lucide-react"; -import React, { useState } from "react"; +import { Building2, CornerDownLeft, Pin, User } from "lucide-react"; +import { Reorder } from "motion/react"; +import React, { useCallback, useMemo, useState } from "react"; import { cn } from "@hypr/utils"; @@ -17,22 +18,41 @@ export function OrganizationsColumn({ }) { const [showNewOrg, setShowNewOrg] = useState(false); const [searchValue, setSearchValue] = useState(""); - const { organizationIds, sortOption, setSortOption } = + const { pinnedIds, unpinnedIds, sortOption, setSortOption } = useSortedOrganizationIds(); const allOrgs = main.UI.useTable("organizations", main.STORE_ID); + const store = main.UI.useStore(main.STORE_ID); - const filteredOrganizationIds = React.useMemo(() => { - if (!searchValue.trim()) { - return organizationIds; - } + const filteredPinnedIds = useMemo(() => { + if (!searchValue.trim()) return pinnedIds; + const q = searchValue.toLowerCase(); + return pinnedIds.filter((id) => { + const nameLower = (allOrgs[id]?.name ?? "").toLowerCase(); + return nameLower.includes(q); + }); + }, [pinnedIds, searchValue, allOrgs]); - return organizationIds.filter((id) => { - const org = allOrgs[id]; - const nameLower = (org?.name ?? "").toLowerCase(); - return nameLower.includes(searchValue.toLowerCase()); + const filteredUnpinnedIds = useMemo(() => { + if (!searchValue.trim()) return unpinnedIds; + const q = searchValue.toLowerCase(); + return unpinnedIds.filter((id) => { + const nameLower = (allOrgs[id]?.name ?? "").toLowerCase(); + return nameLower.includes(q); }); - }, [organizationIds, searchValue, allOrgs]); + }, [unpinnedIds, searchValue, allOrgs]); + + const handleReorderPinned = useCallback( + (newOrder: string[]) => { + if (!store) return; + store.transaction(() => { + newOrder.forEach((id, index) => { + store.setCell("organizations", id, "pin_order", index); + }); + }); + }, + [store], + ); return (
@@ -62,7 +82,31 @@ export function OrganizationsColumn({ onCancel={() => setShowNewOrg(false)} /> )} - {filteredOrganizationIds.map((orgId) => ( + {filteredPinnedIds.length > 0 && ( + + {filteredPinnedIds.map((orgId) => ( + + + + ))} + + )} + {filteredPinnedIds.length > 0 && filteredUnpinnedIds.length > 0 && ( +
+ )} + {filteredUnpinnedIds.map((orgId) => ( { + const pinned = sortedIds.filter((id) => allOrgs[id]?.pinned); + const unpinned = sortedIds.filter((id) => !allOrgs[id]?.pinned); + + const sortedPinned = [...pinned].sort((a, b) => { + const orderA = (allOrgs[a]?.pin_order as number | undefined) ?? Infinity; + const orderB = (allOrgs[b]?.pin_order as number | undefined) ?? Infinity; + return orderA - orderB; + }); + + return { pinnedIds: sortedPinned, unpinnedIds: unpinned }; + }, [sortedIds, allOrgs]); + + return { pinnedIds, unpinnedIds, sortOption, setSortOption }; } function OrganizationItem({ @@ -143,6 +202,39 @@ function OrganizationItem({ organizationId, main.STORE_ID, ); + const isPinned = Boolean(organization.pinned); + const store = main.UI.useStore(main.STORE_ID); + + const handleTogglePin = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!store) return; + + const currentPinned = store.getCell( + "organizations", + organizationId, + "pinned", + ); + if (currentPinned) { + store.setPartialRow("organizations", organizationId, { + pinned: false, + pin_order: undefined, + }); + } else { + const allOrgs = store.getTable("organizations"); + const maxOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + store.setPartialRow("organizations", organizationId, { + pinned: true, + pin_order: maxOrder + 1, + }); + } + }, + [store, organizationId], + ); + if (!organization) { return null; } @@ -155,13 +247,33 @@ function OrganizationItem({ isSelected && isViewingDetails ? "border-black" : "border-transparent", ])} > - +

{organization.name}

+ +
); } diff --git a/apps/desktop/src/components/main/body/contacts/people.tsx b/apps/desktop/src/components/main/body/contacts/people.tsx index 1d282f75fd..fbf5e33044 100644 --- a/apps/desktop/src/components/main/body/contacts/people.tsx +++ b/apps/desktop/src/components/main/body/contacts/people.tsx @@ -105,6 +105,9 @@ export function PeopleColumn({ ))} )} + {filteredPinnedIds.length > 0 && filteredUnpinnedIds.length > 0 && ( +
+ )} {filteredUnpinnedIds.map((humanId) => ( { if (!name) { @@ -35,28 +36,44 @@ export function SortDropdown({ setSortOption: (option: SortOption) => void; }) { return ( - + + + + + + setSortOption(value as SortOption)} + > + + A-Z + + + Z-A + + + Oldest + + + Newest + + + + ); } @@ -67,6 +84,8 @@ export function ColumnHeader({ onAdd, searchValue, onSearchChange, + showSearch: showSearchProp, + onShowSearchChange, }: { title: string; sortOption?: SortOption; @@ -74,8 +93,12 @@ export function ColumnHeader({ onAdd: () => void; searchValue?: string; onSearchChange?: (value: string) => void; + showSearch?: boolean; + onShowSearchChange?: (show: boolean) => void; }) { - const [showSearch, setShowSearch] = useState(false); + const [showSearchInternal, setShowSearchInternal] = useState(false); + const showSearch = showSearchProp ?? showSearchInternal; + const setShowSearch = onShowSearchChange ?? setShowSearchInternal; const handleSearchToggle = () => { if (showSearch) { diff --git a/apps/desktop/src/components/main/body/empty/index.tsx b/apps/desktop/src/components/main/body/empty/index.tsx index 82a645f016..e4b0c7e511 100644 --- a/apps/desktop/src/components/main/body/empty/index.tsx +++ b/apps/desktop/src/components/main/body/empty/index.tsx @@ -1,5 +1,6 @@ import { AppWindowIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Kbd } from "@hypr/ui/components/ui/kbd"; @@ -51,6 +52,57 @@ export function TabContentEmpty({ ); } +const TIPS = [ + { text: "Press ⌘⇧N to create a new note and start listening immediately" }, + { text: "Use ⌘K to quickly search across all your notes" }, + { + text: "Hyprnote works fully offline — set up Ollama or LM Studio in AI Settings", + }, + { + text: "Press ⌘⇧J to open AI Chat and ask follow-up questions about your notes", + }, + { + text: "Use templates to get structured summaries tailored to your meeting type", + }, + { text: "Press ⌘⇧T to reopen the last tab you closed" }, + { + text: "Connect your Apple Calendar to automatically see upcoming meetings", + }, +]; + +function RotatingTip() { + const [index, setIndex] = useState(() => + Math.floor(Math.random() * TIPS.length), + ); + + useEffect(() => { + const interval = setInterval(() => { + setIndex((prev) => (prev + 1) % TIPS.length); + }, 5000); + return () => clearInterval(interval); + }, []); + + return ( +
+ Did you know? +
+ + + {TIPS[index].text} + + +
+
+ ); +} + function EmptyView() { const newNote = useNewNote({ behavior: "current" }); const openCurrent = useTabs((state) => state.openCurrent); @@ -85,7 +137,7 @@ function EmptyView() { ); return ( -
+
+
+ +
openNew({ type: "contacts", - state: { selectedOrganization: null, selectedPerson: null }, + state: { selected: null }, }), { preventDefault: true, diff --git a/apps/desktop/src/components/main/body/search.tsx b/apps/desktop/src/components/main/body/search.tsx index 87b1df446c..33cbf1e4d0 100644 --- a/apps/desktop/src/components/main/body/search.tsx +++ b/apps/desktop/src/components/main/body/search.tsx @@ -168,16 +168,14 @@ function ExpandedSearch({ onBlur }: { onBlur?: () => void }) { openNew({ type: "contacts", state: { - selectedPerson: item.id, - selectedOrganization: null, + selected: { type: "person", id: item.id }, }, }); } else if (item.type === "organization") { openNew({ type: "contacts", state: { - selectedOrganization: item.id, - selectedPerson: null, + selected: { type: "organization", id: item.id }, }, }); } diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx index 07d65e98f6..6ed1ca65af 100644 --- a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx @@ -29,7 +29,7 @@ export function ParticipantChip({ mappingId }: { mappingId: string }) { if (assignedHumanId) { useTabs.getState().openNew({ type: "contacts", - state: { selectedOrganization: null, selectedPerson: assignedHumanId }, + state: { selected: { type: "person", id: assignedHumanId } }, }); } }, [assignedHumanId]); diff --git a/apps/desktop/src/components/main/sidebar/profile/index.tsx b/apps/desktop/src/components/main/sidebar/profile/index.tsx index c6c4439e95..6af5227f19 100644 --- a/apps/desktop/src/components/main/sidebar/profile/index.tsx +++ b/apps/desktop/src/components/main/sidebar/profile/index.tsx @@ -106,8 +106,7 @@ export function ProfileSection({ onExpandChange }: ProfileSectionProps = {}) { openNew({ type: "contacts", state: { - selectedOrganization: null, - selectedPerson: null, + selected: null, }, }); closeMenu(); diff --git a/apps/desktop/src/components/main/sidebar/search/item.tsx b/apps/desktop/src/components/main/sidebar/search/item.tsx index 8e2fdd9d6c..2b6447acaf 100644 --- a/apps/desktop/src/components/main/sidebar/search/item.tsx +++ b/apps/desktop/src/components/main/sidebar/search/item.tsx @@ -282,13 +282,13 @@ function getTab(result: SearchResult): TabInput | null { if (result.type === "human") { return { type: "contacts", - state: { selectedPerson: result.id, selectedOrganization: null }, + state: { selected: { type: "person", id: result.id } }, }; } if (result.type === "organization") { return { type: "contacts", - state: { selectedOrganization: result.id, selectedPerson: null }, + state: { selected: { type: "organization", id: result.id } }, }; } diff --git a/apps/desktop/src/components/settings/general/permissions.tsx b/apps/desktop/src/components/settings/general/permissions.tsx index 71234adbdd..2e742e98a1 100644 --- a/apps/desktop/src/components/settings/general/permissions.tsx +++ b/apps/desktop/src/components/settings/general/permissions.tsx @@ -131,6 +131,7 @@ export function Permissions() { const mic = usePermission("microphone"); const systemAudio = usePermission("systemAudio"); const accessibility = usePermission("accessibility"); + const contacts = usePermission("contacts"); return (
@@ -163,6 +164,15 @@ export function Permissions() { onReset={accessibility.reset} onOpen={accessibility.open} /> +
); diff --git a/apps/desktop/src/store/tinybase/persister/human/transform.test.ts b/apps/desktop/src/store/tinybase/persister/human/transform.test.ts index 2bdbf80f86..735a93d539 100644 --- a/apps/desktop/src/store/tinybase/persister/human/transform.test.ts +++ b/apps/desktop/src/store/tinybase/persister/human/transform.test.ts @@ -59,6 +59,7 @@ describe("frontmatterToHuman", () => { ); expect(result).toEqual({ user_id: "user-1", + created_at: undefined, name: "John Doe", email: "john@example.com", org_id: "org-1", @@ -66,6 +67,7 @@ describe("frontmatterToHuman", () => { linkedin_username: "johndoe", memo: "Notes", pinned: false, + pin_order: undefined, }); }); }); @@ -81,6 +83,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([ "a@example.com", @@ -98,6 +101,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([]); }); @@ -112,6 +116,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([ "a@example.com", @@ -129,6 +134,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual(["a@example.com"]); }); @@ -143,6 +149,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "Some notes", pinned: false, + created_at: "", }); expect(result.body).toBe("Some notes"); }); @@ -150,6 +157,7 @@ describe("humanToFrontmatter", () => { test("converts all fields correctly", () => { const result = humanToFrontmatter({ user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "John Doe", email: "john@example.com", org_id: "org-1", @@ -161,12 +169,14 @@ describe("humanToFrontmatter", () => { expect(result).toEqual({ frontmatter: { user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "John Doe", emails: ["john@example.com"], org_id: "org-1", job_title: "Engineer", linkedin_username: "johndoe", pinned: false, + pin_order: 0, }, body: "Notes", }); diff --git a/apps/desktop/src/store/tinybase/persister/human/transform.ts b/apps/desktop/src/store/tinybase/persister/human/transform.ts index 1c4f9dc4fa..7c5acde1b1 100644 --- a/apps/desktop/src/store/tinybase/persister/human/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/human/transform.ts @@ -27,12 +27,17 @@ function frontmatterToStore( ): HumanFrontmatter { return { user_id: String(frontmatter.user_id ?? ""), + created_at: frontmatter.created_at + ? String(frontmatter.created_at) + : undefined, name: String(frontmatter.name ?? ""), email: emailsToStore(frontmatter), org_id: String(frontmatter.org_id ?? ""), job_title: String(frontmatter.job_title ?? ""), linkedin_username: String(frontmatter.linkedin_username ?? ""), pinned: Boolean(frontmatter.pinned ?? false), + pin_order: + frontmatter.pin_order != null ? Number(frontmatter.pin_order) : undefined, }; } @@ -41,12 +46,14 @@ function storeToFrontmatter( ): Record { return { user_id: store.user_id ?? "", + created_at: store.created_at ?? "", name: store.name ?? "", emails: emailToFrontmatter(store.email), org_id: store.org_id ?? "", job_title: store.job_title ?? "", linkedin_username: store.linkedin_username ?? "", pinned: store.pinned ?? false, + pin_order: store.pin_order ?? 0, }; } diff --git a/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts b/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts index 8d3e2109fe..e85b40d966 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts @@ -10,13 +10,17 @@ describe("frontmatterToOrganization", () => { const result = frontmatterToOrganization( { user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", }, "", ); expect(result).toEqual({ user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", + pinned: false, + pin_order: undefined, }); }); @@ -24,21 +28,65 @@ describe("frontmatterToOrganization", () => { const result = frontmatterToOrganization({}, ""); expect(result).toEqual({ user_id: "", + created_at: undefined, name: "", + pinned: false, + pin_order: undefined, + }); + }); + + test("preserves pinned state", () => { + const result = frontmatterToOrganization( + { + user_id: "user-1", + name: "Acme Corp", + pinned: true, + }, + "", + ); + expect(result).toEqual({ + user_id: "user-1", + created_at: undefined, + name: "Acme Corp", + pinned: true, + pin_order: undefined, }); }); }); describe("organizationToFrontmatter", () => { test("converts organization storage to frontmatter", () => { + const result = organizationToFrontmatter({ + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + pinned: false, + }); + expect(result).toEqual({ + frontmatter: { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + pinned: false, + pin_order: 0, + }, + body: "", + }); + }); + + test("converts pinned organization to frontmatter", () => { const result = organizationToFrontmatter({ user_id: "user-1", name: "Acme Corp", + pinned: true, }); expect(result).toEqual({ frontmatter: { user_id: "user-1", + created_at: "", name: "Acme Corp", + pinned: true, + pin_order: 0, }, body: "", }); diff --git a/apps/desktop/src/store/tinybase/persister/organization/transform.ts b/apps/desktop/src/store/tinybase/persister/organization/transform.ts index 372064f2a0..d7ba1e2003 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/transform.ts @@ -7,7 +7,13 @@ export function frontmatterToOrganization( ): OrganizationStorage { return { user_id: String(frontmatter.user_id ?? ""), + created_at: frontmatter.created_at + ? String(frontmatter.created_at) + : undefined, name: String(frontmatter.name ?? ""), + pinned: Boolean(frontmatter.pinned ?? false), + pin_order: + frontmatter.pin_order != null ? Number(frontmatter.pin_order) : undefined, }; } @@ -17,8 +23,11 @@ export function organizationToFrontmatter(org: OrganizationStorage): { } { return { frontmatter: { - name: org.name ?? "", user_id: org.user_id ?? "", + created_at: org.created_at ?? "", + name: org.name ?? "", + pinned: org.pinned ?? false, + pin_order: org.pin_order ?? 0, }, body: "", }; diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index 946cf84928..33d9a9f1dc 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -112,6 +112,7 @@ export const StoreComponent = () => { }, ) .setQueryDefinition(QUERIES.visibleHumans, "humans", ({ select }) => { + select("created_at"); select("name"); select("email"); select("org_id"); @@ -124,7 +125,10 @@ export const StoreComponent = () => { QUERIES.visibleOrganizations, "organizations", ({ select }) => { + select("created_at"); select("name"); + select("pinned"); + select("pin_order"); }, ) .setQueryDefinition( @@ -403,6 +407,7 @@ interface _QueryResultRows { folder_id: string; }; visibleHumans: { + created_at: string; name: string; email: string; org_id: string; @@ -412,7 +417,10 @@ interface _QueryResultRows { pin_order: number; }; visibleOrganizations: { + created_at: string; name: string; + pinned: boolean; + pin_order: number; }; visibleTemplates: { title: string; diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index bd4849016e..339770d187 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -4,6 +4,7 @@ import type { ChangelogState, ChatShortcutsState, ChatState, + ContactsSelection, ContactsState, EditorView, ExtensionsState, @@ -20,6 +21,7 @@ export type { ChangelogState, ChatShortcutsState, ChatState, + ContactsSelection, ContactsState, EditorView, ExtensionsState, @@ -130,8 +132,7 @@ export const getDefaultState = (tab: TabInput): Tab => { ...base, type: "contacts", state: tab.state ?? { - selectedOrganization: null, - selectedPerson: null, + selected: null, }, }; case "templates": diff --git a/apps/desktop/src/store/zustand/tabs/state.test.ts b/apps/desktop/src/store/zustand/tabs/state.test.ts index 0e842a3c51..a559aa18e3 100644 --- a/apps/desktop/src/store/zustand/tabs/state.test.ts +++ b/apps/desktop/src/store/zustand/tabs/state.test.ts @@ -84,9 +84,8 @@ describe("State Updater Actions", () => { describe("updateContactsTabState", () => { const newContactsState = { - selectedOrganization: "org-1", - selectedPerson: "person-1", - } as const; + selected: { type: "person" as const, id: "person-1" }, + }; test("updates contacts tab and current tab state", () => { const contacts = createContactsTab({ active: true }); diff --git a/apps/desktop/src/store/zustand/tabs/test-utils.ts b/apps/desktop/src/store/zustand/tabs/test-utils.ts index 02800b6593..ad8f869c0d 100644 --- a/apps/desktop/src/store/zustand/tabs/test-utils.ts +++ b/apps/desktop/src/store/zustand/tabs/test-utils.ts @@ -36,8 +36,7 @@ export const createContactsTab = ( pinned: overrides.pinned ?? false, slotId: id(), state: { - selectedOrganization: null, - selectedPerson: null, + selected: null, ...overrides.state, }, }); diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index f5c4d6c84f..a62eb5997e 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -41,6 +41,7 @@ export const tableSchemaForTinybase = { } as const satisfies InferTinyBaseSchema, humans: { user_id: { type: "string" }, + created_at: { type: "string" }, name: { type: "string" }, email: { type: "string" }, org_id: { type: "string" }, @@ -52,7 +53,10 @@ export const tableSchemaForTinybase = { } as const satisfies InferTinyBaseSchema, organizations: { user_id: { type: "string" }, + created_at: { type: "string" }, name: { type: "string" }, + pinned: { type: "boolean" }, + pin_order: { type: "number" }, } as const satisfies InferTinyBaseSchema, calendars: { user_id: { type: "string" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index ce2389cff6..8511dd7952 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -4,6 +4,7 @@ import { jsonObject, type ToStorageType } from "./shared"; export const humanSchema = z.object({ user_id: z.string(), + created_at: z.preprocess((val) => val ?? undefined, z.string().optional()), name: z.string(), email: z.string(), org_id: z.string(), @@ -89,7 +90,10 @@ export const calendarSchema = z.object({ export const organizationSchema = z.object({ user_id: z.string(), + created_at: z.preprocess((val) => val ?? undefined, z.string().optional()), name: z.string(), + pinned: z.preprocess((val) => val ?? false, z.boolean()), + pin_order: z.preprocess((val) => val ?? undefined, z.number().optional()), }); export const sessionSchema = z.object({ diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index 18a08372da..dfa3de0724 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -75,7 +75,8 @@ export type AppWindow = { type: "main" } | { type: "control" } export type ChangelogState = { previous: string | null; current: string } export type ChatShortcutsState = { isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } export type ChatState = { groupId: string | null; initialMessage: string | null } -export type ContactsState = { selectedOrganization: string | null; selectedPerson: string | null } +export type ContactsSelection = { type: "person"; id: string } | { type: "organization"; id: string } +export type ContactsState = { selected: ContactsSelection | null } export type EditorView = { type: "raw" } | { type: "transcript" } | { type: "enhanced"; id: string } | { type: "attachments" } export type ExtensionsState = { selectedExtension: string | null } export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> diff --git a/plugins/windows/src/tab/state.rs b/plugins/windows/src/tab/state.rs index bfb80b4acc..0c89bd0a5c 100644 --- a/plugins/windows/src/tab/state.rs +++ b/plugins/windows/src/tab/state.rs @@ -19,10 +19,19 @@ crate::common_derives! { } } +crate::common_derives! { + #[serde(tag = "type")] + pub enum ContactsSelection { + #[serde(rename = "person")] + Person { id: String }, + #[serde(rename = "organization")] + Organization { id: String }, + } +} + crate::common_derives! { pub struct ContactsState { - pub selected_organization: Option, - pub selected_person: Option, + pub selected: Option, } } From 7a73c6c98b229f1a64b0b130988fe6ccac00204f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:25:23 +0000 Subject: [PATCH 02/12] fix: address review comments - remove dead code, fix pin_order clearing, fix details maxOrder Co-Authored-By: john@hyprnote.com --- .../main/body/contacts/contacts-list.tsx | 4 +- .../components/main/body/contacts/details.tsx | 11 +- .../main/body/contacts/organizations.tsx | 351 ----------------- .../components/main/body/contacts/people.tsx | 372 ------------------ 4 files changed, 10 insertions(+), 728 deletions(-) delete mode 100644 apps/desktop/src/components/main/body/contacts/organizations.tsx delete mode 100644 apps/desktop/src/components/main/body/contacts/people.tsx diff --git a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx index 41e55b1fe8..bab370e4a1 100644 --- a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -341,7 +341,7 @@ function PersonItem({ if (currentPinned) { store.setPartialRow("humans", humanId, { pinned: false, - pin_order: undefined, + pin_order: 0, }); } else { const allHumans = store.getTable("humans"); @@ -438,7 +438,7 @@ function OrganizationItem({ if (currentPinned) { store.setPartialRow("organizations", organizationId, { pinned: false, - pin_order: undefined, + pin_order: 0, }); } else { const allOrgs = store.getTable("organizations"); diff --git a/apps/desktop/src/components/main/body/contacts/details.tsx b/apps/desktop/src/components/main/body/contacts/details.tsx index 18c5f34d0f..23af31d2b8 100644 --- a/apps/desktop/src/components/main/body/contacts/details.tsx +++ b/apps/desktop/src/components/main/body/contacts/details.tsx @@ -117,17 +117,22 @@ export function DetailsColumn({ if (currentPinned) { store.setPartialRow("humans", selectedHumanId, { pinned: false, - pin_order: undefined, + pin_order: 0, }); } else { const allHumans = store.getTable("humans"); - const maxOrder = Object.values(allHumans).reduce((max, h) => { + const allOrgs = store.getTable("organizations"); + const maxHumanOrder = Object.values(allHumans).reduce((max, h) => { const order = (h.pin_order as number | undefined) ?? 0; return Math.max(max, order); }, 0); + const maxOrgOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); store.setPartialRow("humans", selectedHumanId, { pinned: true, - pin_order: maxOrder + 1, + pin_order: Math.max(maxHumanOrder, maxOrgOrder) + 1, }); } }, [store, selectedHumanId]); diff --git a/apps/desktop/src/components/main/body/contacts/organizations.tsx b/apps/desktop/src/components/main/body/contacts/organizations.tsx deleted file mode 100644 index 266b6ab04f..0000000000 --- a/apps/desktop/src/components/main/body/contacts/organizations.tsx +++ /dev/null @@ -1,351 +0,0 @@ -import { Building2, CornerDownLeft, Pin, User } from "lucide-react"; -import { Reorder } from "motion/react"; -import React, { useCallback, useMemo, useState } from "react"; - -import { cn } from "@hypr/utils"; - -import * as main from "../../../../store/tinybase/store/main"; -import { ColumnHeader, type SortOption } from "./shared"; - -export function OrganizationsColumn({ - selectedOrganization, - setSelectedOrganization, - isViewingOrgDetails, -}: { - selectedOrganization: string | null; - setSelectedOrganization: (id: string | null) => void; - isViewingOrgDetails: boolean; -}) { - const [showNewOrg, setShowNewOrg] = useState(false); - const [searchValue, setSearchValue] = useState(""); - const { pinnedIds, unpinnedIds, sortOption, setSortOption } = - useSortedOrganizationIds(); - - const allOrgs = main.UI.useTable("organizations", main.STORE_ID); - const store = main.UI.useStore(main.STORE_ID); - - const filteredPinnedIds = useMemo(() => { - if (!searchValue.trim()) return pinnedIds; - const q = searchValue.toLowerCase(); - return pinnedIds.filter((id) => { - const nameLower = (allOrgs[id]?.name ?? "").toLowerCase(); - return nameLower.includes(q); - }); - }, [pinnedIds, searchValue, allOrgs]); - - const filteredUnpinnedIds = useMemo(() => { - if (!searchValue.trim()) return unpinnedIds; - const q = searchValue.toLowerCase(); - return unpinnedIds.filter((id) => { - const nameLower = (allOrgs[id]?.name ?? "").toLowerCase(); - return nameLower.includes(q); - }); - }, [unpinnedIds, searchValue, allOrgs]); - - const handleReorderPinned = useCallback( - (newOrder: string[]) => { - if (!store) return; - store.transaction(() => { - newOrder.forEach((id, index) => { - store.setCell("organizations", id, "pin_order", index); - }); - }); - }, - [store], - ); - - return ( -
- setShowNewOrg(true)} - searchValue={searchValue} - onSearchChange={setSearchValue} - /> -
-
- - {showNewOrg && ( - setShowNewOrg(false)} - onCancel={() => setShowNewOrg(false)} - /> - )} - {filteredPinnedIds.length > 0 && ( - - {filteredPinnedIds.map((orgId) => ( - - - - ))} - - )} - {filteredPinnedIds.length > 0 && filteredUnpinnedIds.length > 0 && ( -
- )} - {filteredUnpinnedIds.map((orgId) => ( - - ))} -
-
-
- ); -} - -function useSortedOrganizationIds() { - const [sortOption, setSortOption] = useState("alphabetical"); - - const alphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "name", - false, - 0, - undefined, - main.STORE_ID, - ); - const reverseAlphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "name", - true, - 0, - undefined, - main.STORE_ID, - ); - const newestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "created_at", - true, - 0, - undefined, - main.STORE_ID, - ); - const oldestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "created_at", - false, - 0, - undefined, - main.STORE_ID, - ); - - const sortedIds = - sortOption === "alphabetical" - ? alphabeticalIds - : sortOption === "reverse-alphabetical" - ? reverseAlphabeticalIds - : sortOption === "newest" - ? newestIds - : oldestIds; - - const allOrgs = main.UI.useTable("organizations", main.STORE_ID); - - const { pinnedIds, unpinnedIds } = useMemo(() => { - const pinned = sortedIds.filter((id) => allOrgs[id]?.pinned); - const unpinned = sortedIds.filter((id) => !allOrgs[id]?.pinned); - - const sortedPinned = [...pinned].sort((a, b) => { - const orderA = (allOrgs[a]?.pin_order as number | undefined) ?? Infinity; - const orderB = (allOrgs[b]?.pin_order as number | undefined) ?? Infinity; - return orderA - orderB; - }); - - return { pinnedIds: sortedPinned, unpinnedIds: unpinned }; - }, [sortedIds, allOrgs]); - - return { pinnedIds, unpinnedIds, sortOption, setSortOption }; -} - -function OrganizationItem({ - organizationId, - isSelected, - isViewingDetails, - setSelectedOrganization, -}: { - organizationId: string; - isSelected: boolean; - isViewingDetails: boolean; - setSelectedOrganization: (id: string | null) => void; -}) { - const organization = main.UI.useRow( - "organizations", - organizationId, - main.STORE_ID, - ); - const isPinned = Boolean(organization.pinned); - const store = main.UI.useStore(main.STORE_ID); - - const handleTogglePin = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (!store) return; - - const currentPinned = store.getCell( - "organizations", - organizationId, - "pinned", - ); - if (currentPinned) { - store.setPartialRow("organizations", organizationId, { - pinned: false, - pin_order: undefined, - }); - } else { - const allOrgs = store.getTable("organizations"); - const maxOrder = Object.values(allOrgs).reduce((max, o) => { - const order = (o.pin_order as number | undefined) ?? 0; - return Math.max(max, order); - }, 0); - store.setPartialRow("organizations", organizationId, { - pinned: true, - pin_order: maxOrder + 1, - }); - } - }, - [store, organizationId], - ); - - if (!organization) { - return null; - } - - return ( -
-
setSelectedOrganization(organizationId)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setSelectedOrganization(organizationId); - } - }} - className="w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-neutral-100 transition-colors rounded-md" - > - -

{organization.name}

- -
-
- ); -} - -function NewOrganizationForm({ - onSave, - onCancel, -}: { - onSave: () => void; - onCancel: () => void; -}) { - const [name, setName] = useState(""); - const userId = main.UI.useValue("user_id", main.STORE_ID); - - const handleAdd = main.UI.useAddRowCallback( - "organizations", - () => ({ - user_id: userId || "", - name: name.trim(), - created_at: new Date().toISOString(), - }), - [name, userId], - main.STORE_ID, - () => { - setName(""); - onSave(); - }, - ); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - } - if (e.key === "Escape") { - onCancel(); - } - }; - - return ( -
-
-
- setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Add organization" - className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" - autoFocus - /> - {name.trim() && ( - - )} -
-
-
- ); -} diff --git a/apps/desktop/src/components/main/body/contacts/people.tsx b/apps/desktop/src/components/main/body/contacts/people.tsx deleted file mode 100644 index fbf5e33044..0000000000 --- a/apps/desktop/src/components/main/body/contacts/people.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { Facehash } from "facehash"; -import { CornerDownLeft, Pin } from "lucide-react"; -import { Reorder } from "motion/react"; -import React, { useCallback, useMemo, useState } from "react"; - -import { cn } from "@hypr/utils"; - -import * as main from "../../../../store/tinybase/store/main"; -import { ColumnHeader, type SortOption } from "./shared"; - -export function PeopleColumn({ - currentOrgId, - currentHumanId, - setSelectedPerson, -}: { - currentOrgId?: string | null; - currentHumanId?: string | null; - setSelectedPerson: (id: string | null) => void; -}) { - const [showNewPerson, setShowNewPerson] = useState(false); - const [searchValue, setSearchValue] = useState(""); - const { humanIds, pinnedIds, unpinnedIds, sortOption, setSortOption } = - useSortedHumanIds(currentOrgId); - - const allHumans = main.UI.useTable("humans", main.STORE_ID); - const store = main.UI.useStore(main.STORE_ID); - - const filteredHumanIds = useMemo(() => { - if (!searchValue.trim()) { - return humanIds; - } - - return humanIds.filter((id) => { - const human = allHumans[id]; - const q = searchValue.toLowerCase(); - const name = (human?.name ?? "").toLowerCase(); - const email = (human?.email ?? "").toLowerCase(); - return name.includes(q) || email.includes(q); - }); - }, [humanIds, searchValue, allHumans]); - - const filteredPinnedIds = useMemo(() => { - if (!searchValue.trim()) { - return pinnedIds; - } - return pinnedIds.filter((id) => filteredHumanIds.includes(id)); - }, [pinnedIds, filteredHumanIds, searchValue]); - - const filteredUnpinnedIds = useMemo(() => { - if (!searchValue.trim()) { - return unpinnedIds; - } - return unpinnedIds.filter((id) => filteredHumanIds.includes(id)); - }, [unpinnedIds, filteredHumanIds, searchValue]); - - const handleReorderPinned = useCallback( - (newOrder: string[]) => { - if (!store) return; - store.transaction(() => { - newOrder.forEach((id, index) => { - store.setCell("humans", id, "pin_order", index); - }); - }); - }, - [store], - ); - - return ( -
- setShowNewPerson(true)} - searchValue={searchValue} - onSearchChange={setSearchValue} - /> -
-
- {showNewPerson && ( - { - setShowNewPerson(false); - setSelectedPerson(humanId); - }} - onCancel={() => setShowNewPerson(false)} - /> - )} - {filteredPinnedIds.length > 0 && ( - - {filteredPinnedIds.map((humanId) => ( - - - - ))} - - )} - {filteredPinnedIds.length > 0 && filteredUnpinnedIds.length > 0 && ( -
- )} - {filteredUnpinnedIds.map((humanId) => ( - - ))} -
-
-
- ); -} - -export function useSortedHumanIds(currentOrgId?: string | null) { - const [sortOption, setSortOption] = useState("alphabetical"); - - const allAlphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "name", - false, - 0, - undefined, - main.STORE_ID, - ); - const allReverseAlphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "name", - true, - 0, - undefined, - main.STORE_ID, - ); - const allNewestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "created_at", - true, - 0, - undefined, - main.STORE_ID, - ); - const allOldestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "created_at", - false, - 0, - undefined, - main.STORE_ID, - ); - - const thisOrgHumanIds = main.UI.useSliceRowIds( - main.INDEXES.humansByOrg, - currentOrgId ?? "", - main.STORE_ID, - ); - - const allHumans = main.UI.useTable("humans", main.STORE_ID); - - const sortedIds = currentOrgId - ? (sortOption === "alphabetical" - ? allAlphabeticalIds - : sortOption === "reverse-alphabetical" - ? allReverseAlphabeticalIds - : sortOption === "newest" - ? allNewestIds - : allOldestIds - ).filter((id) => thisOrgHumanIds.includes(id)) - : sortOption === "alphabetical" - ? allAlphabeticalIds - : sortOption === "reverse-alphabetical" - ? allReverseAlphabeticalIds - : sortOption === "newest" - ? allNewestIds - : allOldestIds; - - const { humanIds, pinnedIds, unpinnedIds } = useMemo(() => { - const pinned = sortedIds.filter((id) => allHumans[id]?.pinned); - const unpinned = sortedIds.filter((id) => !allHumans[id]?.pinned); - - const sortedPinned = [...pinned].sort((a, b) => { - const orderA = - (allHumans[a]?.pin_order as number | undefined) ?? Infinity; - const orderB = - (allHumans[b]?.pin_order as number | undefined) ?? Infinity; - return orderA - orderB; - }); - - return { - humanIds: [...sortedPinned, ...unpinned], - pinnedIds: sortedPinned, - unpinnedIds: unpinned, - }; - }, [sortedIds, allHumans]); - - return { humanIds, pinnedIds, unpinnedIds, sortOption, setSortOption }; -} - -function PersonItem({ - humanId, - active, - setSelectedPerson, -}: { - humanId: string; - active: boolean; - setSelectedPerson: (id: string | null) => void; -}) { - const person = main.UI.useRow("humans", humanId, main.STORE_ID); - const isPinned = Boolean(person.pinned); - const personName = String(person.name ?? ""); - const personEmail = String(person.email ?? ""); - - const store = main.UI.useStore(main.STORE_ID); - - const handleTogglePin = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (!store) return; - - const currentPinned = store.getCell("humans", humanId, "pinned"); - if (currentPinned) { - store.setPartialRow("humans", humanId, { - pinned: false, - pin_order: undefined, - }); - } else { - const allHumans = store.getTable("humans"); - const maxOrder = Object.values(allHumans).reduce((max, h) => { - const order = (h.pin_order as number | undefined) ?? 0; - return Math.max(max, order); - }, 0); - store.setPartialRow("humans", humanId, { - pinned: true, - pin_order: maxOrder + 1, - }); - } - }, - [store, humanId], - ); - - return ( - - - ); -} - -function NewPersonForm({ - currentOrgId, - onSave, - onCancel, -}: { - currentOrgId?: string | null; - onSave: (humanId: string) => void; - onCancel: () => void; -}) { - const [name, setName] = useState(""); - const userId = main.UI.useValue("user_id", main.STORE_ID); - - const createHuman = main.UI.useSetRowCallback( - "humans", - (p: { name: string; humanId: string }) => p.humanId, - (p: { name: string; humanId: string }) => ({ - user_id: userId || "", - created_at: new Date().toISOString(), - name: p.name, - email: "", - org_id: currentOrgId || "", - job_title: "", - linkedin_username: "", - memo: "", - pinned: false, - }), - [userId, currentOrgId], - main.STORE_ID, - ); - - const handleAdd = () => { - const humanId = crypto.randomUUID(); - createHuman({ humanId, name: name.trim() }); - setName(""); - onSave(humanId); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - } - if (e.key === "Escape") { - onCancel(); - } - }; - - return ( -
-
-
- setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Add person" - className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" - autoFocus - /> - {name.trim() && ( - - )} -
-
-
- ); -} From 87a3b1e73edeabcbabbebd4bfb95362670ac30af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:33:45 +0000 Subject: [PATCH 03/12] fix: disable drag-reorder during search, remove dead NewOrgForm code Co-Authored-By: john@hyprnote.com --- .../main/body/contacts/contacts-list.tsx | 106 +++++------------- 1 file changed, 26 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx index bab370e4a1..fb1a86cdf7 100644 --- a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -21,7 +21,6 @@ export function ContactsListColumn({ setSelected: (value: ContactsSelection | null) => void; }) { const [showNewPerson, setShowNewPerson] = useState(false); - const [showNewOrg, setShowNewOrg] = useState(false); const [searchValue, setSearchValue] = useState(""); const [sortOption, setSortOption] = useState("alphabetical"); const [showSearch, setShowSearch] = useState(false); @@ -252,13 +251,7 @@ export function ContactsListColumn({ onCancel={() => setShowNewPerson(false)} /> )} - {showNewOrg && ( - setShowNewOrg(false)} - onCancel={() => setShowNewOrg(false)} - /> - )} - {pinnedItems.length > 0 && ( + {pinnedItems.length > 0 && !searchValue.trim() && ( i.id)} @@ -288,6 +281,31 @@ export function ContactsListColumn({ ))} )} + {pinnedItems.length > 0 && searchValue.trim() && ( +
+ {pinnedItems.map((item) => + item.kind === "person" ? ( + + setSelected({ type: "person", id: item.id }) + } + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + /> + ), + )} +
+ )} {pinnedItems.length > 0 && nonPinnedItems.length > 0 && (
)} @@ -583,75 +601,3 @@ function NewPersonForm({
); } - -function NewOrgForm({ - onSave, - onCancel, -}: { - onSave: () => void; - onCancel: () => void; -}) { - const [name, setName] = useState(""); - const userId = main.UI.useValue("user_id", main.STORE_ID); - - const handleAdd = main.UI.useAddRowCallback( - "organizations", - () => ({ - user_id: userId || "", - name: name.trim(), - created_at: new Date().toISOString(), - }), - [name, userId], - main.STORE_ID, - () => { - setName(""); - onSave(); - }, - ); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - } - if (e.key === "Escape") { - onCancel(); - } - }; - - return ( -
-
-
- setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Add organization" - className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" - autoFocus - /> - {name.trim() && ( - - )} -
-
-
- ); -} From fe240da5dd03dfdcdf873e566d214d1d304b2da3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:40:49 +0000 Subject: [PATCH 04/12] fix: apply dprint formatting Co-Authored-By: john@hyprnote.com --- .../src/components/main/body/contacts/contacts-list.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx index fb1a86cdf7..fc334b17d0 100644 --- a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -289,9 +289,7 @@ export function ContactsListColumn({ key={`pinned-person-${item.id}`} active={isActive(item)} humanId={item.id} - onClick={() => - setSelected({ type: "person", id: item.id }) - } + onClick={() => setSelected({ type: "person", id: item.id })} /> ) : ( Date: Wed, 18 Feb 2026 16:48:09 +0900 Subject: [PATCH 05/12] refactor: redesign contact details layout Restructure organization and person detail views to improve visual hierarchy and usability. Center profile avatars at the top, organize information into structured rows with consistent labels, and improve spacing throughout the interface. Key changes: - Center avatars in header section with increased size - Replace horizontal layout with vertical field rows - Add consistent label-value pairs for better readability - Improve responsive layout with proper overflow handling - Enhance visual separation between sections --- .../components/main/body/contacts/details.tsx | 77 +++++++++---------- .../body/contacts/organization-details.tsx | 37 +++++---- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/details.tsx b/apps/desktop/src/components/main/body/contacts/details.tsx index 23af31d2b8..6b5e3201ff 100644 --- a/apps/desktop/src/components/main/body/contacts/details.tsx +++ b/apps/desktop/src/components/main/body/contacts/details.tsx @@ -4,6 +4,7 @@ import { CircleMinus, FileText, Pin, + Plus, SearchIcon, } from "lucide-react"; import React, { useCallback, useState } from "react"; @@ -214,41 +215,18 @@ export function DetailsColumn({
{selectedPersonData && selectedHumanId ? ( <> -
-
-
- -
-
-
-
- - -
-
-
+
+
+
@@ -305,6 +283,24 @@ export function DetailsColumn({ )}
+
+
Name
+
+ + +
+
@@ -549,7 +545,7 @@ function EditablePersonMemoField({ personId }: { personId: string }) { value={(value as string) || ""} onChange={handleChange} placeholder="Add notes about this contact..." - className="border-none shadow-none p-2 min-h-[80px] text-base focus-visible:ring-0 focus-visible:ring-offset-0 resize-none" + className="border-none shadow-none px-0 py-2 min-h-[80px] text-base focus-visible:ring-0 focus-visible:ring-offset-0 resize-none" rows={3} />
@@ -584,8 +580,8 @@ function EditPersonOrganizationSelector({ personId }: { personId: string }) { return ( -
- {organization ? ( +
+ {organization?.name ? (
{organization.name} @@ -599,8 +595,9 @@ function EditPersonOrganizationSelector({ personId }: { personId: string }) {
) : ( - - Select organization + + + Add organization )}
diff --git a/apps/desktop/src/components/main/body/contacts/organization-details.tsx b/apps/desktop/src/components/main/body/contacts/organization-details.tsx index 352f763cef..86b888fef8 100644 --- a/apps/desktop/src/components/main/body/contacts/organization-details.tsx +++ b/apps/desktop/src/components/main/body/contacts/organization-details.tsx @@ -35,28 +35,31 @@ export function OrganizationDetailsColumn({
{selectedOrgData && selectedOrganizationId ? ( <> -
-
-
- +
+
+ +
+
+ +
+
+
+
Name
+
+ +
-
-
-
- -

- {peopleInOrg?.length ?? 0}{" "} - {(peopleInOrg?.length ?? 0) === 1 ? "person" : "people"} -

-
+
+
People
+
+ {peopleInOrg?.length ?? 0}{" "} + {(peopleInOrg?.length ?? 0) === 1 ? "person" : "people"}
-
-

People From 42c5818dbf6564af389e22586801ed6b27a817bd Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Wed, 18 Feb 2026 16:48:18 +0900 Subject: [PATCH 06/12] feat: auto-select organizations when no people exist Add organization fallback selection in contacts view. When no person is selected and no people are available, automatically select the first organization instead. This improves user experience by ensuring a contact is always selected when available. --- .../components/main/body/contacts/index.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/index.tsx b/apps/desktop/src/components/main/body/contacts/index.tsx index 763bf4efa1..7568e1e713 100644 --- a/apps/desktop/src/components/main/body/contacts/index.tsx +++ b/apps/desktop/src/components/main/body/contacts/index.tsx @@ -122,11 +122,24 @@ function ContactView({ tab }: { tab: Extract }) { main.STORE_ID, ); + const allOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); + useEffect(() => { - if (!selected && allHumanIds.length > 0) { - setSelected({ type: "person", id: allHumanIds[0] }); + if (!selected) { + if (allHumanIds.length > 0) { + setSelected({ type: "person", id: allHumanIds[0] }); + } else if (allOrgIds.length > 0) { + setSelected({ type: "organization", id: allOrgIds[0] }); + } } - }, [allHumanIds, selected, setSelected]); + }, [allHumanIds, allOrgIds, selected, setSelected]); return ( From db756592491c903f9dee6a4e55063860b575debf Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Wed, 18 Feb 2026 16:53:31 +0900 Subject: [PATCH 07/12] feat: Replace company field with name in contact details Replace EditPersonOrganizationSelector with EditablePersonNameField in contact details view and update field label from "Company" to "Name". Standardize input styling by reducing height from h-8 to h-7 and font size from text-lg to text-base for consistent appearance across person and organization name fields. --- apps/desktop/src/components/main/body/contacts/details.tsx | 7 ++++--- .../components/main/body/contacts/organization-details.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/details.tsx b/apps/desktop/src/components/main/body/contacts/details.tsx index 6b5e3201ff..a34e6207f7 100644 --- a/apps/desktop/src/components/main/body/contacts/details.tsx +++ b/apps/desktop/src/components/main/body/contacts/details.tsx @@ -304,11 +304,12 @@ export function DetailsColumn({
-
Company
+
Name
- +
+

@@ -433,7 +434,7 @@ function EditablePersonNameField({ personId }: { personId: string }) { value={(value as string) || ""} onChange={handleChange} placeholder="Name" - className="border-none shadow-none p-0 h-8 text-lg font-semibold focus-visible:ring-0 focus-visible:ring-offset-0" + className="border-none shadow-none p-0 h-7 text-base focus-visible:ring-0 focus-visible:ring-offset-0" /> ); } diff --git a/apps/desktop/src/components/main/body/contacts/organization-details.tsx b/apps/desktop/src/components/main/body/contacts/organization-details.tsx index 86b888fef8..bc80b5d00f 100644 --- a/apps/desktop/src/components/main/body/contacts/organization-details.tsx +++ b/apps/desktop/src/components/main/body/contacts/organization-details.tsx @@ -223,7 +223,7 @@ function EditableOrganizationNameField({ value={(value as string) || ""} onChange={handleChange} placeholder="Organization name" - className="border-none shadow-none p-0 h-8 text-lg font-semibold focus-visible:ring-0 focus-visible:ring-offset-0" + className="border-none shadow-none p-0 h-7 text-base focus-visible:ring-0 focus-visible:ring-offset-0" /> ); } From 0109f67d24c517d688819d9ab215e57c81f3a9b4 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Wed, 18 Feb 2026 17:13:25 +0900 Subject: [PATCH 08/12] feat: Remove pin functionality from contact details Remove contact pinning feature from the details view. Delete pin button, toggle handler, and related state management. Fix duplicate name field by replacing with company selector field. Simplify name field layout by removing pin button wrapper. --- .../components/main/body/contacts/details.tsx | 49 ++----------------- 1 file changed, 3 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/details.tsx b/apps/desktop/src/components/main/body/contacts/details.tsx index a34e6207f7..a4cd184855 100644 --- a/apps/desktop/src/components/main/body/contacts/details.tsx +++ b/apps/desktop/src/components/main/body/contacts/details.tsx @@ -3,7 +3,6 @@ import { Building2, CircleMinus, FileText, - Pin, Plus, SearchIcon, } from "lucide-react"; @@ -34,8 +33,6 @@ export function DetailsColumn({ selectedHumanId ?? "", main.STORE_ID, ); - const isPinned = selectedPersonData.pinned as boolean | undefined; - const mappingIdsByHuman = main.UI.useSliceRowIds( main.INDEXES.sessionsByHuman, selectedHumanId ?? "", @@ -111,33 +108,6 @@ export function DetailsColumn({ const store = main.UI.useStore(main.STORE_ID); - const handleTogglePin = useCallback(() => { - if (!store || !selectedHumanId) return; - - const currentPinned = store.getCell("humans", selectedHumanId, "pinned"); - if (currentPinned) { - store.setPartialRow("humans", selectedHumanId, { - pinned: false, - pin_order: 0, - }); - } else { - const allHumans = store.getTable("humans"); - const allOrgs = store.getTable("organizations"); - const maxHumanOrder = Object.values(allHumans).reduce((max, h) => { - const order = (h.pin_order as number | undefined) ?? 0; - return Math.max(max, order); - }, 0); - const maxOrgOrder = Object.values(allOrgs).reduce((max, o) => { - const order = (o.pin_order as number | undefined) ?? 0; - return Math.max(max, order); - }, 0); - store.setPartialRow("humans", selectedHumanId, { - pinned: true, - pin_order: Math.max(maxHumanOrder, maxOrgOrder) + 1, - }); - } - }, [store, selectedHumanId]); - const handleMergeContacts = useCallback( (duplicateId: string) => { if (!store || !selectedHumanId) return; @@ -285,31 +255,18 @@ export function DetailsColumn({
Name
-
+
-
-
Name
+
Company
- +
-
From 6b073439fc58e155173a8a7cfd4d082d67ce42f7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:49:57 +0000 Subject: [PATCH 09/12] revert: remove non-contacts changes (calendar-view, empty tips) Co-Authored-By: john@hyprnote.com --- .../main/body/calendar/calendar-view.tsx | 531 +----------------- .../src/components/main/body/empty/index.tsx | 59 +- 2 files changed, 6 insertions(+), 584 deletions(-) diff --git a/apps/desktop/src/components/main/body/calendar/calendar-view.tsx b/apps/desktop/src/components/main/body/calendar/calendar-view.tsx index 02c3ac47d7..f76d73401b 100644 --- a/apps/desktop/src/components/main/body/calendar/calendar-view.tsx +++ b/apps/desktop/src/components/main/body/calendar/calendar-view.tsx @@ -1,4 +1,3 @@ -import { platform } from "@tauri-apps/plugin-os"; import { addDays, addMonths, @@ -18,33 +17,13 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@hypr/ui/components/ui/accordion"; import { Button } from "@hypr/ui/components/ui/button"; import { ButtonGroup } from "@hypr/ui/components/ui/button-group"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@hypr/ui/components/ui/popover"; -import { safeParseDate } from "@hypr/utils"; -import { cn, TZDate } from "@hypr/utils"; +import { cn } from "@hypr/utils"; -import { useConfigValue } from "../../../../config/use-config"; -import { useEvent, useIgnoredEvents } from "../../../../hooks/tinybase"; -import { usePermission } from "../../../../hooks/usePermissions"; -import * as main from "../../../../store/tinybase/store/main"; -import { getOrCreateSessionForEventId } from "../../../../store/tinybase/store/sessions"; -import { useTabs } from "../../../../store/zustand/tabs"; -import { AppleCalendarSelection } from "../../../settings/calendar/configure/apple/calendar-selection"; -import { SyncProvider } from "../../../settings/calendar/configure/apple/context"; -import { AccessPermissionRow } from "../../../settings/calendar/configure/apple/permission"; -import { PROVIDERS } from "../../../settings/calendar/shared"; -import { EventDisplay } from "../sessions/outer-header/metadata"; +import { DayCell } from "./day-cell"; +import { useCalendarData, useNow, useWeekStartsOn } from "./hooks"; +import { CalendarSidebarContent } from "./sidebar"; const WEEKDAY_HEADERS_SUN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const WEEKDAY_HEADERS_MON = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; @@ -77,104 +56,6 @@ function useVisibleCols(ref: React.RefObject) { return cols; } -function useTimezone() { - return useConfigValue("timezone") || undefined; -} - -function toTz(date: Date | string, tz?: string): Date { - const d = typeof date === "string" ? new Date(date) : date; - return tz ? new TZDate(d, tz) : d; -} - -function useNow() { - const tz = useTimezone(); - const [now, setNow] = useState(() => toTz(new Date(), tz)); - - useEffect(() => { - const interval = setInterval(() => { - setNow(toTz(new Date(), tz)); - }, 60000); - return () => clearInterval(interval); - }, [tz]); - - return now; -} - -function getSystemWeekStart(): 0 | 1 { - const locale = navigator.language || "en-US"; - try { - const options = new Intl.Locale(locale); - const info = (options as any).getWeekInfo?.() ?? (options as any).weekInfo; - if (info?.firstDay === 1) return 1; - } catch {} - return 0; -} - -function useWeekStartsOn(): 0 | 1 { - const value = useConfigValue("week_start"); - return useMemo(() => { - if (value === "monday") return 1; - if (value === "sunday") return 0; - return getSystemWeekStart(); - }, [value]); -} - -type CalendarData = { - eventIdsByDate: Record; - sessionIdsByDate: Record; -}; - -function useCalendarData(): CalendarData { - const tz = useTimezone(); - - const eventsTable = main.UI.useResultTable( - main.QUERIES.timelineEvents, - main.STORE_ID, - ); - const sessionsTable = main.UI.useResultTable( - main.QUERIES.timelineSessions, - main.STORE_ID, - ); - const { isIgnored } = useIgnoredEvents(); - - return useMemo(() => { - const eventIdsByDate: Record = {}; - const sessionIdsByDate: Record = {}; - - if (eventsTable) { - for (const [eventId, row] of Object.entries(eventsTable)) { - if (!row.title) continue; - const raw = safeParseDate(row.started_at); - if (!raw) continue; - const day = format(toTz(raw, tz), "yyyy-MM-dd"); - if (isIgnored(row.tracking_id_event, row.recurrence_series_id, day)) - continue; - (eventIdsByDate[day] ??= []).push(eventId); - } - - for (const ids of Object.values(eventIdsByDate)) { - ids.sort((a, b) => { - const aAllDay = eventsTable[a]?.is_all_day ? 0 : 1; - const bAllDay = eventsTable[b]?.is_all_day ? 0 : 1; - return aAllDay - bAllDay; - }); - } - } - - if (sessionsTable) { - for (const [sessionId, row] of Object.entries(sessionsTable)) { - if (row.event_json || !row.title) continue; - const raw = safeParseDate(row.created_at); - if (!raw) continue; - const key = format(toTz(raw, tz), "yyyy-MM-dd"); - (sessionIdsByDate[key] ??= []).push(sessionId); - } - } - - return { eventIdsByDate, sessionIdsByDate }; - }, [eventsTable, sessionsTable, tz, isIgnored]); -} - export function CalendarView() { const now = useNow(); const weekStartsOn = useWeekStartsOn(); @@ -352,407 +233,3 @@ export function CalendarView() {
); } - -function useVisibleItemCount( - ref: React.RefObject, - totalItems: number, -) { - const [maxVisible, setMaxVisible] = useState(totalItems); - - useEffect(() => { - const el = ref.current; - if (!el || totalItems === 0) return; - - const compute = () => { - const available = el.clientHeight; - const children = Array.from(el.children) as HTMLElement[]; - if (children.length === 0 || available <= 0) return; - - const chipH = children[0].offsetHeight; - if (chipH === 0) return; - - const gap = parseFloat(getComputedStyle(el).rowGap) || 0; - - const allH = totalItems * chipH + Math.max(0, totalItems - 1) * gap; - if (allH <= available) { - setMaxVisible((prev) => (prev === totalItems ? prev : totalItems)); - return; - } - - const overflowH = chipH; - let count = 0; - let used = 0; - - while (count < totalItems) { - const next = chipH + (count > 0 ? gap : 0); - const remaining = totalItems - count - 1; - const moreSpace = remaining > 0 ? overflowH + gap : 0; - if (used + next + moreSpace > available) break; - used += next; - count++; - } - - const result = Math.max(1, count); - setMaxVisible((prev) => (prev === result ? prev : result)); - }; - - compute(); - const observer = new ResizeObserver(compute); - observer.observe(el); - return () => observer.disconnect(); - }, [ref, totalItems]); - - return maxVisible; -} - -function DayCell({ - day, - isCurrentMonth, - calendarData, -}: { - day: Date; - isCurrentMonth: boolean; - calendarData: CalendarData; -}) { - const dateKey = format(day, "yyyy-MM-dd"); - const eventIds = calendarData.eventIdsByDate[dateKey] ?? []; - const sessionIds = calendarData.sessionIdsByDate[dateKey] ?? []; - - const now = useNow(); - const itemsRef = useRef(null); - const totalItems = eventIds.length + sessionIds.length; - const maxVisible = useVisibleItemCount(itemsRef, totalItems); - const today = format(day, "yyyy-MM-dd") === format(now, "yyyy-MM-dd"); - - const visibleEvents = eventIds.slice(0, maxVisible); - const remainingSlots = Math.max(0, maxVisible - visibleEvents.length); - const visibleSessions = sessionIds.slice(0, remainingSlots); - const shownCount = visibleEvents.length + visibleSessions.length; - const overflow = totalItems - shownCount; - - return ( -
-
-
- {format(day, "d")} -
-
-
- {visibleEvents.map((eventId) => ( - - ))} - {visibleSessions.map((sessionId) => ( - - ))} - {overflow > 0 && ( - - - - - e.stopPropagation()} - > -
- {format(day, "MMM d, yyyy")} -
-
- {eventIds.map((eventId) => ( - - ))} - {sessionIds.map((sessionId) => ( - - ))} -
-
-
- )} -
-
- ); -} - -function useCalendarColor(calendarId: string | null): string | null { - const calendar = main.UI.useRow("calendars", calendarId ?? "", main.STORE_ID); - if (!calendarId) return null; - return calendar?.color ? String(calendar.color) : null; -} - -function EventChip({ eventId }: { eventId: string }) { - const tz = useTimezone(); - const event = main.UI.useResultRow( - main.QUERIES.timelineEvents, - eventId, - main.STORE_ID, - ); - const calendarColor = useCalendarColor( - (event?.calendar_id as string) ?? null, - ); - - if (!event || !event.title) { - return null; - } - - const isAllDay = !!event.is_all_day; - const color = calendarColor ?? "#888"; - - const startedAt = event.started_at - ? format(toTz(event.started_at as string, tz), "h:mm a") - : null; - - return ( - - - {isAllDay ? ( - - ) : ( - - )} - - e.stopPropagation()} - > - - - - ); -} - -function EventPopoverContent({ eventId }: { eventId: string }) { - const event = useEvent(eventId); - const store = main.UI.useStore(main.STORE_ID); - const openNew = useTabs((state) => state.openNew); - const tz = useTimezone(); - - const eventRow = main.UI.useResultRow( - main.QUERIES.timelineEvents, - eventId, - main.STORE_ID, - ); - - const handleOpen = useCallback(() => { - if (!store) return; - const title = (eventRow?.title as string) || "Untitled"; - const sessionId = getOrCreateSessionForEventId(store, eventId, title, tz); - openNew({ type: "sessions", id: sessionId }); - }, [store, eventId, eventRow?.title, openNew, tz]); - - if (!event) { - return null; - } - - return ( -
- - -
- ); -} - -function SessionChip({ sessionId }: { sessionId: string }) { - const tz = useTimezone(); - const session = main.UI.useResultRow( - main.QUERIES.timelineSessions, - sessionId, - main.STORE_ID, - ); - - if (!session || !session.title) { - return null; - } - - const createdAt = session.created_at - ? format(toTz(session.created_at as string, tz), "h:mm a") - : null; - - return ( - - - - - e.stopPropagation()} - > - - - - ); -} - -function SessionPopoverContent({ sessionId }: { sessionId: string }) { - const session = main.UI.useResultRow( - main.QUERIES.timelineSessions, - sessionId, - main.STORE_ID, - ); - const openNew = useTabs((state) => state.openNew); - const tz = useTimezone(); - - const handleOpen = useCallback(() => { - openNew({ type: "sessions", id: sessionId }); - }, [openNew, sessionId]); - - if (!session) { - return null; - } - - const createdAt = session.created_at - ? format(toTz(session.created_at as string, tz), "MMM d, yyyy h:mm a") - : null; - - return ( -
-
- {session.title as string} -
-
- {createdAt &&
{createdAt}
} - -
- ); -} - -function CalendarSidebarContent() { - const isMacos = platform() === "macos"; - const calendar = usePermission("calendar"); - - const visibleProviders = PROVIDERS.filter( - (p) => p.platform === "all" || (p.platform === "macos" && isMacos), - ); - - return ( - - {visibleProviders.map((provider) => - provider.disabled ? ( -
- {provider.icon} - {provider.displayName} - {provider.badge && ( - - {provider.badge} - - )} -
- ) : ( - - -
- {provider.icon} - - {provider.displayName} - - {provider.badge && ( - - {provider.badge} - - )} -
-
- - {provider.id === "apple" && ( -
- {calendar.status !== "authorized" && ( -
- -
- )} - {calendar.status === "authorized" && ( - - - - )} -
- )} -
-
- ), - )} -
- ); -} diff --git a/apps/desktop/src/components/main/body/empty/index.tsx b/apps/desktop/src/components/main/body/empty/index.tsx index e4b0c7e511..82a645f016 100644 --- a/apps/desktop/src/components/main/body/empty/index.tsx +++ b/apps/desktop/src/components/main/body/empty/index.tsx @@ -1,6 +1,5 @@ import { AppWindowIcon } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Kbd } from "@hypr/ui/components/ui/kbd"; @@ -52,57 +51,6 @@ export function TabContentEmpty({ ); } -const TIPS = [ - { text: "Press ⌘⇧N to create a new note and start listening immediately" }, - { text: "Use ⌘K to quickly search across all your notes" }, - { - text: "Hyprnote works fully offline — set up Ollama or LM Studio in AI Settings", - }, - { - text: "Press ⌘⇧J to open AI Chat and ask follow-up questions about your notes", - }, - { - text: "Use templates to get structured summaries tailored to your meeting type", - }, - { text: "Press ⌘⇧T to reopen the last tab you closed" }, - { - text: "Connect your Apple Calendar to automatically see upcoming meetings", - }, -]; - -function RotatingTip() { - const [index, setIndex] = useState(() => - Math.floor(Math.random() * TIPS.length), - ); - - useEffect(() => { - const interval = setInterval(() => { - setIndex((prev) => (prev + 1) % TIPS.length); - }, 5000); - return () => clearInterval(interval); - }, []); - - return ( -
- Did you know? -
- - - {TIPS[index].text} - - -
-
- ); -} - function EmptyView() { const newNote = useNewNote({ behavior: "current" }); const openCurrent = useTabs((state) => state.openCurrent); @@ -137,7 +85,7 @@ function EmptyView() { ); return ( -
+
-
- -
Date: Wed, 18 Feb 2026 18:35:59 +0900 Subject: [PATCH 10/12] feat: Add keyboard navigation to organization selector Add arrow key navigation and Enter key selection to the organization dropdown. Implement highlighted index state to track the currently selected item and enable keyboard-driven selection of organizations or creation of new ones. Update styling to show highlighted state and reset highlighting when search term changes. --- .../components/main/body/contacts/details.tsx | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/details.tsx b/apps/desktop/src/components/main/body/contacts/details.tsx index a4cd184855..7bb94ad3be 100644 --- a/apps/desktop/src/components/main/body/contacts/details.tsx +++ b/apps/desktop/src/components/main/body/contacts/details.tsx @@ -579,6 +579,7 @@ function OrganizationControl({ closePopover: () => void; }) { const [searchTerm, setSearchTerm] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const userId = main.UI.useValue("user_id", main.STORE_ID); const organizationsData = main.UI.useResultTable( @@ -599,6 +600,9 @@ function OrganizationControl({ ) : allOrganizations; + const showCreateOption = searchTerm.trim() && organizations.length === 0; + const itemCount = organizations.length + (showCreateOption ? 1 : 0); + const createOrganization = main.UI.useSetRowCallback( "organizations", (p: { name: string; orgId: string }) => p.orgId, @@ -623,8 +627,19 @@ function OrganizationControl({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); + } else if (e.key === "ArrowUp") { e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < organizations.length) { + selectOrganization(organizations[highlightedIndex].id); + } else if (showCreateOption) { + handleCreateOrganization(); + } } }; @@ -646,7 +661,10 @@ function OrganizationControl({ setSearchTerm(e.target.value)} + onChange={(e) => { + setSearchTerm(e.target.value); + setHighlightedIndex(-1); + }} onKeyDown={handleKeyDown} placeholder="Search or add company" className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:ring-offset-0" @@ -655,12 +673,18 @@ function OrganizationControl({ {searchTerm.trim() && (
- {organizations.map((org: any) => ( + {organizations.map((org: any, index: number) => ( ))} - {organizations.length === 0 && ( + {showCreateOption && (
)} -
+
Name
@@ -327,39 +325,6 @@ export function DetailsColumn({
-
-
-
-

- Danger Zone -

-
-
-
-
-

- Delete this contact -

-

- This action cannot be undone -

-
- -
-
-
-
-
diff --git a/apps/desktop/src/components/main/body/contacts/index.tsx b/apps/desktop/src/components/main/body/contacts/index.tsx index 7568e1e713..d7d4d76a35 100644 --- a/apps/desktop/src/components/main/body/contacts/index.tsx +++ b/apps/desktop/src/components/main/body/contacts/index.tsx @@ -144,14 +144,18 @@ function ContactView({ tab }: { tab: Extract }) { return ( - + {selected?.type === "organization" ? ( setSelected({ type: "person", id: personId }) } @@ -159,7 +163,6 @@ function ContactView({ tab }: { tab: Extract }) { ) : ( )} diff --git a/apps/desktop/src/components/main/body/contacts/organization-details.tsx b/apps/desktop/src/components/main/body/contacts/organization-details.tsx index bc80b5d00f..169f2d74af 100644 --- a/apps/desktop/src/components/main/body/contacts/organization-details.tsx +++ b/apps/desktop/src/components/main/body/contacts/organization-details.tsx @@ -10,11 +10,9 @@ import * as main from "../../../../store/tinybase/store/main"; export function OrganizationDetailsColumn({ selectedOrganizationId, - handleDeleteOrganization, onPersonClick, }: { selectedOrganizationId?: string | null; - handleDeleteOrganization: (id: string) => void; onPersonClick?: (personId: string) => void; }) { const selectedOrgData = main.UI.useRow( @@ -42,7 +40,7 @@ export function OrganizationDetailsColumn({
-
+
Name
@@ -51,18 +49,16 @@ export function OrganizationDetailsColumn({ />
-
-
People
-
- {peopleInOrg?.length ?? 0}{" "} - {(peopleInOrg?.length ?? 0) === 1 ? "person" : "people"} -
-

People + + {" "} + · {peopleInOrg?.length ?? 0}{" "} + {(peopleInOrg?.length ?? 0) === 1 ? "member" : "members"} +

{(peopleInOrg?.length ?? 0) > 0 ? ( @@ -150,39 +146,6 @@ export function OrganizationDetailsColumn({
-
-
-
-

- Danger Zone -

-
-
-
-
-

- Delete this organization -

-

- This action cannot be undone -

-
- -
-
-
-
-
From 241f6ec823a1078f3d3f249238ae0532e7a9577a Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Wed, 18 Feb 2026 19:30:08 +0900 Subject: [PATCH 12/12] feat: Replace contact initials with Facehash avatars Remove custom getInitials utility function and replace text-based initials with Facehash component for contact avatars. Use person name, email, or humanId as fallback for generating unique visual avatars. This provides more visually distinctive and consistent contact representation in the contacts list. --- .../main/body/contacts/contacts-list.tsx | 14 +++++++++----- .../src/components/main/body/contacts/shared.tsx | 12 ------------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx index c6dbc4496f..d66bdda2ba 100644 --- a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -1,3 +1,4 @@ +import { Facehash } from "facehash"; import { Building2, CornerDownLeft, Pin } from "lucide-react"; import { Reorder } from "motion/react"; import React, { useCallback, useMemo, useState } from "react"; @@ -8,7 +9,7 @@ import { cn } from "@hypr/utils"; import { useNativeContextMenu } from "../../../../hooks/useNativeContextMenu"; import * as main from "../../../../store/tinybase/store/main"; -import { ColumnHeader, getInitials, type SortOption } from "./shared"; +import { ColumnHeader, type SortOption } from "./shared"; type ContactItem = | { kind: "person"; id: string } @@ -417,10 +418,13 @@ function PersonItem({ active ? "border-neutral-500 bg-neutral-100" : "border-transparent", ])} > -
- - {getInitials(personName || personEmail)} - +
+
diff --git a/apps/desktop/src/components/main/body/contacts/shared.tsx b/apps/desktop/src/components/main/body/contacts/shared.tsx index ef84b372e0..009f7988f3 100644 --- a/apps/desktop/src/components/main/body/contacts/shared.tsx +++ b/apps/desktop/src/components/main/body/contacts/shared.tsx @@ -10,18 +10,6 @@ import { DropdownMenuTrigger, } from "@hypr/ui/components/ui/dropdown-menu"; -export const getInitials = (name?: string | null) => { - if (!name) { - return "?"; - } - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2); -}; - export type SortOption = | "alphabetical" | "reverse-alphabetical"