Skip to content

feat(tags): tag categories, tag hub, and single-tag page - #901

Merged
h4yfans merged 72 commits into
mainfrom
tag-categories
Aug 2, 2026
Merged

feat(tags): tag categories, tag hub, and single-tag page#901
h4yfans merged 72 commits into
mainfrom
tag-categories

Conversation

@h4yfans

@h4yfans h4yfans commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What this does

Turns the flat tag namespace into an organizable system: tags can be grouped under named categories, and clicking a tag anywhere opens a full tag page (a table of everything tagged, replacing the cramped sidebar drill-down).

  • Tag categories — group tags under user-defined categories, independent of the tag's / name. A tag belongs to at most one category; unassigned tags fall under "Uncategorized".
  • Tag hub (tags singleton tab) — create / rename / delete categories, inline tag creation, drag-and-drop ordering, and a search across both categories and tags. Reached from the tag section's hub button.
  • Single-tag page (tag tab, one per tag) — a shared FolderTableView listing the tag's notes, tasks, and inbox items (including / descendants), a kind filter, and header actions (rename / colour / icon / delete).
  • Sidebar — the tag list now groups by category with a Manual sort default. Clicking a tag anywhere in the app opens the tag tab; the old drill-down panel is removed.

Architecture & data

  • New tag_categories table + category_id / sort_order columns on tag_definitions. Additive hand-written migration (0038) — no reset, existing rows keep their values.
  • No foreign key on category_id (a cascade FK broke sync before): a dangling category_id reads as uncategorized. Categories soft-delete; deleting a category never deletes its tags — they become uncategorized.
  • Synced as a new tag_category record type (pull handler + push service + field-level clock), registered including ENCRYPTABLE_ITEM_TYPES so category names are encrypted on the wire. Category assignment rides on the existing tag_definition sync.
  • Ordering and filtering live in pure, unit-tested modules (reorder.ts, filter.ts); listTagItems correctly spans the dual data/index databases and matches descendants without prefix collisions (workworkshop).

Backward compatibility

  • Older builds skip the unknown tag_category type on pull and cannot clobber category_id / sort_order on a tag_definition write (field-level merge, tested including explicit null un-assignment).
  • A newer build opening an older vault gets the additive migration; no data loss either direction.

Testing

  • Unit / component: main-process 4160, renderer 12007, sync-server 792 — all green.
  • E2E: a new spec (sidebar tag → tab; a hub category surviving a real process restart) plus three pre-existing tag specs migrated off the deleted drill-down.
  • Gates green: typecheck, lint, ipc:check, i18n:check, architecture, contracts, docs:impact --strict, docs:build.
  • A cross-device delete-propagation bug (a category delete used a fresh vector clock instead of the row's, so it was skipped on peers and could resurrect) was found and fixed with an integration test wiring the sync service's delete payload through the peer's apply path.

Not yet done

  • Two-device mixed-version sync (dev:a / dev:b) is a manual check — commands are in the branch notes.
  • Deferred cleanups, safe to leave: the now-vestigial sidebar drill-down context/container (tag was the only drill-down type) and the now-unused use-tag-detail / use-task-tag-detail hooks.
  • Tag-page pin is toolbar-level (pin selected notes) rather than a per-row pin/unpin with indicator — the shared table has no per-row action slot and TagItem carries no pinned flag.

Design spec and implementation plan are under docs/superpowers/.

h4yfans added 30 commits July 23, 2026 16:54
The delete-category test asserted survival via getOrCreateTag, which
is get-or-insert: a hard-delete bug would silently recreate the row
and the test would still pass. Query tag_definitions directly instead
and check a non-default sortOrder, color, createdAt, and row count
survive unchanged.

Also swap the raw tag_definitions / category_id string literals in the
tagCount subquery for the typed schema references already imported,
so a schema rename breaks the build instead of failing silently at
runtime.
tag_category was added to SYNC_ITEM_TYPES, RECORD_SYNC_ITEM_TYPES, and
RECORD_CLOCK_REQUIRED_ITEM_TYPES but missed the fourth parallel list,
ENCRYPTABLE_ITEM_TYPES. It's a record type carrying user-authored data
and belongs in the encryptable set exactly as tag_definition does.

Add a parity test asserting ENCRYPTABLE_ITEM_TYPES and
RECORD_SYNC_ITEM_TYPES contain the same set of types, so a future item
type added to one without the other fails immediately. Regenerate the
IPC invoke map, whose crypto:* channel signatures embed the
ENCRYPTABLE_ITEM_TYPES union type.
The update branch that returns 'applied' (remote clock cleanly newer,
same device key) was never exercised. Added a test that upserts a row
then re-upserts with a strictly greater clock on the same device to
distinguish it from the concurrent/merge conflict path.
…ition-handler

Review found no test exercised the null-vs-omitted distinction the
!== undefined check on categoryId exists for. Adds one test seeding a
category+sortOrder, applying a dominating-clock payload with an
explicit categoryId: null, and asserting the category clears while
sortOrder is untouched.
Annotate tagsService with Omit<TagsClientAPI, ...> & overrides for the
three category methods that intentionally take flat args, instead of
dropping the type entirely. Restores compile-time conformance for the
ten pre-existing methods and listCategories/reorder.
…ailure

updateTagColor succeeding then reorder failing left a tag created but
uncategorized, invisible until an unrelated event revalidated the
caches, while showing "Failed to create tag" - which is wrong, the tag
was created. Refetch categories/note-tags on that branch and use a new
tagsHub.errors.createTagFiledFailed message.
The prior test only asserted listCategories' call count, which
fetchCategories drives on its own - the mocked useNoteTagsQuery had no
refetch field, so createTag's void refetchNoteTags?.() call was an
unobservable no-op and deleting it wouldn't fail the test. Added a
refetch mock matching useNoteTagsQuery's real return shape and asserted
it's called, since a tag's visibility in the hub comes from that data,
not from listCategories.

Also renamed the createTagFiledFailed i18n key to
createTagCategorizeFailed (typo fix, same message), and reset the
updateTagColor/reorder/refetchNoteTags mocks in beforeEach to match
listCategories' existing reset style.
… reorder

CategoryBlock already had working inline rename and delete-confirm UI, but
the hub page rendered real category blocks without onRename/onDelete, so
both affordances were visible but silently did nothing. Pass
renameCategory/deleteCategory from useTagCategories() to the mapped
category blocks only; the Uncategorized block keeps neither.

Also add page-level tests for handleDragEnd (a tag moved across categories,
a category reordered past another), asserting reorder() gets the exact
payload. Verified by temporarily breaking the target-index resolution and
confirming both new tests fail, then reverting.
TagViewPage always passed an undefined color prop into getTagColors,
so the header chip fell back to the name-hash color even when the tag
has a user-picked stored color. That mismatched the hub chip / sidebar,
which resolve color from tag_definitions via useNoteTagsQuery.

Look up the tag's row in useNoteTagsQuery (case-insensitive match) and
prefer its stored color, falling back to the color prop then empty
string when the query hasn't resolved or the tag isn't found.
…d inbox branches

listTagItems' descendant/prefix guarantee was only exercised via notes;
tasks and inbox shared the same tagMatches predicate but had no dedicated
coverage, so a future per-source predicate split wouldn't be caught.
h4yfans added 19 commits August 1, 2026 16:44
…ulk bar scope

Ports tag-view.tsx's handleNoteOpen so tag-scoped rows route by kind (task
tab, inbox with a fresh focusedAt token, note via sidebar navigation) while
folder scope keeps its plain-tab handler. Wires Task 10's FilterBuilder
lockedCondition and Task 12's BulkActionBar scope/selectedRows props, deriving
selectedRows from the live rows + selection set on every render.
The note-only subset of the selection was filtered through the table row
model, which is narrowed by the header search and, in the grouped table,
by group expansion. Selection is pruned by neither, so a bulk delete or
move issued while a search was active silently skipped selected rows that
happened to be filtered out of view.

Derive it from `notes` instead — kind is a property of a row, not of its
visibility — and reuse the same memo for the shift-cmd-M shortcut, which
was recomputing the filter inline.
Under tag scope a row can be a task or an inbox item, but the tag chip's
remove handler and the bulk "add tag" action both call the notes-only
`notesService.update` IPC over the raw selection. Gate the chip handler on
kind and run the bulk action over the note-only subset, so the note rows
of a mixed selection are still tagged while the rest are skipped.

The row context menu acts on that same note-only subset, so label its bulk
items with that count and hide them when the selection holds no notes —
previously a task-only selection offered "Delete 0 Notes".
LayoutGrid read as a generic view switcher next to the tag rows it opens.
Copilot AI review requested due to automatic review settings August 1, 2026 22:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Not ready to approve

A few schema/contract details need tightening (notably scope normalization and schema/index alignment) before the change can be safely treated as complete.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (4)

packages/contracts/src/folder-view-api.ts:86

  • scopeKey lowercases tag names but does not trim them, so callers that accidentally include leading/trailing whitespace will get a different cache key than the same logical tag (and it won’t match the schema change above unless every call path is guaranteed to go through Zod parsing). Trimming here makes the helper robust when it’s used directly.
    packages/db-schema/src/schema/tag-definitions.ts:5
  • The SQL migration creates idx_tag_definitions_category on tag_definitions(category_id), but the Drizzle schema definition does not declare that index. This kind of schema/migration drift makes it easier to accidentally remove/rename an index later (and makes schema review harder). Consider declaring the index in sqliteTable(...) to keep the schema aligned with the hand-written migration.
    packages/contracts/src/folder-view-api.ts:77
  • ViewScopeSchema currently allows whitespace-only tag names (e.g. ' ' passes .min(1)), which can create hard-to-debug cache keys and IPC requests that don't resolve to any real tag. Trim (and validate) both path and tag at the schema boundary so all downstream code can assume normalized values.

This issue also appears on line 84 of the same file.
apps/desktop/src/main/database/drizzle-data/meta/_journal.json:297

  • PR description says the additive migration is 0038, but the code changes introduce 0040_tag_categories and 0041_tag_definition_views (as recorded in the migration journal). Please update the PR description so reviewers and release notes don’t reference the wrong migration number.
      "idx": 40,
      "version": "6",
      "when": 1784892405671,
      "tag": "0040_tag_categories",
      "breakpoints": true
    },
    {
      "idx": 41,
      "version": "6",
      "when": 1785585337000,
      "tag": "0041_tag_definition_views",
      "breakpoints": true
  • Files reviewed: 122/124 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

@h4yfans
h4yfans marked this pull request as ready for review August 2, 2026 15:53
@h4yfans
h4yfans merged commit 0b153c7 into main Aug 2, 2026
19 of 21 checks passed
@h4yfans
h4yfans deleted the tag-categories branch August 2, 2026 15:53
style={{ backgroundColor: tagColors?.text }}
/>
{tagSegments.map((segment, i) => (
<Fragment key={i}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "i".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

h4yfans added a commit that referenced this pull request Aug 3, 2026
Tag categories, the tag hub, and the single-tag page shipped in #901 but
were unreachable from MCP, and vault_get_tags narrowed each tag down to
name + count so an agent could not even tell that categories exist.

- allowlist tags.listCategories as a desktop read operation
- allowlist tags.createCategory / renameCategory / deleteCategory /
  reorder as write operations, so they run behind write approval
- widen TagCount with color, icon, sort_order, category_id and
  category_name; getAllTagsWithCounts already carries every field except
  the category name, which the adapter joins from listTagCategories

Closes #918
h4yfans added a commit that referenced this pull request Aug 3, 2026
Tag categories, the tag hub, and the single-tag page shipped in #901 but
were unreachable from MCP, and vault_get_tags narrowed each tag down to
name + count so an agent could not even tell that categories exist.

- allowlist tags.listCategories as a desktop read operation
- allowlist tags.createCategory / renameCategory / deleteCategory /
  reorder as write operations, so they run behind write approval
- widen TagCount with color, icon, sort_order, category_id and
  category_name; getAllTagsWithCounts already carries every field except
  the category name, which the adapter joins from listTagCategories

Closes #918
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants