Tag manager (curated tag library)
The tag manager is a curated library of session tags that live in their own database — separate from the free-form per-session tag strings stored on each sessions row. Free-form tags (described in session-tags.md) are short strings you type into the picker on the spot; library tags are entries you create once in Settings, optionally scoped to a subset of projects, then re-apply to any session that fits. A tag can also be ANCHORED, rendering as its own collapsible section in the sidebar.
What it is
The tag manager is a curated library of session tags that live in their own database — separate from the free-form per-session tag strings stored on each sessions row. Free-form tags (described in session-tags.md) are short strings you type into the picker on the spot; library tags are pre-defined entries you create once in Settings, optionally scoped to a subset of projects, and then re-apply to any session that fits.
The two systems are designed to coexist. A session can carry library applications and free-form tags at the same time. The renderer merges them into a single visual chip strip; the picker shows library suggestions in their own group above the free-form suggestions; deleting a library tag does not affect free-form tag strings of the same name.
The library is reached from Settings → Tags, a dedicated settings section showing every library tag in a table — search box, "Filter by project" pill, and a + New tag button. Each row shows the tag's color dot, name, scope (Global with a globe icon, or 1 project / N projects with the count), session count, description, and an edit / trash action pair on hover. Clicking + New tag or the pencil icon on a row opens the Add tag / Edit tag dialog; clicking the trash icon opens a confirm dialog that names the affected session count.
The same Add tag dialog is also reachable from the Tags virtual project's sidebar header — see tags-view.md "Tag sidebar". Both entry points open the same TagEditDialog component in mode="create" and share the same Zod validation, tags:create IPC, and tags:changed push fan-out, so a tag created from either surface shows up everywhere immediately.
The picker surface (the centered T-hotkey palette and the session overflow menu's "Tags…" row) is the same component as for free-form tags. With library tags configured, the suggestions popover gains a From library group above the free-form suggestions — picking a library row applies the tag to the current session via a backend join (no string is written to sessions.tags); picking a free-form row or typing-and-Enter creates a free-form tag the same way it always did. Library applications and free-form chips render side by side both inside the picker chip row and on the SessionPanel header.
Where chips actually appear in v1. As of this build, library and free-form tag chips render in one place only: the SessionPanel header chip strip, immediately under the title row of an open session. Sidebar session rows do not show tag chips or color dots — that surface was deliberately removed because it became too dense alongside the project color bar and status dot. To see a session's tags you must open it.
Where to find it
Settings → Tags — a table of every library tag with a search box, a project filter and + New tag. The same dialog opens from the Tags virtual project's sidebar header.
How it behaves
How to use it
Manage the library — Settings → Tags
- Open the library. Open Settings (gear icon, Settings virtual project, or Ctrl+,) and pick Tags in the left rail. The table lists every library tag with its color dot, name, scope, session count, and description.
- Add a tag. Click + New tag. The Add tag dialog asks for: a name (any non-empty string up to 30 characters — letters, digits, spaces, punctuation, mixed case all allowed); an optional description up to 200 characters; a color (or hash-derived default); and a scope — either Global (default — visible to every project) or Specific projects (a checkbox grid of your projects). Save creates the row and emits a push so every open client refreshes.
- Edit a tag. Click the pencil icon on a row. The same dialog opens with fields seeded; renaming, recoloring, rewriting the description, or moving the scope between Global and Specific projects all work. Saving emits a push that refreshes every client. The same dialog is also reachable from the Tags virtual project's right-click context menu (see tags-view.md) — Rename opens it focused on the name field, Change color… opens it focused on the color picker.
- Filter the table by project. Click the project filter pill above the table. With a project selected, the Sessions column switches from the global session count to the count within that scope, and the table hides any tag whose scope excludes that project.
- Delete a tag. Click the trash icon on the row. The confirm dialog reads "This removes the tag from N session(s). Free-form tag chips with the same name remain unaffected." Confirming hard-removes every application and soft-deletes the library row.
Apply, suggest, and remove — the picker
- Open the picker. With a session selected (composer not focused), press T — or use the session overflow menu's Tags… row. The centered modal palette opens with the input auto-focused.
- Apply from the library. Type a few letters; library matches appear in a From library group at the top of the suggestions list. Arrow-key down or click to highlight, Enter or click to apply. The tag's chip immediately renders both in the picker and (after closing) in the SessionPanel header.
- Add a free-form tag. Type a name that does not match any library entry; the Create yourtext row appears at the bottom of the popover. Enter creates a free-form tag exactly as before — no library row is touched.
- Remove a tag. Click the × on any chip. Library chips call the unapply IPC; free-form chips remove the string from
sessions.tags. With the input empty, Backspace removes the rightmost chip — same behavior as before. The Tags virtual project's right pane offers a second per-session unapply surface for library tags only — an × on each session row in the selected-tag list, with a toast + Undo (re-applies viatags:apply). See tags-view.md "Remove a tag from a single session". - Browse the full library. Click Browse all (N)… at the bottom of the suggestions list to swap to a checkbox grid of every visible library tag. Use the search box at the top to filter; check / uncheck to apply / unapply. Click the back-arrow to return to the type-and-create view.
How it works
Storage and the scope-by-derivation rule
Migration v127 in src/main/db/database.ts (lines 4247–4294) creates three new tables that coexist with the existing sessions.tags JSON column:
tags— the library entries. Columns:id(TEXT PK),name,description,color,icon,created_at(INTEGER ms),updated_at(INTEGER ms), andis_deleted(INTEGER, default0— soft-delete flag). A unique indexidx_tags_name_activeis built onLOWER(name) WHERE is_deleted = 0, which guarantees case-insensitive name uniqueness across the active set without blocking re-use of a deleted name.tag_project_scopes— the scope-mapping table. Each row is a(tag_id, project_id)pair. The absence of any row for tag T is itself the signal that T is global — there is noscopecolumn anywhere. A tag with zero scope rows is global by derivation; a tag with one or more scope rows is project-scoped to exactly those projects. Both foreign keys cascadeON DELETE: deleting a tag drops its scopes, deleting a project drops its scope rows for every tag that referenced it.session_tags— the application join. Columns:session_id,tag_id,applied_at(INTEGER ms — used to order chips in the renderer),applied_by(TEXT, default'user'—'user' | 'ai'today,'cli'reserved for v1.1). Composite primary key(session_id, tag_id). Both foreign keys cascadeON DELETE, so deleting a session or a tag immediately removes the application rows.
The LibraryTag type (defined in src/shared/tag-types.ts) exposes scope: 'global' | 'project' and projectIds: string[] to the renderer, but those are derived at the IPC layer — the backend joins against tag_project_scopes and computes scope based on whether any rows exist. Renderer code never asks the database for a stored scope column because there isn't one.
The IPC contract
All channel constants live in src/shared/ipc-channels/session.ts and follow the project's standard envelope ({ success: true, data } or { success: false, error }):
tags:list(TAGS_LIST) — list every active library tag, including derived scope, projectIds, and session counts.tags:create(TAG_CREATE) — insert a new library row plus its scope rows in one transaction.tags:update(TAG_UPDATE) — patch name / description / color / icon / scope; rewrites scope rows when scope changes.tags:delete(TAG_DELETE) — in one transaction, explicitlyDELETEs everysession_tagsrow for thattag_id(this is what actually scrubs applications —ON DELETE CASCADEdoes not fire on the soft-deleteUPDATE) and thenUPDATEs the library row tois_deleted = 1. The unique-name index ignores soft-deleted rows so the same name can be created again later.tags:visible-for-session(TAGS_VISIBLE_FOR_SESSION) — returns the library subset visible to a given session given its project's scope rows. Used by the picker to populate the From library suggestions group; described in detail under "Scope and visibility" below.tags:apply(TAG_APPLY) — insert a row intosession_tags. Enforces both the visibility rule (the tag must be visible for the session right now) and the combined 10-tag cap.tags:unapply(TAG_UNAPPLY) — delete a row fromsession_tags. Always allowed regardless of current visibility (this is the escape valve for sticky tags — see "Sticky behaviour" below).tags:applied-for-session(SESSION_TAGS_FOR_SESSION) — return everyTagApplicationrow for a given session, used to seed the renderer's per-session map.
Two push events keep open clients in sync without polling:
tags:changed(TAGS_CHANGED) — fires after any library mutation (create/update/delete). The payload includesaffectedSessionIdsso the renderer can invalidate the per-session caches it needs.tags:session-tags-changed(SESSION_TAGS_CHANGED) — fires after any application-level mutation (apply/unapply) for a single session.
The legacy session:delete-tag-globally (SESSION_DELETE_TAG_GLOBALLY) channel is unrelated and remains: it scrubs a free-form tag string from every session that holds it. It does not touch the library — deleting a library tag named feature does not remove free-form feature strings, and deleting a free-form feature globally does not delete the library row.
Renderer dedupe rule (library wins)
The SessionPanel header chip strip is built in src/renderer/src/features/sessions/SessionPanel.tsx (the mergedChips useMemo, lines 589–622, builds the merged list) and rendered in src/renderer/src/features/sessions/SessionPanel/SessionPanelHeader.tsx (lines 404–466). The merge runs in two passes:
- Library applications first — iterated in
applied_atascending order (earliest application leftmost). Each application is resolved against the loadedLibraryTagcatalog; if the tag row is missing (catalog still loading), the application is skipped — the next render with a loaded catalog will pick it up. - Free-form tags second — iterated in their
sessions.tagsJSON-array order.
Dedupe key is the trimmed, lowercased name. The first pass populates a taken Set; the second pass skips any free-form name whose lowercased+trimmed form is already in the Set. Library always wins — a session that carries both a library tag named feature and a free-form 'Feature' string renders only the library chip; the free-form chip is silently filtered out for that render. Empty merged list means the wrapper element is omitted entirely (no empty container DOM).
The chip × handlers are forked by chip kind. Library chips call the tags store's unapplyTag(sessionId, tagId) which invokes tags:unapply. Free-form chips call the existing handleUpdateTags path that filters the name out of sessions.tags and writes the new array back via the session-update IPC. The chip strip is gated by the same sessionTagsEnabled setting that gates the picker — turning the toggle off hides the strip without touching storage.
Scope and visibility (the "visible to a session" rule)
A library tag is visible to a session when at least one of:
- The tag is global (zero rows in
tag_project_scopesfor that tag), OR - The tag has a scope row matching the session's
projectId.
tags:visible-for-session returns exactly that filtered subset. Virtual / null projectId (the inbox-rooted picker, virtual projects without a real project record) falls back to global-only so the picker still renders something useful. The picker's From library suggestions, the Browse all grid, and the per-row session-count column in Settings → Tags all derive from this rule.
Sticky behaviour — narrowing scope is non-destructive
Scope edits are intentionally non-destructive at the application layer. When a tag's scope is narrowed — say, you change a previously-global tag to Specific projects with only project A and B selected, or you remove project C from an existing scope — tags:visible-for-session no longer returns that tag for sessions in project C.
But the existing session_tags rows for project-C sessions are not deleted. The chip continues to render in the SessionPanel header (sticky), the chip × still calls tags:unapply so the user can clear it manually, and the picker's From library suggestions for those sessions hide the now-out-of-scope tag so it cannot be re-applied via the picker on that session.
This guarantees a scope edit can never destroy already-recorded curation data. The user explicitly removes the chip when they decide that application no longer belongs there. The chip × is the escape valve for any sticky out-of-scope application.
Tag-name validation (permissive)
Library tag names are deliberately permissive — any non-empty string up to 30 characters is allowed. Mixed case, spaces, punctuation, and even the __ prefix that plugin code uses as a sentinel on sessions.tags all work. The single source of truth is TAG_NAME_REGEX from src/shared/tag-types.ts, shared between the backend Zod schema and the frontend dialog validation; the regex is /^.+$/ and the empty-name rejection is the only check it performs (length is enforced separately at the Zod boundary via .max(30)).
Free-form tags share the same rule and have always been permissive — __plugin__ was a legal free-form tag because plugin code writes it directly. The historical (?!__) lookahead on library names was scaffolding to prevent a library tag from collapsing onto the __plugin__ sentinel via the renderer dedupe. That concern doesn't materialise: library tags don't write into sessions.tags at all (they live in the session_tags join table), and plugin-detection code reads the JSON column directly. The worst-case outcome of a user creating a library tag named __plugin__ and applying it to a plugin session is cosmetic — the library chip and the sentinel chip would collapse to one in the header strip — not data corruption.
The frontend dialog displays Tag name cannot be empty. (exported as TAG_NAME_VALIDATION_MESSAGE) inline if the trimmed name is empty. The backend Zod's matching message is 'tag name cannot be empty'. The legacy 'TAG_NAME_RESERVED' code remains in the TagError union ('TAG_NAME_TAKEN' | 'TAG_CAP_REACHED' | 'TAG_NOT_VISIBLE' | 'INVALID_SCOPE' | 'TAG_NAME_RESERVED') for backwards compatibility but is no longer reachable from the validation path.
Combined 10-tag cap
The 10-tag cap is combined, not additive. Backend applyTagToSession and frontend cap-enforcement count library applications and free-form tag strings together, and 'TAG_CAP_REACHED' fires when the combined count would exceed 10. A session with 5 library applications and 5 free-form tags is full; trying to apply or create an 11th of either kind fails the same way.
Per-tag length: free-form tags cap at 30 characters at the input layer (browser stops accepting input). Library tag names cap via the Zod schema attached to tags:create / tags:update and via the regex.
Deletion semantics
Three deletion paths exist and they are independent:
- Delete a library tag (Settings → Tags → trash icon → confirm dialog).
softDeleteTagruns in a single transaction:SELECT DISTINCT session_id FROM session_tags WHERE tag_id = ?(for the post-mutation TAGS_CHANGED fan-out), thenDELETE FROM session_tags WHERE tag_id = ?(this is what actually scrubs the applications —ON DELETE CASCADEonsession_tags.tag_idwould not fire on the soft-deleteUPDATEalone), thenUPDATE tags SET is_deleted = 1 ... WHERE id = ? AND is_deleted = 0. The cascade FK is belt-and-suspenders for the never-used hard-delete path. Net effect: every application across every session is removed — that is why the confirm dialog reads "This removes the tag from N session(s)." Free-formsessions.tagsstrings of the same name are untouched. The library row itself stays in the database (soft-deleted) so audit history is preserved. - Project soft-delete cascade. When a project is soft-deleted,
deleteProjectexplicitlyDELETEs everytag_project_scopesrow whoseproject_idmatches, in the same transaction as the project'sis_deleted = 1UPDATE. (TheON DELETE CASCADEFK ontag_project_scopes.project_idis again belt-and-suspenders — it would only fire on a hardDELETE, not on the soft-deleteUPDATE.) A tag that had been scoped to two projects now has only one scope row — and if the deleted project was its only scope, the tag becomes global by derivation. - Free-form tag deletion. Removing a free-form chip via × in the picker mutates
sessions.tagsfor that session only. Removing a free-form tag globally viasession:delete-tag-globallyscrubs the string from every session that holds it. Neither path affects the library.
Sidebar groups — anchoring a tag to the session list
A library tag can be anchored: it then renders as its own collapsible section in the session sidebar, listing the current project's sessions that carry it. The anchor is the tag's show_in_sidebar flag.
- Create AND anchor from the sidebar. The foot of the sidebar's group sections carries an Add group row, which opens a menu with two ways in: New group… (the same
TagEditDialogin create mode, group toggle already on) and a search box listing the tags you already have but have not anchored — click one and it becomes a group. Only tags that will actually show a section in the project you are looking at are offered, and never one you have already anchored. Enter anchors the first match, but only once something has been typed. Each group header carries a ⋯ menu: Edit group…, Move up, Move down, Remove from sidebar. Removing clears only the anchor — the tag, its scope and every application survive, so it can be switched straight back on. - An anchored group shows even when it is empty, with a "No sessions tagged yet" note. A group is something you anchored on purpose, so a new one is visible immediately instead of vanishing until something is tagged with it. The built-in sidebar sections (Scheduled, Snoozed, Saved) still hide when empty.
- Groups are opt-in per tag. Nothing is promoted automatically, so a library of 63 tags with no anchors adds no sections.
- Grouped sessions are an OVERLAY. A session in a group still appears in its normal Live / Paused / … section; grouping never pulls a row out of the status list.
- Your own order.
⋯→ Move up / Move down reorders the groups, and the order is saved on thesidebarGroupOrdersetting, so it survives a restart and matches on the phone. The order is a sorting hint only — it can never add or remove a section, and a stored id whose tag is not anchored produces nothing. - Scope applies as it does everywhere else. A global tag's group appears in every project; a project-scoped one appears only in its own projects.
- Gated on the same Settings → Sessions → Session Tags toggle. With it off, no group sections and no New group row appear anywhere.
- Not built: a per-project group order, and drag-and-drop reordering (Move up / Move down is keyboard- and touch-friendly, which dragging a scrolling sidebar is not).
UI surface citations
For agents wiring or debugging the UI:
- src/renderer/src/features/settings/TagSettings.tsx — the Settings → Tags management view (search input, project filter pill, "+ New tag" button, tag table with per-row edit / delete actions). Confirm dialog text and per-row session-count rendering live here.
- src/renderer/src/features/settings/TagEditDialog.tsx — the Add tag / Edit tag dialog. Wraps
DialogShell. SharesTAG_NAME_REGEXvalidation with the backend; saves call the tags-store actions which invoketags:create/tags:update. - src/renderer/src/components/ui/TagPicker.tsx — the centered modal palette. The library prop API (
libraryTags,appliedLibraryTagIds,onApplyLibraryTag,onUnapplyLibraryTag,onOpenManager) is what wires the From library group above the free-form suggestions and what powers chip × on library chips. - src/renderer/src/components/ui/TagPaletteGate.tsx — the global tag-palette store consumer. On open it triggers
loadTagsandloadApplicationsForSession, computes the visible subset via the'global' || projectIds.includes(projectId)predicate, and threads apply / unapply / openManager callbacks through to TagPicker. - src/renderer/src/features/sessions/SessionPanel.tsx — the SessionPanel header chip strip. The
mergedChipsuseMemo (lines 589–622) builds the merged list with library-first ordering and dedupe; SessionPanel/SessionPanelHeader.tsx lines 404–466 render the chips with × handlers forked by kind.
Out of scope (v1)
These were considered and deferred to a later iteration:
- AI auto-tag. No automation rule action applies a library tag based on agent output, channel content, or any other signal. Library applications today are always user-driven (
applied_by = 'user') or agent-driven via a tool the agent already has (applied_by = 'ai'). The'cli'value ofapplied_byis reserved but not yet written — the CLI routes shipped in v1.1 record applications as'user'to mirror in-app picker behaviour. See cli-tags.md. - Promote-banner. No UX surfaces a "this free-form tag exists on N sessions, promote it to a library tag?" prompt. Promotion is a manual workflow today: create the library tag in Settings, optionally clean up the free-form occurrences via global delete, then re-apply the library tag where you want it.
- A per-project group order. The order is global; within any project the relative order of its own groups is preserved.
Related
- session-tags.md — the free-form per-session tag system the library coexists with; the T picker, the type-and-Enter create flow, and the deterministic 8-color palette all originate there
- cli-tags.md — manage the library and apply/unapply tags from an external AI agent via the localhost CLI control server (7 routes; library mutations approval-gated, per-session apply/unapply immediate)
- bulk-select-sidebar.md — Shift+J/K multi-select for batch operations on tagged sessions
- snooze-a-session.md — another session-organisation primitive surfaced through the same overflow menu
Last verified 2026-10-01