diff --git a/.changeset/fn-8562-chat-conversation-tags.md b/.changeset/fn-8562-chat-conversation-tags.md new file mode 100644 index 0000000000..dc3af64fe1 --- /dev/null +++ b/.changeset/fn-8562-chat-conversation-tags.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Organize Direct chat conversations with reusable tags and sidebar filtering. +category: feature +dev: Tags are project-scoped and retain conversations when deleted. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bb6c0a7e84..2333f74211 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2270,3 +2270,7 @@ Custom workflow authors can add optional explanatory copy beneath each column na ## Planning Mode contextual comments In plan review, select text inside the rendered plan and choose **Add comment to selection**. On mobile widths through 768px, the selection action appears in the bottom plan-action rail beside **Refine** and **Proceed with plan**; at 769px and wider it stays beside the selected plan content. Enter a suggestion to capture the selected quote and suggestion as a pending contextual comment. You can remove individual comments before choosing **Submit comments**; Fusion sends the ordered batch through the existing Planning Mode revision generation, so the agent revises the quoted areas while preserving unaffected plan content. A successful revised-plan update clears the batch; a failed submission retains it for retry. + +### Conversation tags + +Direct conversations can be organized with reusable tags. Open a conversation's **More** menu to create a tag or toggle its assignments; a conversation can have multiple tags. Use the tag selector beside conversation search to filter pinned and recent conversations without affecting text search. Tags are project-scoped, and deleting a tag only removes its assignments—it never deletes conversations or messages. Chat Rooms do not use conversation tags. diff --git a/docs/storage.md b/docs/storage.md index 3edc1105d9..47e951bffd 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -801,3 +801,5 @@ exists on disk. Configuration changes are immutable `project.configuration_revisions` snapshots. Rows are partitioned by `(project_id, id)` and address resources with structured JSON targets plus a canonical JSON target key; history reads are newest-first by owner, kind, and target. A database identity sequence deterministically breaks same-millisecond timestamp ties. Project settings, workflow setting values, routine definitions, and automation definitions record their PostgreSQL mutations within the same transaction, so a failed revision insert rolls back the configuration write. The legacy SQLite writers reject versioned configuration mutations before side effects: accepting a write without an atomically durable revision would violate the rollback contract. User-global `~/.fusion/settings.json` history uses the reserved `__fusion_global_configuration__` owner identity rather than the project that initiated the write. Filesystem writes are serialized and stage a durable revision-intent file before replacing settings; a later mutation reconciles an interrupted intent by completing its journal append or restoring the old snapshot. When its revision append fails, the store restores the pre-write raw settings file before rejecting. `TaskStore.rollbackConfiguration()` exactly restores project/global/workflow snapshots; `RoutineStore` and `AutomationStore` expose the same rollback action for their stable-ID resources. Each rollback includes deletion/recreation semantics and appends exactly one forward revision marked `source: "rollback"`, rather than modifying history. + +Direct chat tags are stored in the project PostgreSQL schema as `chat_tags` and `chat_session_tags`. Tags are normalized and project-scoped; assignment cleanup does not delete chat sessions. diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index abdfda824d..1fb616a4e0 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -79,6 +79,7 @@ import { TASK_WEDGE_NOTIFICATION_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION, MISSION_LINEAGE_STOP_VERSION, + CHAT_SESSION_TAGS_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -665,7 +666,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", // + 1 task_verification_requests + 1 durable symbol_locks table (FN-8305) // + 1 mission_lineage_stops (FNXC:MissionLineageBudget FN-8543 / migration 0035). // Plugin tables are added separately by the hook. - expect(bySchema.project).toBe(96); + expect(bySchema.project).toBe(98); expect(bySchema.central).toBe(18); expect(bySchema.archive).toBe(1); }); @@ -1630,6 +1631,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_WEDGE_NOTIFICATION_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION, MISSION_LINEAGE_STOP_VERSION, + CHAT_SESSION_TAGS_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1691,6 +1693,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_WEDGE_NOTIFICATION_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION, MISSION_LINEAGE_STOP_VERSION, + CHAT_SESSION_TAGS_VERSION, ]); }); @@ -1885,6 +1888,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_WEDGE_NOTIFICATION_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION, MISSION_LINEAGE_STOP_VERSION, + CHAT_SESSION_TAGS_VERSION, ]); }); @@ -1960,6 +1964,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_WEDGE_NOTIFICATION_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION, MISSION_LINEAGE_STOP_VERSION, + CHAT_SESSION_TAGS_VERSION, ]); }); @@ -2035,6 +2040,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_WEDGE_NOTIFICATION_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION, MISSION_LINEAGE_STOP_VERSION, + CHAT_SESSION_TAGS_VERSION, ]); }); }); diff --git a/packages/core/src/async-chat-store.ts b/packages/core/src/async-chat-store.ts index 4182df6a4a..0af301acd0 100644 --- a/packages/core/src/async-chat-store.ts +++ b/packages/core/src/async-chat-store.ts @@ -16,6 +16,7 @@ * flip. These helpers are the async target the PostgreSQL integration tests * consume. */ +import { randomUUID } from "node:crypto"; import { and, asc, desc, eq, gt, ilike, inArray, isNull, lte, ne, or as orFn, sql as drizzleSql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; @@ -31,6 +32,9 @@ import type { ChatRoomStatus, ChatSession, ChatSessionStatus, + ChatTag, + ChatTagCreateInput, + ChatTagUpdateInput, RoomMemberRole, } from "./chat-types.js"; @@ -39,9 +43,14 @@ type QueryHandle = AsyncDataLayer["db"] | DbTransaction; // ── Row → Entity converters ── +function rowToTag(row: Record): ChatTag { + return { id: row.id as string, projectId: (row.ownerProjectId as string) === "__default__" ? null : row.ownerProjectId as string, name: row.name as string, createdAt: row.createdAt as string, updatedAt: row.updatedAt as string }; +} + function rowToSession(row: Record): ChatSession { return { id: row.id as string, + tags: [], agentId: row.agentId as string, title: (row.title as string | null) ?? null, status: row.status as ChatSessionStatus, @@ -147,7 +156,7 @@ export async function getChatSession(handle: QueryHandle, id: string): Promise 0 ? await query.where(and(...conditions)) : await query; - return rows.map(rowToSession); + return attachTagsToSessions(handle, rows.map(rowToSession)); } /** * Delete a chat session by id. Returns true if a row was deleted. */ export async function deleteChatSession(handle: QueryHandle, id: string): Promise { + const [session] = await handle.select({ projectId: schema.project.chatSessions.projectId }) + .from(schema.project.chatSessions).where(eq(schema.project.chatSessions.id, id)); + if (!session) return false; + const sessionProjectId = session.projectId; + if (!sessionProjectId) throw new Error("Chat session is missing its required project partition"); + // FNXC:ChatTags 2026-08-05-12:15: cleanup retains the session's RLS partition even for bypass/admin handles; an unqualified ID could delete another project's assignment. + await handle.delete(schema.project.chatSessionTags).where(and( + eq(schema.project.chatSessionTags.sessionId, id), + eq(schema.project.chatSessionTags.projectId, sessionProjectId), + )); const result = await handle .delete(schema.project.chatSessions) - .where(eq(schema.project.chatSessions.id, id)) + .where(and(eq(schema.project.chatSessions.id, id), eq(schema.project.chatSessions.projectId, sessionProjectId))) .returning({ id: schema.project.chatSessions.id }); return result.length > 0; } +function tagScope(projectId: string | null | undefined): string { return projectId ?? "__default__"; } +function normalizeTagName(name: string): { name: string; normalizedName: string } { + const cleaned = name.trim().replace(/\s+/g, " "); + if (!cleaned || cleaned.length > 64) throw new Error("Tag name must be between 1 and 64 characters"); + return { name: cleaned, normalizedName: cleaned.toLocaleLowerCase() }; +} + +async function attachTags(handle: QueryHandle, session: ChatSession): Promise { + const [attached] = await attachTagsToSessions(handle, [session]); + return attached ?? session; +} + +async function attachTagsToSessions(handle: QueryHandle, sessions: ChatSession[]): Promise { + if (!sessions.length) return sessions; + const rows = await handle.select({ sessionId: schema.project.chatSessionTags.sessionId, id: schema.project.chatTags.id, ownerProjectId: schema.project.chatTags.ownerProjectId, name: schema.project.chatTags.name, createdAt: schema.project.chatTags.createdAt, updatedAt: schema.project.chatTags.updatedAt }) + .from(schema.project.chatSessionTags).innerJoin(schema.project.chatTags, and( + eq(schema.project.chatSessionTags.tagId, schema.project.chatTags.id), + eq(schema.project.chatSessionTags.projectId, schema.project.chatTags.projectId), + )) + .innerJoin(schema.project.chatSessions, and( + eq(schema.project.chatSessionTags.sessionId, schema.project.chatSessions.id), + eq(schema.project.chatSessionTags.projectId, schema.project.chatSessions.projectId), + )) + .where(inArray(schema.project.chatSessionTags.sessionId, sessions.map((session) => session.id))).orderBy(asc(schema.project.chatTags.normalizedName), asc(schema.project.chatTags.id)); + const tagsBySession = new Map(); + for (const row of rows) { const tags = tagsBySession.get(row.sessionId) ?? []; tags.push(rowToTag(row)); tagsBySession.set(row.sessionId, tags); } + return sessions.map((session) => ({ ...session, tags: tagsBySession.get(session.id) ?? [] })); +} + +/** FNXC:ChatTags 2026-08-05-10:55: Tags normalize whitespace/case and are always read in deterministic name order. */ +export async function listChatTags(handle: QueryHandle, projectId: string | null): Promise { + const rows = await handle.select().from(schema.project.chatTags).where(eq(schema.project.chatTags.ownerProjectId, tagScope(projectId))).orderBy(asc(schema.project.chatTags.normalizedName), asc(schema.project.chatTags.id)); + return rows.map(rowToTag); +} + +export async function createChatTag(layer: AsyncDataLayer, input: ChatTagCreateInput): Promise { + const { name, normalizedName } = normalizeTagName(input.name); const projectId = input.projectId ?? null; const now = new Date().toISOString(); + return layer.transactionImmediate(async (tx) => { + const id = `chat-tag-${randomUUID().slice(0, 8)}`; + const rows = await tx.insert(schema.project.chatTags).values({ id, ownerProjectId: tagScope(projectId), name, normalizedName, createdAt: now, updatedAt: now }).onConflictDoNothing().returning(); + if (!rows[0]) throw new Error("A tag with that name already exists"); + return rowToTag(rows[0]); + }); +} + +export async function renameChatTag(layer: AsyncDataLayer, id: string, projectId: string | null, input: ChatTagUpdateInput): Promise { + const { name, normalizedName } = normalizeTagName(input.name); + return layer.transactionImmediate(async (tx) => { const rows = await tx.update(schema.project.chatTags).set({ name, normalizedName, updatedAt: new Date().toISOString() }).where(and(eq(schema.project.chatTags.id, id), eq(schema.project.chatTags.ownerProjectId, tagScope(projectId)))).returning(); if (!rows[0]) return undefined; return rowToTag(rows[0]); }); +} + +export async function deleteChatTag(layer: AsyncDataLayer, id: string, projectId: string | null): Promise { + return layer.transactionImmediate(async (tx) => { + const [tag] = await tx.select({ projectId: schema.project.chatTags.projectId }) + .from(schema.project.chatTags) + .where(and(eq(schema.project.chatTags.id, id), eq(schema.project.chatTags.ownerProjectId, tagScope(projectId)))); + if (!tag?.projectId) return false; + const tagProjectId = tag.projectId; + // FNXC:ChatTags 2026-08-05-12:15: validate the scoped parent before cleanup; bypass handles must not erase a same-ID tag assignment in another partition. + await tx.delete(schema.project.chatSessionTags).where(and( + eq(schema.project.chatSessionTags.tagId, id), + eq(schema.project.chatSessionTags.projectId, tagProjectId), + )); + return (await tx.delete(schema.project.chatTags) + .where(and(eq(schema.project.chatTags.id, id), eq(schema.project.chatTags.projectId, tagProjectId))) + .returning({ id: schema.project.chatTags.id })).length > 0; + }); +} + +export async function replaceChatSessionTags(layer: AsyncDataLayer, sessionId: string, projectId: string | null, tagIds: string[]): Promise { + return layer.transactionImmediate(async (tx) => { + const session = await getChatSessionForUpdate(tx, sessionId); if (!session || session.projectId !== projectId) return undefined; + const ownerProjectCondition = projectId === null + ? isNull(schema.project.chatSessions.ownerProjectId) + : eq(schema.project.chatSessions.ownerProjectId, projectId); + const [sessionPartition] = await tx.select({ projectId: schema.project.chatSessions.projectId }).from(schema.project.chatSessions) + .where(and(eq(schema.project.chatSessions.id, sessionId), ownerProjectCondition)).for("update"); + if (!sessionPartition?.projectId) return undefined; + const sessionProjectId = sessionPartition.projectId; + const uniqueIds = [...new Set(tagIds)]; + const tags = uniqueIds.length ? await tx.select().from(schema.project.chatTags).where(and( + inArray(schema.project.chatTags.id, uniqueIds), + eq(schema.project.chatTags.ownerProjectId, tagScope(projectId)), + eq(schema.project.chatTags.projectId, sessionProjectId), + )) : []; + if (tags.length !== uniqueIds.length) throw new Error("One or more tags do not belong to this project"); + // FNXC:ChatTags 2026-08-05-12:15: replacements are scoped by the locked session partition, preserving project isolation when the DB bypass role is active. + await tx.delete(schema.project.chatSessionTags).where(and(eq(schema.project.chatSessionTags.sessionId, sessionId), eq(schema.project.chatSessionTags.projectId, sessionProjectId))); + if (uniqueIds.length) await tx.insert(schema.project.chatSessionTags).values(uniqueIds.map((tagId) => ({ projectId: sessionProjectId, sessionId, tagId, assignedAt: new Date().toISOString() }))).onConflictDoNothing(); + return attachTags(tx, session); + }); +} + // ── Message CRUD ── /** diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 4141dd8f60..ccd58dd27c 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -16,6 +16,9 @@ import * as schema from "./postgres/schema/index.js"; import * as asyncChatStore from "./async-chat-store.js"; import type { ChatSession, + ChatTag, + ChatTagCreateInput, + ChatTagUpdateInput, ChatSessionStatus, ChatMessage, ChatAttachment, @@ -111,6 +114,7 @@ export class ChatStore extends EventEmitter { const session: ChatSession = { id: `chat-${randomUUID().slice(0, 8)}`, agentId: input.agentId, + tags: [], title: input.title ?? null, status: "active", projectId: input.projectId ?? null, @@ -315,6 +319,34 @@ export class ChatStore extends EventEmitter { return deletedCount; } + /* + FNXC:ChatTags 2026-08-05-10:55: + Direct-conversation tags are project-scoped by the session's owner project. + Tag mutations are transactional and return an enriched session so SSE clients + never receive an assignment update without its deterministic tag array. + */ + async listTags(projectId: string | null = null): Promise { + return asyncChatStore.listChatTags(this.asyncLayer.db, projectId); + } + + async createTag(input: ChatTagCreateInput): Promise { + return asyncChatStore.createChatTag(this.asyncLayer, input); + } + + async renameTag(id: string, projectId: string | null, input: ChatTagUpdateInput): Promise { + return asyncChatStore.renameChatTag(this.asyncLayer, id, projectId, input); + } + + async deleteTag(id: string, projectId: string | null): Promise { + return asyncChatStore.deleteChatTag(this.asyncLayer, id, projectId); + } + + async replaceSessionTags(id: string, projectId: string | null, tagIds: string[]): Promise { + const updated = await asyncChatStore.replaceChatSessionTags(this.asyncLayer, id, projectId, tagIds); + if (updated) this.emit("chat:session:updated", updated); + return updated; + } + // ── Message CRUD Operations ─────────────────────────────────────── /** diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index dc7625558c..469417c5df 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -36,6 +36,15 @@ export interface ChatInFlightGenerationState { updatedAt: string; } +export interface ChatTag { + id: string; + /** Owning project; `null` represents the canonical default project scope. */ + projectId: string | null; + name: string; + createdAt: string; + updatedAt: string; +} + export interface ChatSession { id: string; /** Session routing kind; legacy sessions default to direct */ @@ -46,6 +55,8 @@ export interface ChatSession { roomName?: string | null; /** ID of the agent participating in this session */ agentId: string; + /** Deterministically name-sorted Direct-conversation tags. Always present. */ + tags: ChatTag[]; /** Human-readable title for the session (optional, can be auto-generated) */ title: string | null; /** Current status of the session */ @@ -231,6 +242,9 @@ export interface ChatSessionCreateInput { * Input for updating a chat session. * All fields are optional; only provided fields are updated. */ +export interface ChatTagCreateInput { name: string; projectId?: string | null; } +export interface ChatTagUpdateInput { name: string; } + export interface ChatSessionUpdateInput { /** New session title */ title?: string | null; diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 9ccd8d950f..978a0e4573 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -2148,6 +2148,9 @@ export type { export type { ChatSessionStatus, + ChatTag, + ChatTagCreateInput, + ChatTagUpdateInput, ChatMessageRole, ChatInFlightToolCall, ChatInFlightGenerationState, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d76a90c9c7..721864a964 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2330,6 +2330,9 @@ export type { export type { ChatSessionStatus, + ChatTag, + ChatTagCreateInput, + ChatTagUpdateInput, ChatMessageRole, ChatInFlightToolCall, ChatInFlightGenerationState, diff --git a/packages/core/src/postgres/migrations/0036_chat_session_tags.sql b/packages/core/src/postgres/migrations/0036_chat_session_tags.sql new file mode 100644 index 0000000000..8bdcf4ad86 --- /dev/null +++ b/packages/core/src/postgres/migrations/0036_chat_session_tags.sql @@ -0,0 +1,64 @@ +-- FNXC:ChatTags 2026-08-05-10:55: +-- Direct conversation tags have an explicit project scope and normalized name so +-- whitespace/case variants cannot split one reusable category. Assignment rows +-- cascade with either parent; store transactions additionally prove a tag and +-- session share the same scope before writing. +CREATE TABLE IF NOT EXISTS project.chat_tags ( + id text NOT NULL, + project_id text NOT NULL DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), + owner_project_id text NOT NULL DEFAULT '__default__', + name text NOT NULL, + normalized_name text NOT NULL, + created_at text NOT NULL, + updated_at text NOT NULL, + CONSTRAINT chat_tags_normalized_name_nonempty CHECK (length(normalized_name) > 0), + PRIMARY KEY (project_id, id), + CONSTRAINT uq_chat_tags_scope_name UNIQUE (project_id, normalized_name) +); +CREATE INDEX IF NOT EXISTS idx_chat_tags_scope_name ON project.chat_tags(project_id, normalized_name); + +-- Some upgrade-recovery fixtures materialize only a partial historical schema. +-- Do not stamp a broken assignment table before its session parent exists; a normal +-- 0000 upgrade always has chat_sessions and receives both cascading constraints. +DO $$ +BEGIN + IF to_regclass('project.chat_sessions') IS NOT NULL THEN + -- FNXC:ChatTags 2026-08-05-12:15: legacy sessions use `id` as their primary + -- key, so establish the project/id candidate key before scoped assignments + -- reference it. The global ID primary key keeps this addition safe for rows + -- already present on upgraded clusters. + ALTER TABLE project.chat_sessions + ADD CONSTRAINT chat_sessions_project_id_id_key UNIQUE (project_id, id); + + CREATE TABLE IF NOT EXISTS project.chat_session_tags ( + session_id text NOT NULL, + tag_id text NOT NULL, + project_id text NOT NULL DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'), + assigned_at text NOT NULL, + PRIMARY KEY (project_id, session_id, tag_id), + CONSTRAINT chat_session_tags_session_fk FOREIGN KEY (project_id, session_id) + REFERENCES project.chat_sessions(project_id, id) ON DELETE CASCADE, + CONSTRAINT chat_session_tags_tag_fk FOREIGN KEY (project_id, tag_id) + REFERENCES project.chat_tags(project_id, id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_chat_session_tags_tag ON project.chat_session_tags(project_id, tag_id, session_id); + END IF; +END $$; + +ALTER TABLE project.chat_tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE project.chat_tags FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS fusion_project_isolation ON project.chat_tags; +CREATE POLICY fusion_project_isolation ON project.chat_tags USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); +DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.chat_tags; +CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.chat_tags FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); +DO $$ +BEGIN + IF to_regclass('project.chat_session_tags') IS NOT NULL THEN + ALTER TABLE project.chat_session_tags ENABLE ROW LEVEL SECURITY; + ALTER TABLE project.chat_session_tags FORCE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS fusion_project_isolation ON project.chat_session_tags; + CREATE POLICY fusion_project_isolation ON project.chat_session_tags USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); + DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.chat_session_tags; + CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.chat_session_tags FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); + END IF; +END $$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 46bdd304a9..effad5e815 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -42,7 +42,7 @@ SCHEMA_BASELINE_VERSION advances to 0031 for durable, single-owner task continuations at workflow column boundaries. FNXC:LegacyAdoption 2026-07-21-17:30: -SCHEMA_BASELINE_VERSION advances to 0032 for fusion_runtime SELECT + +SCHEMA_BASELINE_VERSION advances to 0036 for normalized Direct conversation tags; it previously advanced to 0032 for fusion_runtime SELECT + SECURITY DEFINER write access to the legacy-adoption drained marker. FNXC:TaskWedgeNotifications 2026-10-19-00:00: @@ -50,7 +50,7 @@ Advance the PostgreSQL schema ceiling for the durable wedge episode column. The forward migration must run before TaskStore writes the new field on fresh and upgraded databases. */ -export const SCHEMA_BASELINE_VERSION = "0035"; +export const SCHEMA_BASELINE_VERSION = "0036"; /** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -150,6 +150,8 @@ export const TASK_WEDGE_NOTIFICATION_VERSION = "0033"; export const MILESTONE_ASSERTION_PROVENANCE_VERSION = "0034"; /** FNXC:MissionLineageBudget 2026-07-22-12:00: migration is explicit because upgraded clusters need durable root stop tombstones. */ export const MISSION_LINEAGE_STOP_VERSION = "0035"; +/** FNXC:ChatTags 2026-08-05-10:55: existing clusters need normalized project-scoped Direct conversation tags. */ +export const CHAT_SESSION_TAGS_VERSION = "0036"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -359,6 +361,7 @@ const MILESTONE_ASSERTION_PROVENANCE_MIGRATION_PATH = join( "0034_milestone_assertion_provenance.sql", ); const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_mission_lineage_stop.sql"); +const CHAT_SESSION_TAGS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0036_chat_session_tags.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -464,6 +467,7 @@ export async function applySchemaBaseline( const taskWedgeNotificationAlreadyApplied = applied.includes(TASK_WEDGE_NOTIFICATION_VERSION); const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION); const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION); + const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -977,6 +981,14 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* FNXC:ChatTags 2026-08-05-10:55: migrations are explicitly registered; this must run after the baseline on both fresh and upgrade databases. */ + if (!chatSessionTagsAlreadyApplied) { + const migrationSql = await readFile(CHAT_SESSION_TAGS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CHAT_SESSION_TAGS_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index fd1c656720..c3b6104281 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1895,6 +1895,40 @@ export const chatSessions = projectSchema.table("chat_sessions", { }, (t) => [ index("idxChatSessionsAgentId").on(t.agentId), index("idxChatSessionsProjectId").on(t.projectId), + uniqueIndex("uqChatSessionsProjectIdId").on(t.projectId, t.id), +]); + +/* +FNXC:ChatTags 2026-08-05-10:55: +Tags deliberately retain an owner scope independent of the RLS partition. The +canonical `__default__` scope makes nullable legacy session project IDs unique +without relying on PostgreSQL's NULL-unique behavior. +*/ +export const chatTags = projectSchema.table("chat_tags", { + id: text("id").notNull(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + ownerProjectId: text("owner_project_id").notNull(), + name: text("name").notNull(), + normalizedName: text("normalized_name").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), + uniqueIndex("uqChatTagsScopeName").on(t.projectId, t.normalizedName), + index("idxChatTagsScopeName").on(t.projectId, t.normalizedName), +]); + +export const chatSessionTags = projectSchema.table("chat_session_tags", { + sessionId: text("session_id").notNull(), + tagId: text("tag_id").notNull(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + assignedAt: text("assigned_at").notNull(), +}, (t) => [ + // FNXC:ChatTags 2026-08-05-12:15: both parents include the RLS partition, preventing bypass/admin access from joining a same-named ID in another project. + primaryKey({ columns: [t.projectId, t.sessionId, t.tagId] }), + foreignKey({ columns: [t.projectId, t.sessionId], foreignColumns: [chatSessions.projectId, chatSessions.id] }).onDelete("cascade"), + foreignKey({ columns: [t.projectId, t.tagId], foreignColumns: [chatTags.projectId, chatTags.id] }).onDelete("cascade"), + index("idxChatSessionTagsTag").on(t.projectId, t.tagId, t.sessionId), ]); export const cliSessions = projectSchema.table("cli_sessions", { diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 05bae20164..2de05fdb6c 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -5,5 +5,6 @@ * while implementation lives under `app/api/*` modules. */ export * from "./api/legacy"; +export * from "./api/chat"; export * from "./api-node"; export * from "./api/report"; diff --git a/packages/dashboard/app/api/chat.ts b/packages/dashboard/app/api/chat.ts index 9e3c56133c..7cb88c57ed 100644 --- a/packages/dashboard/app/api/chat.ts +++ b/packages/dashboard/app/api/chat.ts @@ -9,6 +9,7 @@ import type { ChatRoomMember, ChatRoomMessage, EnrichedChatSession, + ChatTag, } from "@fusion/core"; import { api, buildApiUrl } from "./client.js"; import { withProjectId } from "./health.js"; @@ -27,6 +28,9 @@ export interface ChatSessionResponse { session: EnrichedChatSession; } +export interface ChatTagListResponse { tags: ChatTag[]; } +export interface ChatTagResponse { tag: ChatTag; } + export interface ChatMessageListResponse { messages: ChatMessage[]; } @@ -69,6 +73,19 @@ export interface FetchChatSessionsOptions { titleOnly?: boolean; } +export function fetchChatTags(projectId?: string): Promise { + return api(withProjectId("/chat/tags", projectId)); +} +export function createChatTag(name: string, projectId?: string): Promise { + return api(withProjectId("/chat/tags", projectId), { method: "POST", body: JSON.stringify({ name }) }); +} +export function renameChatTag(id: string, name: string, projectId?: string): Promise { + return api(withProjectId(`/chat/tags/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify({ name }) }); +} +export function deleteChatTag(id: string, projectId?: string): Promise<{ success: boolean }> { + return api<{ success: boolean }>(withProjectId(`/chat/tags/${encodeURIComponent(id)}`, projectId), { method: "DELETE" }); +} + /** Fetch all chat sessions for a project */ export function fetchChatSessions( projectId?: string, @@ -206,6 +223,7 @@ export function updateChatSession( agentId?: string; thinkingLevel?: string | null; pinned?: boolean; + tagIds?: string[]; }, projectId?: string, ): Promise { diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 2e5d67940f..b8d4c9c6e7 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -2773,3 +2773,20 @@ Queued-message banners stack above the composer input with a capped scroll area, color: var(--accent-text); border-color: color-mix(in srgb, var(--color-warning) 55%, transparent); } + +/* FNXC:ChatTags 2026-08-05-10:55: shared ChatView hosts expose Direct-only tag filters and compact chips without introducing a second sidebar layout. */ +.chat-tag-filter { display: flex; align-items: center; gap: var(--space-xs); color: var(--text-muted); } +.chat-tag-filter select { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg); color: var(--text); padding: var(--space-xs); } +.chat-session-tags { display: flex; flex-wrap: wrap; gap: var(--space-xs); overflow: hidden; } +.chat-session-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 var(--space-xs); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text-muted); font-size: var(--font-size-xs); } +/* FNXC:ChatTags 2026-08-05-12:15: direct-conversation tag controls keep assignment, rename, and deletion in the existing context menu so shared Chat hosts do not grow another sidebar action row. */ +.chat-session-tag-menu { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-xs); border-bottom: 1px solid var(--border); } +.chat-tag-menu-item { display: flex; align-items: center; gap: var(--space-xs); min-width: 0; } +.chat-session-context-menu .chat-tag-menu-item > [role="menuitemcheckbox"] { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-session-context-menu .chat-tag-menu-item > .btn-icon { flex: 0 0 auto; width: auto; padding: var(--space-xs); } +.chat-tag-create-row { display: flex; gap: var(--space-xs); } +.chat-tag-create-row .input { min-width: 0; } +@media (max-width: 768px) { + .chat-session-tags { max-height: calc(var(--space-lg) * 2); } + .chat-tag-menu-item { gap: var(--space-2xs); } +} diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index f99163524f..1c62c6b8cf 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -21,13 +21,14 @@ import { Pin, PinOff, MoreHorizontal, + Tag, } from "lucide-react"; import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo } from "../hooks/useChat"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; import { useChatUnread } from "../hooks/useChatUnread"; import { useViewportMode } from "./Header"; import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api"; -import { type Agent, type Settings } from "@fusion/core"; +import { type Agent, type ChatTag, type Settings } from "@fusion/core"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; import { AgentMentionPopup } from "./AgentMentionPopup"; @@ -626,6 +627,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout setSessionModel, setSessionThinkingLevel, deleteSession, + tags = [], + selectedTagId, + setSelectedTagId, + createTag, + renameTag, + deleteTag, + setSessionTags, sendMessage, editMessageAndResend, stopStreaming, @@ -715,6 +723,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const [renameTitle, setRenameTitle] = useState(""); const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState(null); + const [newTagName, setNewTagName] = useState(""); + const [renameTagDialog, setRenameTagDialog] = useState<{ id: string; name: string } | null>(null); + const [renameTagName, setRenameTagName] = useState(""); + const [confirmDeleteTag, setConfirmDeleteTag] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH); const [createRoomOpen, setCreateRoomOpen] = useState(false); @@ -3385,6 +3397,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout data-testid="chat-search-input" /> + {/* Session list section */}
@@ -3465,6 +3491,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
+ {(session.tags ?? []).length > 0 ?
{(session.tags ?? []).map((tag) => {tag.name})}
: null} {session.matchedMessagePreview ? (
{t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })} @@ -3647,6 +3674,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {t("chat.rename", "Rename")} +
+ {tags.map((tag) => { + const assigned = (contextMenuSession?.tags ?? []).some((candidate) => candidate.id === tag.id); + return
+ + + +
; + })} +
+ setNewTagName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void createTag(newTagName).then(() => setNewTagName("")).catch(() => addToast(t("chat.failedToCreateTag", "Failed to create tag"), "error")); } }} /> + +
+
)} + {renameTagDialog && ( +
setRenameTagDialog(null)}> +
event.stopPropagation()}> +

{t("chat.renameTagTitle", "Rename tag")}

+ + setRenameTagName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void renameTag(renameTagDialog.id, renameTagName).then(() => setRenameTagDialog(null)).catch(() => addToast(t("chat.failedToRenameTag", "Failed to rename tag"), "error")); } }} autoFocus /> +
+ + +
+
+
+ )} + + {confirmDeleteTag && ( +
setConfirmDeleteTag(null)}> +
event.stopPropagation()}> +

{t("chat.deleteTagTitle", "Delete tag?")}

+

{t("chat.deleteTagBody", "This removes the tag from all conversations, but does not delete conversations.")}

+
+ + +
+
+
+ )} + {/* Confirm Delete Dialog */} {confirmDelete && (
setConfirmDelete(null)}> diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx index 2858d67b1c..63add30da7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -1180,6 +1180,33 @@ describe("ChatView core interactions", () => { expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument(); }); + it("renders tag rename and deletion controls from the conversation menu", async () => { + const renameTag = vi.fn().mockResolvedValue(undefined); + const deleteTag = vi.fn().mockResolvedValue(undefined); + const tag = { id: "tag-topic", projectId: "proj-123", name: "Topic", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", tags: [tag], createdAt: tag.createdAt, updatedAt: tag.updatedAt }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", tags: [tag], createdAt: tag.createdAt, updatedAt: tag.updatedAt }], + tags: [tag], + renameTag, + deleteTag, + }); + + await renderWithAct(); + await userEvent.pointer({ target: screen.getByTestId("chat-session-session-001"), keys: "[MouseRight]" }); + + await userEvent.click(screen.getByTestId("chat-context-rename-tag-tag-topic")); + await userEvent.clear(screen.getByTestId("chat-rename-tag-input")); + await userEvent.type(screen.getByTestId("chat-rename-tag-input"), "Research"); + await userEvent.click(screen.getByTestId("chat-rename-tag-save")); + expect(renameTag).toHaveBeenCalledWith("tag-topic", "Research"); + + await userEvent.pointer({ target: screen.getByTestId("chat-session-session-001"), keys: "[MouseRight]" }); + await userEvent.click(screen.getByTestId("chat-context-delete-tag-tag-topic")); + await userEvent.click(screen.getByTestId("chat-delete-tag-confirm")); + expect(deleteTag).toHaveBeenCalledWith("tag-topic"); + }); + it("calls archiveSession when clicking Archive in context menu", async () => { const archiveSession = vi.fn(); setupMockChat({ diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index b2b30708d1..7d15e17432 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -14,6 +14,7 @@ import type { ChatSession, ChatMessage } from "@fusion/core"; // Mock the API module vi.mock("../../api", () => ({ fetchChatSessions: vi.fn(), + fetchChatTags: vi.fn().mockResolvedValue({ tags: [] }), fetchChatSession: vi.fn(), createChatSession: vi.fn(), fetchChatMessages: vi.fn(), diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 6c1bf00043..35d590578b 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -10,6 +10,10 @@ import { attachChatStream, streamChatResponse, cancelChatResponse, + fetchChatTags, + createChatTag as apiCreateChatTag, + renameChatTag as apiRenameChatTag, + deleteChatTag as apiDeleteChatTag, type ChatFailureInfo, type ChatSessionListResponse, type ChatStreamErrorMeta, @@ -17,7 +21,7 @@ import { import { subscribeSse } from "../sse-bus"; import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; -import type { Agent, ChatInFlightGenerationState, ChatMessage } from "@fusion/core"; +import type { Agent, ChatInFlightGenerationState, ChatMessage, ChatTag } from "@fusion/core"; const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session"; /** @@ -74,6 +78,8 @@ export interface ChatSessionInfo { lastMessageAt?: string; isGenerating?: boolean; inFlightGeneration?: ChatInFlightGenerationState | null; + /** Legacy mock payloads may omit this; UI treats omission as no assignments. */ + tags?: ChatTag[]; /** * When set, this chat session is driven by a cli-agent executor (U12). The * message-pane + composer region is delegated to instead of @@ -110,6 +116,9 @@ export interface UseChatReturn { sessions: ChatSessionInfo[]; activeSession: ChatSessionInfo | null; sessionsLoading: boolean; + tags: ChatTag[]; + selectedTagId: string | null; + setSelectedTagId: (id: string | null) => void; // Message state messages: ChatMessageInfo[]; @@ -142,6 +151,10 @@ export interface UseChatReturn { */ setSessionThinkingLevel: (id: string, level: string) => Promise; deleteSession: (id: string) => Promise; + createTag: (name: string) => Promise; + renameTag: (id: string, name: string) => Promise; + deleteTag: (id: string) => Promise; + setSessionTags: (sessionId: string, tagIds: string[]) => Promise; // Message operations /** @@ -405,6 +418,8 @@ export function useChat( const [sessions, setSessions] = useState(() => readCachedSessions(projectId)); const [activeSession, setActiveSession] = useState(null); const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0); + const [tags, setTags] = useState([]); + const [selectedTagId, setSelectedTagId] = useState(null); // Message state const [messages, setMessages] = useState([]); @@ -522,6 +537,14 @@ export function useChat( setSessionsLoading(cachedSessions.length === 0); }, [projectId, readCachedSessions]); + useEffect(() => { + let live = true; + setSelectedTagId(null); + setTags([]); + void fetchChatTags(projectId).then((data) => { if (live) setTags(data.tags); }).catch(() => { if (live) setTags([]); }); + return () => { live = false; }; + }, [projectId]); + // Initial load useEffect(() => { refreshSessions(); @@ -1681,10 +1704,25 @@ export function useChat( return () => clearTimeout(timeoutId); }, [trimmedSearchQuery, projectId]); + /* FNXC:ChatTags 2026-08-05-10:55: optimistic assignment keeps shared Chat hosts in sync while a failed API mutation rolls back exactly the prior session snapshot. */ + const createTag = useCallback(async (name: string) => { const response = await apiCreateChatTag(name, projectId); setTags((previous) => [...previous, response.tag].sort((a, b) => a.name.localeCompare(b.name))); }, [projectId]); + const renameTag = useCallback(async (id: string, name: string) => { const response = await apiRenameChatTag(id, name, projectId); setTags((previous) => previous.map((tag) => tag.id === id ? response.tag : tag).sort((a, b) => a.name.localeCompare(b.name))); setSessions((previous) => previous.map((session) => ({ ...session, tags: (session.tags ?? []).map((tag) => tag.id === id ? response.tag : tag) }))); }, [projectId]); + const deleteTag = useCallback(async (id: string) => { await apiDeleteChatTag(id, projectId); setTags((previous) => previous.filter((tag) => tag.id !== id)); setSessions((previous) => previous.map((session) => ({ ...session, tags: (session.tags ?? []).filter((tag) => tag.id !== id) }))); setSelectedTagId((selected) => selected === id ? null : selected); }, [projectId]); + const setSessionTags = useCallback(async (sessionId: string, tagIds: string[]) => { + const previous = sessionsRef.current; + const assigned = tags.filter((tag) => tagIds.includes(tag.id)); + setSessions((current) => current.map((session) => session.id === sessionId ? { ...session, tags: assigned } : session)); + try { + const response = await updateChatSession(sessionId, { tagIds }, projectId); + setSessions((current) => current.map((session) => session.id === sessionId ? response.session : session)); + if (activeSessionRef.current?.id === sessionId) setActiveSession(response.session); + } catch (error) { setSessions(previous); throw error; } + }, [projectId, tags]); + // Filter sessions based on search query: title/agentId match always applies; content // matches (from contentMatchedPreviews) are always unioned in. const filteredSessions = (() => { - if (!trimmedSearchQuery) return sessions; + if (!trimmedSearchQuery) return selectedTagId ? sessions.filter((session) => (session.tags ?? []).some((tag) => tag.id === selectedTagId)) : sessions; const lowerQuery = trimmedSearchQuery.toLowerCase(); const titleMatched = sessions.filter( @@ -1705,7 +1743,8 @@ export function useChat( const existing = merged.get(session.id); merged.set(session.id, { ...(existing ?? session), matchedMessagePreview: preview }); } - return sortChatSessions(Array.from(merged.values())); + const searchMatches = sortChatSessions(Array.from(merged.values())); + return selectedTagId ? searchMatches.filter((session) => (session.tags ?? []).some((tag) => tag.id === selectedTagId)) : searchMatches; })(); useEffect(() => { @@ -1983,6 +2022,9 @@ export function useChat( sessions, activeSession, sessionsLoading, + tags, + selectedTagId, + setSelectedTagId, messages, messagesLoading, isStreaming, @@ -1999,6 +2041,10 @@ export function useChat( setSessionModel, setSessionThinkingLevel, deleteSession, + createTag, + renameTag, + deleteTag, + setSessionTags, sendMessage, editMessageAndResend, stopStreaming, diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 1a474e780c..0186d68fe5 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -241,6 +241,60 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } }); + /* + FNXC:ChatTags 2026-08-05-10:55: + Tags are a Direct-conversation taxonomy, not room metadata. Every endpoint + resolves the project-scoped ChatStore and passes its project identity into the + store mutation, preventing unqualified tag/session IDs from crossing scopes. + */ + router.get("/chat/tags", rateLimit(RATE_LIMITS.api), async (req, res) => { + try { + const { projectId } = await getProjectContext(req); + const { chatStore } = await resolveScopedChatStore(projectId); + res.json({ tags: await chatStore.listTags(projectId ?? null) }); + } catch (err) { rethrowAsApiError(err, "Failed to list chat tags"); } + }); + + router.post("/chat/tags", rateLimit(RATE_LIMITS.mutation), async (req, res) => { + try { + if (typeof req.body?.name !== "string") throw badRequest("name must be a string"); + const { projectId } = await getProjectContext(req); + const { chatStore } = await resolveScopedChatStore(projectId); + const tag = await chatStore.createTag({ name: req.body.name, projectId: projectId ?? null }); + res.status(201).json({ tag }); + } catch (err) { + if (err instanceof ApiError) throw err; + const message = err instanceof Error ? err.message : "Invalid tag"; + if (message.includes("tag") || message.includes("Tag")) throw badRequest(message); + rethrowAsApiError(err, "Failed to create chat tag"); + } + }); + + router.patch("/chat/tags/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { + try { + if (typeof req.body?.name !== "string") throw badRequest("name must be a string"); + const { projectId } = await getProjectContext(req); + const { chatStore } = await resolveScopedChatStore(projectId); + const tag = await chatStore.renameTag(String(req.params.id), projectId ?? null, { name: req.body.name }); + if (!tag) throw notFound("Chat tag not found"); + res.json({ tag }); + } catch (err) { + if (err instanceof ApiError) throw err; + const message = err instanceof Error ? err.message : "Invalid tag"; + if (message.includes("tag") || message.includes("Tag")) throw badRequest(message); + rethrowAsApiError(err, "Failed to rename chat tag"); + } + }); + + router.delete("/chat/tags/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { + try { + const { projectId } = await getProjectContext(req); + const { chatStore } = await resolveScopedChatStore(projectId); + if (!await chatStore.deleteTag(String(req.params.id), projectId ?? null)) throw notFound("Chat tag not found"); + res.json({ success: true }); + } catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to delete chat tag"); } + }); + // ── Chat Routes ──────────────────────────────────────────────────────────── /** @@ -533,6 +587,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): modelId: rawModelId, agentId: rawAgentId, pinned: rawPinned, + tagIds: rawTagIds, } = req.body as { title?: string; status?: string; @@ -541,6 +596,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): modelId?: string | null; agentId?: string; pinned?: boolean; + tagIds?: unknown; }; // Validate status if provided @@ -548,6 +604,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw badRequest("status must be 'active' or 'archived'"); } + if (rawTagIds !== undefined && (!Array.isArray(rawTagIds) || rawTagIds.some((id) => typeof id !== "string" || !id.trim()))) { + throw badRequest("tagIds must be an array of tag IDs"); + } + if (rawPinned !== undefined && typeof rawPinned !== "boolean") { throw badRequest("pinned must be a boolean"); } @@ -595,6 +655,16 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): if (!session) { throw notFound(`Chat session ${sessionId} not found`); } + if (rawTagIds !== undefined) { + try { + session = await chatStore.replaceSessionTags(sessionId, session.projectId ?? null, rawTagIds.map((id) => id.trim())); + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to update conversation tags"; + throw badRequest(message); + } + if (!session) throw notFound(`Chat session ${sessionId} not found`); + } + if (rawPinned !== undefined) { try { session = await chatStore.setSessionPinned(sessionId, rawPinned);