Skip to content

feat(contacts): refactor contacts#3818

Closed
ComputelessComputer wants to merge 12 commits intomainfrom
c-branch-5
Closed

feat(contacts): refactor contacts#3818
ComputelessComputer wants to merge 12 commits intomainfrom
c-branch-5

Conversation

@ComputelessComputer
Copy link
Collaborator

@ComputelessComputer ComputelessComputer commented Feb 10, 2026

Summary

Refactors the contacts system with a unified selection model and adds pin/ordering support for organizations.

Core changes:

  • Replaced { selectedOrganization, selectedPerson } with a tagged union ContactsSelection ({ type: "person" | "organization", id })
  • Added created_at, pinned, pin_order fields to organization schema (zod + tinybase)
  • Added created_at to human schema
  • New contacts-list.tsx component with combined pinned list (people + orgs), drag-to-reorder, and sort options
  • Removed contacts permission check from calendar sidebar (no longer needed there)

Bug fixes (latest commit):

  • Fixed invalid nested <button> elements in PersonItem and OrganizationItem components (both contacts-list.tsx and organizations.tsx) — outer buttons replaced with <div role="button">
  • Added pin_order to organization and human frontmatter transforms (both toFrontmatter and fromFrontmatter directions) so pin order persists across sessions
  • Fixed pin order calculation to use cross-table max (both humans + organizations) when pinning, so new pins always sort to the end of the combined list

Review & Testing Checklist for Human

  • Tab state migration: The ContactsState shape changed from { selectedOrganization, selectedPerson } to { selected: ContactsSelection | null }. Verify that existing persisted tab states don't cause runtime errors on app load.
  • Keyboard accessibility: The <button><div role="button"> change in three components should still support Enter/Space activation and tab focus. Manually test keyboard navigation through the contacts list.
  • Pin order round-trip: pin_order defaults to 0 in organizationToFrontmatter / humanToFrontmatter for unpinned items. After a save+load cycle, unpinned items will have pin_order: 0 instead of undefined. Confirm this doesn't affect sort behavior (pinned items are filtered by pinned boolean, not pin_order).
  • Drag reorder of mixed pinned contacts: Pin a mix of people and organizations, then drag to reorder. Verify order persists after navigating away and back.

Suggested test plan: Open the contacts tab, create a few people and organizations, pin some of each, reorder the pinned list via drag, close and reopen the app, and verify the order and selection state are preserved.

Notes

  • The dprint fmt rustfmt errors are pre-existing (missing rustfmt component for the Rust toolchain) and unrelated to this PR.
  • Transform tests were updated for created_at but don't explicitly assert pin_order round-trip — may want to add those.

Link to Devin run: https://app.devin.ai/sessions/8172945c219e4a7689550c510b911cd7
Requested by: @ComputelessComputer


Open with Devin

@netlify
Copy link

netlify bot commented Feb 10, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit dea5a05
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/6991fafb4dba440008ba2a53

@netlify
Copy link

netlify bot commented Feb 10, 2026

Deploy Preview for hyprnote canceled.

Name Link
🔨 Latest commit dea5a05
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/6991fafb93791b000886e3ef

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 7 additional findings.

Open in Devin Review

@ComputelessComputer ComputelessComputer force-pushed the c-branch-5 branch 3 times, most recently from 4e7fd98 to 84386d7 Compare February 10, 2026 10:03
Implement pinning functionality for organizations with drag and
reorder capabilities. Separate pinned and unpinned organizations,
add visual divider, and enable custom pinned order management.
@ComputelessComputer
Copy link
Collaborator Author

@yujonglee made changes to contacts view that involves adding to tinybase.

graphite-app[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

ComputelessComputer and others added 3 commits February 10, 2026 19:10
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
…rm.ts

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration bot and others added 2 commits February 10, 2026 10:49
…ss-table pin ordering

Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
Co-Authored-By: john@hyprnote.com <john@hyprnote.com>
Comment on lines +224 to +232
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,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pin order calculation is inconsistent with unified contacts list. This only checks organizations table, but should check both humans and organizations tables since they share a unified pin order space.

When a user pins an organization here, it could get the same pin_order as an already-pinned person, causing ordering conflicts in the unified contacts list.

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,
});
Suggested change
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,
});
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,
});

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Old OrganizationsColumn and PeopleColumn are now dead code

The imports of OrganizationsColumn (from organizations.tsx) and PeopleColumn / useSortedHumanIds (from people.tsx) were removed from apps/desktop/src/components/main/body/contacts/index.tsx. These components are no longer referenced anywhere in the codebase. Notably, people.tsx:250-282 still has the nested <button> HTML validity issue (outer <button> wrapping inner pin <button>), and organizations.tsx:224-232 only checks the organizations table for max pin_order (not cross-table). Neither issue matters at runtime since these components are unreachable, but the files should be cleaned up or removed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 168 to 174
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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test assertion missing pin_order: 0 for humanToFrontmatter

The storeToFrontmatter function (apps/desktop/src/store/tinybase/persister/human/transform.ts:50) always emits pin_order: store.pin_order ?? 0, so when pin_order is not provided in the input, the actual output frontmatter object contains pin_order: 0. However, the test's expected output does not include pin_order, causing toEqual to fail due to the extra non-undefined property.

Root Cause

The storeToFrontmatter function always includes pin_order with a fallback of 0:

pin_order: store.pin_order ?? 0,

When the test input at line 157 omits pin_order, the actual output is { ..., pin_order: 0 }. Vitest's toEqual performs strict deep equality — an extra property with a non-undefined value (0) in the received object that is absent in the expected object causes the assertion to fail.

Impact: This test will fail, blocking CI.

(Refers to lines 168-179)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +62 to +70
expect(result).toEqual({
frontmatter: {
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
name: "Acme Corp",
pinned: false,
},
body: "",
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test assertion missing pin_order: 0 for organizationToFrontmatter (unpinned case)

The organizationToFrontmatter function (apps/desktop/src/store/tinybase/persister/organization/transform.ts:30) always emits pin_order: org.pin_order ?? 0, so when the input has no pin_order, the output contains pin_order: 0. The test expected object omits this field, causing toEqual to fail.

Root Cause

Same mechanism as the human transform: organizationToFrontmatter always includes pin_order with a default of 0:

pin_order: org.pin_order ?? 0,

The test at line 62-70 expects a frontmatter without pin_order, but the actual output includes pin_order: 0. Vitest's toEqual treats this as a mismatch.

Impact: This test will fail, blocking CI.

Suggested change
expect(result).toEqual({
frontmatter: {
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
name: "Acme Corp",
pinned: false,
},
body: "",
});
expect(result).toEqual({
frontmatter: {
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
name: "Acme Corp",
pinned: false,
pin_order: 0,
},
body: "",
});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 79 to 87
expect(result).toEqual({
frontmatter: {
user_id: "user-1",
created_at: "",
name: "Acme Corp",
pinned: true,
},
body: "",
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test assertion missing pin_order: 0 for organizationToFrontmatter (pinned case)

Same root cause as the unpinned case: organizationToFrontmatter always outputs pin_order: org.pin_order ?? 0. The pinned test input at line 76 omits pin_order, so the output has pin_order: 0, but the expected object at lines 80-87 does not include it.

Root Cause

The test input is:

{ user_id: "user-1", name: "Acme Corp", pinned: true }

Since pin_order is not provided, org.pin_order is undefined, and undefined ?? 0 evaluates to 0. The actual frontmatter output contains pin_order: 0, but the expected output at line 80-87 omits it, causing toEqual to fail.

Impact: This test will fail, blocking CI.

(Refers to lines 79-88)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 132 to 136
...base,
type: "contacts",
state: tab.state ?? {
selectedOrganization: null,
selectedPerson: null,
selected: null,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Tab state migration: persisted old ContactsState shape may cause issues on app load

The ContactsState type changed from { selectedOrganization: string | null, selectedPerson: string | null } to { selected: ContactsSelection | null }. Pinned tabs are serialized/deserialized via apps/desktop/src/store/zustand/tabs/pinned-persistence.ts. If a user has pinned contacts tabs persisted with the old shape, restorePinnedTabsToStore will restore them with the old state structure. The getDefaultState at apps/desktop/src/store/zustand/tabs/schema.ts:132-136 provides a fallback { selected: null } when tab.state is undefined, but if the old serialized state is present, it will be used as-is with the wrong shape. Accessing tab.state.selected would then be undefined (not null), which might work due to optional chaining in most places but could cause subtle differences.

(Refers to lines 131-136)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration bot deleted the c-branch-5 branch February 15, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant