Add contact tags#421
Conversation
Free-form tags per contact: editable via a selectize-style input in the contact detail view (type to filter existing tags, Enter/comma to create, × to remove). Tags surface as a filterable column in both AllContacts and ProjectView. Syncs across shared projects via the existing sub-table merge/push pattern. Closes #418 Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Use --font-serif for the "Add a tag…" hint text instead of --font-mono. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…rawer Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Table cell: show first 2 tags + +N overflow badge instead of clipping - Filter dropdown: selected options float to top; search input appears when options exceed 10; list capped at 50 visible items Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
| contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, | ||
| tag TEXT NOT NULL, | ||
| created_at INTEGER NOT NULL DEFAULT 0, | ||
| synced_at INTEGER, |
There was a problem hiding this comment.
The synced_at column is declared here but never written to anywhere in this PR — it will always be NULL. Either populate it in the contacts:add-tag handler (and update it after a merge/push in the sync engine), or drop it to avoid dead schema.
| synced_at INTEGER, | |
| created_at INTEGER NOT NULL DEFAULT 0, | |
| UNIQUE(contact_id, tag) |
| const filtered = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) | ||
| .slice(0, MULTISELECT_MAX_VISIBLE); | ||
|
|
||
| const checkedFirst = [ | ||
| ...filtered.filter((o) => selectedSet.has(o.value)), | ||
| ...filtered.filter((o) => !selectedSet.has(o.value)), | ||
| ]; |
There was a problem hiding this comment.
The cap (.slice(0, MULTISELECT_MAX_VISIBLE)) is applied before the selected-first sort, so if a user has selected tags that fall outside the first 50 options, those selected tags won't appear at the top (or at all until the user searches for them). Pin selected items first, then fill with unselected up to the cap:
| const filtered = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) | |
| .slice(0, MULTISELECT_MAX_VISIBLE); | |
| const checkedFirst = [ | |
| ...filtered.filter((o) => selectedSet.has(o.value)), | |
| ...filtered.filter((o) => !selectedSet.has(o.value)), | |
| ]; | |
| const selectedItems = options.filter((o) => selectedSet.has(o.value)); | |
| const unselectedVisible = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) | |
| .filter((o) => !selectedSet.has(o.value)) | |
| .slice(0, Math.max(0, MULTISELECT_MAX_VISIBLE - selectedItems.length)); | |
| const checkedFirst = [...selectedItems, ...unselectedVisible]; |
| .run(rss.id, contactId, rss.rss_url, rss.last_polled_at, rss.is_invalid); | ||
| } | ||
|
|
||
| shared.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); |
There was a problem hiding this comment.
Tags are global (one set per contact across all projects), but pushSubTablesToShared is called per-project per-contact. This means if a contact belongs to two different shared projects synced from two different clients, the second push will DELETE the first client's tags before re-inserting its own — silently clobbering them on the shared DB.
The merge on the clobbered client's next sync will union them back locally, but then that client's next push overwrites shared again. The two clients can never converge on the shared copy.
One fix: merge rather than replace on push — only insert rows that are missing from shared, and optionally tombstone-delete rows that were explicitly removed locally (which requires tracking deletions). A simpler short-term fix is to union on push the same way merge does:
| shared.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); | |
| const sharedTagSet = new Set( | |
| (shared.prepare('SELECT tag FROM contact_tags WHERE contact_id = ?').all(contactId) as { tag: string }[]).map((r) => r.tag), | |
| ); | |
| const tagRows = local | |
| .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') | |
| .all(contactId) as { id: string; tag: string; created_at: number }[]; | |
| for (const t of tagRows) { | |
| if (!sharedTagSet.has(t.tag)) { | |
| shared | |
| .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') | |
| .run(t.id, contactId, t.tag, t.created_at); | |
| } | |
| } |
This won't propagate explicit local deletions to shared (since there's no tombstone table), but it at least prevents the clobber. Worth deciding explicitly which semantics you want before shipping this to multi-client users.
|
|
||
| .col-filter-search { | ||
| width: 100%; | ||
| border: 1px solid var(--color-border); |
There was a problem hiding this comment.
--color-border isn't in the design token list in CLAUDE.md. If it isn't defined in global.css, this falls back to currentColor in most browsers and the border will be invisible/wrong in dark mode. The established border tokens are rgba(26, 24, 21, 0.14) (light) and 0.28 (strong). Consider using one of those directly:
| border: 1px solid var(--color-border); | |
| border: 1px solid rgba(26, 24, 21, 0.14); |
| useEffect(() => { | ||
| window.sourcerer.listAllContactTags().then(setAllTags); | ||
| }, [tags]); |
There was a problem hiding this comment.
This refetches the complete global tag list every time the contact's tags array changes (i.e. on every add/remove). For vaults with many contacts and tags this could be a noticeable round-trip per keystroke.
Consider fetching once on mount and appending the new tag to allTags optimistically in addTag instead of relying on the re-fetch, or at minimum memoize the IPC call behind a short debounce. At minimum, the dependency could be [] with a manual refresh after a successful add, avoiding the unnecessary fetch on remove too.
| for (const t of mergedTags) { | ||
| local | ||
| .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') | ||
| .run(t.id, contactId, t.tag, t.created_at); | ||
| } |
There was a problem hiding this comment.
Minor: db.prepare() inside a loop re-parses the SQL on every iteration. Hoist it above the loop:
| for (const t of mergedTags) { | |
| local | |
| .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') | |
| .run(t.id, contactId, t.tag, t.created_at); | |
| } | |
| const insertTag = local.prepare( | |
| 'INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)', | |
| ); | |
| for (const t of mergedTags) { | |
| insertTag.run(t.id, contactId, t.tag, t.created_at); | |
| } |
Same pattern applies in pushSubTablesToShared.
- Drop unused synced_at column from contact_tags schema - Fix MultiSelectFilter: selected items always shown first, cap applied only to unselected items so selected options are never hidden - Sync push: union rather than delete-replace to avoid clobbering tags from other clients on the shared DB - Fix --color-border token (not a valid design token); use rgba value - Fetch allTags once on mount; append optimistically on add to avoid round-trip per keystroke - Hoist prepared statements above loops in sync engine merge/push Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
contacts:add-tag and contacts:remove-tag previously only wrote to contact_tags, leaving the contact's updated_at unchanged. The push path checks updated_at > synced_at to decide whether to push sub-tables, so tags added after a contact's last sync were silently never pushed. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
| const localOnlyTags = localTags.filter((t) => !sharedTagValues.has(t.tag)); | ||
| const mergedTags = [...sharedTags, ...localOnlyTags]; | ||
|
|
||
| local.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); |
There was a problem hiding this comment.
Sync doesn't propagate tag deletions
The merge strategy here is purely additive: it takes sharedTags ∪ localOnlyTags and writes the union back to local. That means if a user on client A removes a tag and syncs, client B will never learn about the deletion — the tag stays on B forever.
The same additive-only pattern is used in pushSubTablesToShared (only inserts new tags, never deletes). This is consistent with how some other sub-tables are handled, but for an explicitly user-removable attribute like tags it's a meaningful limitation worth calling out in docs or comments.
If you want true delete propagation you'd need a tombstone/deletion-log table (hard). A middle ground is "shared wins on delete" — delete local tags not present in shared during merge — but that would silently remove legitimately local-only tags. At minimum this behaviour should be documented here so future developers don't assume deletions propagate.
| const now = Math.floor(Date.now() / 1000); | ||
| db.transaction(() => { | ||
| db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); | ||
| db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); |
There was a problem hiding this comment.
updated_at is bumped even when nothing was deleted
If tag doesn't exist on contactId, the DELETE is a no-op, but contacts SET updated_at still runs, marking the contact as modified and triggering a spurious sync push.
| db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); | |
| const { changes } = db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); | |
| if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); |
| const db = getDatabase(); | ||
| const now = Math.floor(Date.now() / 1000); | ||
| db.transaction(() => { | ||
| db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); |
There was a problem hiding this comment.
remove-tag doesn't normalize the tag before querying
add-tag stores the tag lowercased and trimmed. If the renderer ever passes an un-normalized value here (e.g. a tag with trailing whitespace), the DELETE will silently miss and the tag won't be removed.
| db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); | |
| db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag.trim().toLowerCase()); |
| db.transaction(() => { | ||
| db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') | ||
| .run(uuidv4(), contactId, normalized, now); | ||
| db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); |
There was a problem hiding this comment.
updated_at bumped even when the INSERT was a no-op (duplicate tag)
INSERT OR IGNORE silently skips duplicates, but the UPDATE contacts SET updated_at always runs regardless. Adding the same tag twice sends a spurious sync. Use db.changes() on the statement result to guard it:
| db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); | |
| const { changes } = db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') | |
| .run(uuidv4(), contactId, normalized, now); | |
| if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); |
| line-height: 1.5; | ||
| } | ||
|
|
||
| .tags-chip-remove { |
There was a problem hiding this comment.
Missing border-radius: 0 on a button element
Per the project design system (CLAUDE.md): "All interactive elements (buttons, inputs, selects, textareas) use border-radius: 0 — square corners everywhere, no exceptions." The .tags-chip-remove button needs an explicit reset here, even if the global stylesheet may default to 0, to be safe across browser agents and to follow the convention used elsewhere.
| .tags-chip-remove { | |
| .tags-chip-remove { | |
| background: none; | |
| border: none; | |
| border-radius: 0; | |
| padding: 0; | |
| margin: 0; | |
| cursor: pointer; | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--color-text-dim); | |
| line-height: 1; | |
| display: flex; | |
| align-items: center; | |
| } |
| onChange: (v: string[]) => void; | ||
| }) { | ||
| const [search, setSearch] = useState(''); | ||
|
|
There was a problem hiding this comment.
Search state is not reset when the filter panel closes and reopens
search is component-local state on MultiSelectFilter, which is re-mounted each time the filter panel is toggled. That means the state is actually reset on every open — but only because the component unmounts. If that rendering strategy ever changes (e.g. hidden with display:none rather than conditionally rendered), stale search terms would reappear.
More immediately: autoFocus on the search input fires every render while the panel is open (including when a checkbox is toggled), which can cause jarring focus jumps. Consider moving focus management to a useEffect with an empty dep array instead.
| const contactId = insertContact(testDb, 'Alice'); | ||
| const before = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; | ||
| await new Promise((r) => setTimeout(r, 1100)); | ||
| await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); |
There was a problem hiding this comment.
setTimeout(1100) makes this test slow and fragile on a heavily loaded CI machine
The 1.1-second sleep is needed because updated_at uses Unix seconds (Math.floor(Date.now() / 1000)) and the test needs the two values to differ. A cleaner approach is to insert the contact with a fixed historical timestamp and compare it directly instead of relying on wall-clock advancement:
// Instead of sleeping, set a known past updated_at
const contactId = insertContact(testDb, 'Alice');
testDb.prepare('UPDATE contacts SET updated_at = 0 WHERE id = ?').run(contactId);
await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' });
const after = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at;
expect(after).toBeGreaterThan(0);Same pattern applies to the remove-tag variant below.
- add-tag/remove-tag: only bump updated_at if the write had changes - TagsSection: border-radius: 0 on chip remove button - MultiSelectFilter: focus search input once on mount via useEffect/useRef, not autoFocus (which re-fired on every checkbox toggle) - Tests: replace 1.1s sleeps with updated_at backdating; add cases for no-op add and no-op remove not bumping updated_at - Sync: add comment on additive-only tag merge limitation (see #422) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Adds first-class “contact tags” support across the app (DB schema, IPC, renderer UI, filtering, and shared-project sync) so tags can be edited in contact details and used to filter contact tables.
Changes:
- Introduces
contact_tagstable to local/shared SQLite schemas and includes tags in sync sub-table merge/push. - Adds IPC + preload APIs for adding/removing tags and listing all distinct tags; updates contact list/detail/project-row payloads to include
tags: string[]. - Adds renderer UI for editing tags on the contact detail view and filtering/displaying tags in All Contacts and Project View tables.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/tags.test.ts | New Vitest coverage for tag IPC handlers, list/get/list-for-project tag fields, and sync behavior. |
| src/shared/types.ts | Extends shared contact row/detail/list types to include tags: string[]. |
| src/renderer/src/views/ProjectView.tsx | Computes tag filter options for project contacts and passes them into the table. |
| src/renderer/src/views/ContactsTable.tsx | Adds Tags column rendering + multi-select filter support; adds tags to filter models. |
| src/renderer/src/views/ColumnHeader.tsx | Enhances MultiSelectFilter with search + checked-first ordering + limits. |
| src/renderer/src/views/ColumnHeader.css | Styles the new multiselect search input. |
| src/renderer/src/views/AllContacts.tsx | Implements OR-semantics tag filtering and builds tag filter options for All Contacts. |
| src/renderer/src/views/AllContacts.css | Adds styling for tag chips/overflow in the contacts table. |
| src/renderer/src/hooks/useProjectContacts.ts | Adds OR-semantics tag filtering to project contact filtering hook. |
| src/renderer/src/env.d.ts | Types the new window.sourcerer.*ContactTag* preload APIs. |
| src/renderer/src/contacts/TagsSection.tsx | New selectize-style tag editor for contact detail view. |
| src/renderer/src/contacts/TagsSection.css | Styles the tag editor (chips/input/dropdown). |
| src/renderer/src/contacts/GlobalTab.tsx | Renders the new Tags section in the contact detail global tab. |
| src/preload/index.ts | Exposes new tag IPC invocations via window.sourcerer. |
| src/main/sync/engine.ts | Includes contact_tags in shared/local merge + push paths (additive-only merge). |
| src/main/ipc/contacts.ts | Adds tag fields to contact list/get/list-for-project and introduces tag IPC handlers. |
| src/main/database/shared-schema.ts | Adds contact_tags table + index to shared schema. |
| src/main/database/schema.ts | Adds contact_tags table + index to local schema. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| WHERE pm.contact_id = c.id | ||
| ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw | ||
| ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw, | ||
| (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY created_at) AS tags_raw |
| (SELECT MIN(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_first_contacted, | ||
| (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted | ||
| (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted, | ||
| (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY created_at) AS tags_raw |
| ipcMain.handle('contacts:remove-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { | ||
| const db = getDatabase(); | ||
| const now = Math.floor(Date.now() / 1000); | ||
| db.transaction(() => { | ||
| const { changes } = db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); | ||
| if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); | ||
| })(); | ||
| broadcastContactsChanged(); | ||
| }); |
| onChange={(e) => { | ||
| setInput(e.target.value.replace(',', '')); | ||
| setDropdownOpen(true); | ||
| }} |
- Wrap GROUP_CONCAT in an inner subquery to guarantee ORDER BY created_at is respected by the aggregate (SQLite does not guarantee aggregate order from an outer ORDER BY on a scalar subquery) - Strip all commas from tag input, not just the first (replace with /,/g) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Defensive guard: trim and lowercase before DELETE so un-normalized values passed by future callers can't silently miss the row. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Summary
What's new for users
You can now label contacts with one or more freeform tags (e.g. "whistleblower", "legal", "source") directly from the contact detail view. Tags autocomplete from existing ones across all your contacts. You can filter both the All Contacts list and any project view down to contacts that have a given tag.
Test plan
Implementation notes
contact_tagstable added to both local and shared schema (no migration needed — pre-production)UNIQUE(contact_id, tag)enforces deduplication at the DB leveltags: string[]added toContactListItem,ContactDetail, andProjectContactRowcontacts:add-tag,contacts:remove-tag,contacts:list-all-tagsCloses #418