---
title: Tag manager (curated tag library)
---
# Tag manager (curated tag library)

## 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](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](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

1. **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.
2. **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.
3. **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](tags-view.md)) — **Rename** opens it focused on the name field, **Change color…** opens it focused on the color picker.
4. **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.
5. **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

1. **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.
2. **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.
3. **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.
4. **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 via `tags:apply`). See [tags-view.md](tags-view.md) "Remove a tag from a single session".
5. **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](../../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), and `is_deleted` (INTEGER, default `0` — soft-delete flag). A unique index `idx_tags_name_active` is built on `LOWER(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 no `scope` column 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 cascade `ON 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 cascade `ON DELETE`, so deleting a session or a tag immediately removes the application rows.

The `LibraryTag` type (defined in [src/shared/tag-types.ts](../../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](../../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, explicitly `DELETE`s every `session_tags` row for that `tag_id` (this is what actually scrubs applications — `ON DELETE CASCADE` does not fire on the soft-delete `UPDATE`) and then `UPDATE`s the library row to `is_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 into `session_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 from `session_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 every `TagApplication` row 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 includes `affectedSessionIds` so 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](../../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](../../src/renderer/src/features/sessions/SessionPanel/SessionPanelHeader.tsx) (lines 404–466). The merge runs in two passes:

1. **Library applications first** — iterated in `applied_at` ascending order (earliest application leftmost). Each application is resolved against the loaded `LibraryTag` catalog; if the tag row is missing (catalog still loading), the application is skipped — the next render with a loaded catalog will pick it up.
2. **Free-form tags second** — iterated in their `sessions.tags` JSON-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_scopes` for 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](../../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).** [`softDeleteTag`](../../src/main/db/queries-tags.ts) runs in a single transaction: `SELECT DISTINCT session_id FROM session_tags WHERE tag_id = ?` (for the post-mutation TAGS_CHANGED fan-out), then `DELETE FROM session_tags WHERE tag_id = ?` (this is what actually scrubs the applications — `ON DELETE CASCADE` on `session_tags.tag_id` would not fire on the soft-delete `UPDATE` alone), then `UPDATE 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-form `sessions.tags` strings 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, [`deleteProject`](../../src/main/db/queries-projects/projects.ts) explicitly `DELETE`s every `tag_project_scopes` row whose `project_id` matches, in the same transaction as the project's `is_deleted = 1` `UPDATE`. (The `ON DELETE CASCADE` FK on `tag_project_scopes.project_id` is again belt-and-suspenders — it would only fire on a hard `DELETE`, not on the soft-delete `UPDATE`.) 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.tags` for that session only. Removing a free-form tag globally via `session:delete-tag-globally` scrubs 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 `TagEditDialog` in 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 the `sidebarGroupOrder` setting, 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](../../src/renderer/src/features/settings/sections/tags/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](../../src/renderer/src/features/settings/TagEditDialog.tsx) — the **Add tag** / **Edit tag** dialog. Wraps `DialogShell`. Shares `TAG_NAME_REGEX` validation with the backend; saves call the tags-store actions which invoke `tags:create` / `tags:update`.
- [src/renderer/src/components/ui/TagPicker.tsx](../../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](../../src/renderer/src/components/ui/TagPaletteGate.tsx) — the global tag-palette store consumer. On open it triggers `loadTags` and `loadApplicationsForSession`, 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](../../src/renderer/src/features/sessions/SessionPanel.tsx) — the SessionPanel header chip strip. The `mergedChips` useMemo (lines 589–622) builds the merged list with library-first ordering and dedupe; [SessionPanel/SessionPanelHeader.tsx](../../src/renderer/src/features/sessions/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 of `applied_by` is 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](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](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](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](bulk-select-sidebar.md) — Shift+J/K multi-select for batch operations on tagged sessions
- [snooze-a-session.md](snooze-a-session.md) — another session-organisation primitive surfaced through the same overflow menu
