FN-8562: add tags to chat conversations

Enable project-scoped reusable tags for Direct chat sessions.

- Add tag storage, migrations, and scoped assignment APIs.
- Add chat sidebar tag creation, assignment, and filtering controls.
- Document tag behavior and cover schema and chat interactions.
- Establish the session project/id candidate key before creating scoped tag foreign keys.

Files changed:
 .changeset/fn-8562-chat-conversation-tags.md       |   7 ++
 docs/dashboard-guide.md                            |   4 +
 docs/storage.md                                    |   2 +
 .../src/__tests__/postgres/schema-applier.test.ts  |   8 +-
 packages/core/src/async-chat-store.ts              | 117 ++++++++++++++++++++-
 packages/core/src/chat-store.ts                    |  32 ++++++
 packages/core/src/chat-types.ts                    |  14 +++
 packages/core/src/index.gate.ts                    |   3 +
 packages/core/src/index.ts                         |   3 +
 .../postgres/migrations/0036_chat_session_tags.sql |  64 +++++++++++
 packages/core/src/postgres/schema-applier.ts       |  16 ++-
 packages/core/src/postgres/schema/project.ts       |  34 ++++++
 packages/dashboard/app/api.ts                      |   1 +
 packages/dashboard/app/api/chat.ts                 |  18 ++++
 packages/dashboard/app/components/ChatView.css     |  17 +++
 packages/dashboard/app/components/ChatView.tsx     |  70 +++++++++++-
 .../__tests__/ChatView.core-interactions.test.tsx  |  27 +++++
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |   1 +
 packages/dashboard/app/hooks/useChat.ts            |  52 ++++++++-
 .../dashboard/src/routes/register-chat-routes.ts   |  70 ++++++++++++
 20 files changed, 550 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8562

Fusion-Task-Lineage: 4efe798c-0865-451a-adbd-6b85f9f8b149

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-24 18:09:32 -07:00
parent 03cfc2dfc8
commit a9d5b06013
20 changed files with 550 additions and 10 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,
]);
});
});

View File

@@ -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<string, unknown>): 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<string, unknown>): 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<C
.select()
.from(schema.project.chatSessions)
.where(eq(schema.project.chatSessions.id, id));
return rows[0] ? rowToSession(rows[0]) : undefined;
return rows[0] ? attachTags(handle, rowToSession(rows[0])) : undefined;
}
/**
@@ -167,20 +176,122 @@ export async function listChatSessions(
.from(schema.project.chatSessions)
.orderBy(desc(schema.project.chatSessions.updatedAt));
const rows = conditions.length > 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<boolean> {
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<ChatSession> {
const [attached] = await attachTagsToSessions(handle, [session]);
return attached ?? session;
}
async function attachTagsToSessions(handle: QueryHandle, sessions: ChatSession[]): Promise<ChatSession[]> {
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<string, ChatTag[]>();
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<ChatTag[]> {
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<ChatTag> {
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<ChatTag | undefined> {
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<boolean> {
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<ChatSession | undefined> {
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 ──
/**

View File

@@ -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<ChatStoreEvents> {
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<ChatStoreEvents> {
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<ChatTag[]> {
return asyncChatStore.listChatTags(this.asyncLayer.db, projectId);
}
async createTag(input: ChatTagCreateInput): Promise<ChatTag> {
return asyncChatStore.createChatTag(this.asyncLayer, input);
}
async renameTag(id: string, projectId: string | null, input: ChatTagUpdateInput): Promise<ChatTag | undefined> {
return asyncChatStore.renameChatTag(this.asyncLayer, id, projectId, input);
}
async deleteTag(id: string, projectId: string | null): Promise<boolean> {
return asyncChatStore.deleteChatTag(this.asyncLayer, id, projectId);
}
async replaceSessionTags(id: string, projectId: string | null, tagIds: string[]): Promise<ChatSession | undefined> {
const updated = await asyncChatStore.replaceChatSessionTags(this.asyncLayer, id, projectId, tagIds);
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
// ── Message CRUD Operations ───────────────────────────────────────
/**

View File

@@ -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;

View File

@@ -2148,6 +2148,9 @@ export type {
export type {
ChatSessionStatus,
ChatTag,
ChatTagCreateInput,
ChatTagUpdateInput,
ChatMessageRole,
ChatInFlightToolCall,
ChatInFlightGenerationState,

View File

@@ -2330,6 +2330,9 @@ export type {
export type {
ChatSessionStatus,
ChatTag,
ChatTagCreateInput,
ChatTagUpdateInput,
ChatMessageRole,
ChatInFlightToolCall,
ChatInFlightGenerationState,

View File

@@ -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 $$;

View File

@@ -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 };
});
}

View File

@@ -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", {

View File

@@ -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";

View File

@@ -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<ChatTagListResponse> {
return api<ChatTagListResponse>(withProjectId("/chat/tags", projectId));
}
export function createChatTag(name: string, projectId?: string): Promise<ChatTagResponse> {
return api<ChatTagResponse>(withProjectId("/chat/tags", projectId), { method: "POST", body: JSON.stringify({ name }) });
}
export function renameChatTag(id: string, name: string, projectId?: string): Promise<ChatTagResponse> {
return api<ChatTagResponse>(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<ChatSessionResponse> {

View File

@@ -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); }
}

View File

@@ -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<string | null>(null);
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(null);
const [newTagName, setNewTagName] = useState("");
const [renameTagDialog, setRenameTagDialog] = useState<{ id: string; name: string } | null>(null);
const [renameTagName, setRenameTagName] = useState("");
const [confirmDeleteTag, setConfirmDeleteTag] = useState<ChatTag | null>(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"
/>
</div>
<label className="chat-tag-filter" htmlFor="chat-tag-filter">
<Tag size={14} aria-hidden="true" />
<select
id="chat-tag-filter"
value={selectedTagId ?? ""}
onChange={(event) => setSelectedTagId(event.target.value || null)}
data-testid="chat-tag-filter"
aria-label={t("chat.filterByTag", "Filter conversations by tag")}
>
<option value="">{t("chat.allTags", "All tags")}</option>
{tags.map((tag) => <option key={tag.id} value={tag.id}>{tag.name}</option>)}
</select>
{selectedTagId ? <button type="button" className="btn-icon" aria-label={t("chat.clearTagFilter", "Clear tag filter")} onClick={() => setSelectedTagId(null)}><X size={14} /></button> : null}
</label>
</div>
{/* Session list section */}
<div className="chat-session-list chat-sidebar-list">
@@ -3465,6 +3491,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<div className="chat-session-preview">
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
</div>
{(session.tags ?? []).length > 0 ? <div className="chat-session-tags" data-testid={`chat-session-tags-${session.id}`}>{(session.tags ?? []).map((tag) => <span className="chat-session-tag" key={tag.id}>{tag.name}</span>)}</div> : null}
{session.matchedMessagePreview ? (
<div className="chat-session-preview chat-session-preview--matched" data-testid={`chat-session-matched-preview-${session.id}`}>
{t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })}
@@ -3647,6 +3674,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<Pencil size={14} />
{t("chat.rename", "Rename")}
</button>
<div className="chat-session-tag-menu" role="group" aria-label={t("chat.conversationTags", "Conversation tags")}>
{tags.map((tag) => {
const assigned = (contextMenuSession?.tags ?? []).some((candidate) => candidate.id === tag.id);
return <div className="chat-tag-menu-item" key={tag.id}>
<button type="button" role="menuitemcheckbox" aria-checked={assigned} data-testid={`chat-context-tag-${tag.id}`} onClick={() => void setSessionTags(contextMenu.sessionId, assigned ? (contextMenuSession?.tags ?? []).filter((candidate) => candidate.id !== tag.id).map((candidate) => candidate.id) : [...(contextMenuSession?.tags ?? []).map((candidate) => candidate.id), tag.id]).catch(() => addToast(t("chat.failedToUpdateTags", "Failed to update tags"), "error"))}>{assigned ? "✓ " : ""}{tag.name}</button>
<button type="button" className="btn-icon" aria-label={t("chat.renameTag", "Rename tag {{name}}", { name: tag.name })} data-testid={`chat-context-rename-tag-${tag.id}`} onClick={(event) => { event.stopPropagation(); setRenameTagName(tag.name); setRenameTagDialog(tag); }}><Pencil size={14} /></button>
<button type="button" className="btn-icon" aria-label={t("chat.deleteTag", "Delete tag {{name}}", { name: tag.name })} data-testid={`chat-context-delete-tag-${tag.id}`} onClick={(event) => { event.stopPropagation(); setConfirmDeleteTag(tag); }}><Trash2 size={14} /></button>
</div>;
})}
<div className="chat-tag-create-row">
<input className="input" value={newTagName} placeholder={t("chat.newTag", "New tag")} aria-label={t("chat.newTag", "New tag")} onChange={(event) => 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")); } }} />
<button type="button" className="btn btn-sm" onClick={() => void createTag(newTagName).then(() => setNewTagName("")).catch(() => addToast(t("chat.failedToCreateTag", "Failed to create tag"), "error"))}>{t("chat.addTag", "Add")}</button>
</div>
</div>
<button
onClick={() => handleArchive(contextMenu.sessionId)}
data-testid="chat-context-archive"
@@ -3716,6 +3757,33 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
)}
{renameTagDialog && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setRenameTagDialog(null)}>
<div className="chat-new-dialog chat-view-dialog" role="dialog" aria-modal="true" aria-labelledby="chat-rename-tag-dialog-title" onClick={(event) => event.stopPropagation()}>
<h3 id="chat-rename-tag-dialog-title">{t("chat.renameTagTitle", "Rename tag")}</h3>
<label className="chat-rename-label" htmlFor="chat-rename-tag-input">{t("chat.tagName", "Tag name")}</label>
<input id="chat-rename-tag-input" className="input chat-rename-input" value={renameTagName} data-testid="chat-rename-tag-input" onChange={(event) => 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 />
<div className="chat-new-dialog-actions">
<button className="btn btn-sm" onClick={() => setRenameTagDialog(null)}>{t("chat.cancel", "Cancel")}</button>
<button className="btn btn-sm btn-primary" data-testid="chat-rename-tag-save" onClick={() => void renameTag(renameTagDialog.id, renameTagName).then(() => setRenameTagDialog(null)).catch(() => addToast(t("chat.failedToRenameTag", "Failed to rename tag"), "error"))}>{t("chat.save", "Save")}</button>
</div>
</div>
</div>
)}
{confirmDeleteTag && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteTag(null)}>
<div className="chat-new-dialog chat-view-dialog" role="dialog" aria-modal="true" aria-labelledby="chat-delete-tag-dialog-title" onClick={(event) => event.stopPropagation()}>
<h3 id="chat-delete-tag-dialog-title">{t("chat.deleteTagTitle", "Delete tag?")}</h3>
<p className="chat-view-delete-dialog-copy">{t("chat.deleteTagBody", "This removes the tag from all conversations, but does not delete conversations.")}</p>
<div className="chat-new-dialog-actions">
<button className="btn btn-sm" onClick={() => setConfirmDeleteTag(null)}>{t("chat.cancel", "Cancel")}</button>
<button className="btn btn-sm btn-danger" data-testid="chat-delete-tag-confirm" onClick={() => void deleteTag(confirmDeleteTag.id).then(() => setConfirmDeleteTag(null)).catch(() => addToast(t("chat.failedToDeleteTag", "Failed to delete tag"), "error"))}>{t("chat.delete", "Delete")}</button>
</div>
</div>
</div>
)}
{/* Confirm Delete Dialog */}
{confirmDelete && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}>

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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({

View File

@@ -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(),

View File

@@ -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 <CliChatSurface> 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<void>;
deleteSession: (id: string) => Promise<void>;
createTag: (name: string) => Promise<void>;
renameTag: (id: string, name: string) => Promise<void>;
deleteTag: (id: string) => Promise<void>;
setSessionTags: (sessionId: string, tagIds: string[]) => Promise<void>;
// Message operations
/**
@@ -405,6 +418,8 @@ export function useChat(
const [sessions, setSessions] = useState<ChatSessionInfo[]>(() => readCachedSessions(projectId));
const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0);
const [tags, setTags] = useState<ChatTag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
// Message state
const [messages, setMessages] = useState<ChatMessageInfo[]>([]);
@@ -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,

View File

@@ -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);