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 ## 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. 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. 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. 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, TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION, MISSION_LINEAGE_STOP_VERSION,
CHAT_SESSION_TAGS_VERSION,
} from "../../postgres/schema-applier.js"; } from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.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 task_verification_requests + 1 durable symbol_locks table (FN-8305)
// + 1 mission_lineage_stops (FNXC:MissionLineageBudget FN-8543 / migration 0035). // + 1 mission_lineage_stops (FNXC:MissionLineageBudget FN-8543 / migration 0035).
// Plugin tables are added separately by the hook. // 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.central).toBe(18);
expect(bySchema.archive).toBe(1); expect(bySchema.archive).toBe(1);
}); });
@@ -1630,6 +1631,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_WEDGE_NOTIFICATION_VERSION, TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION, MISSION_LINEAGE_STOP_VERSION,
CHAT_SESSION_TAGS_VERSION,
]); ]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
}); });
@@ -1691,6 +1693,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_WEDGE_NOTIFICATION_VERSION, TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION, MISSION_LINEAGE_STOP_VERSION,
CHAT_SESSION_TAGS_VERSION,
]); ]);
}); });
@@ -1885,6 +1888,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_WEDGE_NOTIFICATION_VERSION, TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION, MISSION_LINEAGE_STOP_VERSION,
CHAT_SESSION_TAGS_VERSION,
]); ]);
}); });
@@ -1960,6 +1964,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_WEDGE_NOTIFICATION_VERSION, TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION, MISSION_LINEAGE_STOP_VERSION,
CHAT_SESSION_TAGS_VERSION,
]); ]);
}); });
@@ -2035,6 +2040,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_WEDGE_NOTIFICATION_VERSION, TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION, MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_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 * flip. These helpers are the async target the PostgreSQL integration tests
* consume. * 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 { 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 * as schema from "./postgres/schema/index.js";
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
@@ -31,6 +32,9 @@ import type {
ChatRoomStatus, ChatRoomStatus,
ChatSession, ChatSession,
ChatSessionStatus, ChatSessionStatus,
ChatTag,
ChatTagCreateInput,
ChatTagUpdateInput,
RoomMemberRole, RoomMemberRole,
} from "./chat-types.js"; } from "./chat-types.js";
@@ -39,9 +43,14 @@ type QueryHandle = AsyncDataLayer["db"] | DbTransaction;
// ── Row → Entity converters ── // ── 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 { function rowToSession(row: Record<string, unknown>): ChatSession {
return { return {
id: row.id as string, id: row.id as string,
tags: [],
agentId: row.agentId as string, agentId: row.agentId as string,
title: (row.title as string | null) ?? null, title: (row.title as string | null) ?? null,
status: row.status as ChatSessionStatus, status: row.status as ChatSessionStatus,
@@ -147,7 +156,7 @@ export async function getChatSession(handle: QueryHandle, id: string): Promise<C
.select() .select()
.from(schema.project.chatSessions) .from(schema.project.chatSessions)
.where(eq(schema.project.chatSessions.id, id)); .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) .from(schema.project.chatSessions)
.orderBy(desc(schema.project.chatSessions.updatedAt)); .orderBy(desc(schema.project.chatSessions.updatedAt));
const rows = conditions.length > 0 ? await query.where(and(...conditions)) : await query; 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. * Delete a chat session by id. Returns true if a row was deleted.
*/ */
export async function deleteChatSession(handle: QueryHandle, id: string): Promise<boolean> { 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 const result = await handle
.delete(schema.project.chatSessions) .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 }); .returning({ id: schema.project.chatSessions.id });
return result.length > 0; 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 ── // ── 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 * as asyncChatStore from "./async-chat-store.js";
import type { import type {
ChatSession, ChatSession,
ChatTag,
ChatTagCreateInput,
ChatTagUpdateInput,
ChatSessionStatus, ChatSessionStatus,
ChatMessage, ChatMessage,
ChatAttachment, ChatAttachment,
@@ -111,6 +114,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
const session: ChatSession = { const session: ChatSession = {
id: `chat-${randomUUID().slice(0, 8)}`, id: `chat-${randomUUID().slice(0, 8)}`,
agentId: input.agentId, agentId: input.agentId,
tags: [],
title: input.title ?? null, title: input.title ?? null,
status: "active", status: "active",
projectId: input.projectId ?? null, projectId: input.projectId ?? null,
@@ -315,6 +319,34 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
return deletedCount; 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 ─────────────────────────────────────── // ── Message CRUD Operations ───────────────────────────────────────
/** /**

View File

@@ -36,6 +36,15 @@ export interface ChatInFlightGenerationState {
updatedAt: string; 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 { export interface ChatSession {
id: string; id: string;
/** Session routing kind; legacy sessions default to direct */ /** Session routing kind; legacy sessions default to direct */
@@ -46,6 +55,8 @@ export interface ChatSession {
roomName?: string | null; roomName?: string | null;
/** ID of the agent participating in this session */ /** ID of the agent participating in this session */
agentId: string; agentId: string;
/** Deterministically name-sorted Direct-conversation tags. Always present. */
tags: ChatTag[];
/** Human-readable title for the session (optional, can be auto-generated) */ /** Human-readable title for the session (optional, can be auto-generated) */
title: string | null; title: string | null;
/** Current status of the session */ /** Current status of the session */
@@ -231,6 +242,9 @@ export interface ChatSessionCreateInput {
* Input for updating a chat session. * Input for updating a chat session.
* All fields are optional; only provided fields are updated. * 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 { export interface ChatSessionUpdateInput {
/** New session title */ /** New session title */
title?: string | null; title?: string | null;

View File

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

View File

@@ -2330,6 +2330,9 @@ export type {
export type { export type {
ChatSessionStatus, ChatSessionStatus,
ChatTag,
ChatTagCreateInput,
ChatTagUpdateInput,
ChatMessageRole, ChatMessageRole,
ChatInFlightToolCall, ChatInFlightToolCall,
ChatInFlightGenerationState, 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. continuations at workflow column boundaries.
FNXC:LegacyAdoption 2026-07-21-17:30: 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. SECURITY DEFINER write access to the legacy-adoption drained marker.
FNXC:TaskWedgeNotifications 2026-10-19-00:00: 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 forward migration must run before TaskStore writes the new field on fresh and
upgraded databases. 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. */ /** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000"; const INITIAL_SCHEMA_VERSION = "0000";
@@ -150,6 +150,8 @@ export const TASK_WEDGE_NOTIFICATION_VERSION = "0033";
export const MILESTONE_ASSERTION_PROVENANCE_VERSION = "0034"; 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. */ /** 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"; 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. */ /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; 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", "0034_milestone_assertion_provenance.sql",
); );
const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_mission_lineage_stop.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 * 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 taskWedgeNotificationAlreadyApplied = applied.includes(TASK_WEDGE_NOTIFICATION_VERSION);
const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION); const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION);
const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION); const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION);
const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION);
assertBinaryNotOlderThanDatabase(applied); assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false; let schemaChanged = false;
@@ -977,6 +981,14 @@ export async function applySchemaBaseline(
schemaChanged = true; 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 }; return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
}); });
} }

View File

@@ -1895,6 +1895,40 @@ export const chatSessions = projectSchema.table("chat_sessions", {
}, (t) => [ }, (t) => [
index("idxChatSessionsAgentId").on(t.agentId), index("idxChatSessionsAgentId").on(t.agentId),
index("idxChatSessionsProjectId").on(t.projectId), 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", { export const cliSessions = projectSchema.table("cli_sessions", {

View File

@@ -5,5 +5,6 @@
* while implementation lives under `app/api/*` modules. * while implementation lives under `app/api/*` modules.
*/ */
export * from "./api/legacy"; export * from "./api/legacy";
export * from "./api/chat";
export * from "./api-node"; export * from "./api-node";
export * from "./api/report"; export * from "./api/report";

View File

@@ -9,6 +9,7 @@ import type {
ChatRoomMember, ChatRoomMember,
ChatRoomMessage, ChatRoomMessage,
EnrichedChatSession, EnrichedChatSession,
ChatTag,
} from "@fusion/core"; } from "@fusion/core";
import { api, buildApiUrl } from "./client.js"; import { api, buildApiUrl } from "./client.js";
import { withProjectId } from "./health.js"; import { withProjectId } from "./health.js";
@@ -27,6 +28,9 @@ export interface ChatSessionResponse {
session: EnrichedChatSession; session: EnrichedChatSession;
} }
export interface ChatTagListResponse { tags: ChatTag[]; }
export interface ChatTagResponse { tag: ChatTag; }
export interface ChatMessageListResponse { export interface ChatMessageListResponse {
messages: ChatMessage[]; messages: ChatMessage[];
} }
@@ -69,6 +73,19 @@ export interface FetchChatSessionsOptions {
titleOnly?: boolean; 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 */ /** Fetch all chat sessions for a project */
export function fetchChatSessions( export function fetchChatSessions(
projectId?: string, projectId?: string,
@@ -206,6 +223,7 @@ export function updateChatSession(
agentId?: string; agentId?: string;
thinkingLevel?: string | null; thinkingLevel?: string | null;
pinned?: boolean; pinned?: boolean;
tagIds?: string[];
}, },
projectId?: string, projectId?: string,
): Promise<ChatSessionResponse> { ): 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); color: var(--accent-text);
border-color: color-mix(in srgb, var(--color-warning) 55%, transparent); 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, Pin,
PinOff, PinOff,
MoreHorizontal, MoreHorizontal,
Tag,
} from "lucide-react"; } from "lucide-react";
import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo } from "../hooks/useChat"; import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo } from "../hooks/useChat";
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
import { useChatUnread } from "../hooks/useChatUnread"; import { useChatUnread } from "../hooks/useChatUnread";
import { useViewportMode } from "./Header"; import { useViewportMode } from "./Header";
import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api"; 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 { CustomModelDropdown } from "./CustomModelDropdown";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
import { AgentMentionPopup } from "./AgentMentionPopup"; import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -626,6 +627,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
setSessionModel, setSessionModel,
setSessionThinkingLevel, setSessionThinkingLevel,
deleteSession, deleteSession,
tags = [],
selectedTagId,
setSelectedTagId,
createTag,
renameTag,
deleteTag,
setSessionTags,
sendMessage, sendMessage,
editMessageAndResend, editMessageAndResend,
stopStreaming, stopStreaming,
@@ -715,6 +723,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const [renameTitle, setRenameTitle] = useState(""); const [renameTitle, setRenameTitle] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = 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 [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH); const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
const [createRoomOpen, setCreateRoomOpen] = useState(false); const [createRoomOpen, setCreateRoomOpen] = useState(false);
@@ -3385,6 +3397,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
data-testid="chat-search-input" data-testid="chat-search-input"
/> />
</div> </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> </div>
{/* Session list section */} {/* Session list section */}
<div className="chat-session-list chat-sidebar-list"> <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"> <div className="chat-session-preview">
{session.lastMessagePreview || t("chat.noMessages", "No messages")} {session.lastMessagePreview || t("chat.noMessages", "No messages")}
</div> </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 ? ( {session.matchedMessagePreview ? (
<div className="chat-session-preview chat-session-preview--matched" data-testid={`chat-session-matched-preview-${session.id}`}> <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 })} {t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })}
@@ -3647,6 +3674,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<Pencil size={14} /> <Pencil size={14} />
{t("chat.rename", "Rename")} {t("chat.rename", "Rename")}
</button> </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 <button
onClick={() => handleArchive(contextMenu.sessionId)} onClick={() => handleArchive(contextMenu.sessionId)}
data-testid="chat-context-archive" data-testid="chat-context-archive"
@@ -3716,6 +3757,33 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div> </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 */} {/* Confirm Delete Dialog */}
{confirmDelete && ( {confirmDelete && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}> <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(); 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 () => { it("calls archiveSession when clicking Archive in context menu", async () => {
const archiveSession = vi.fn(); const archiveSession = vi.fn();
setupMockChat({ setupMockChat({

View File

@@ -14,6 +14,7 @@ import type { ChatSession, ChatMessage } from "@fusion/core";
// Mock the API module // Mock the API module
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchChatSessions: vi.fn(), fetchChatSessions: vi.fn(),
fetchChatTags: vi.fn().mockResolvedValue({ tags: [] }),
fetchChatSession: vi.fn(), fetchChatSession: vi.fn(),
createChatSession: vi.fn(), createChatSession: vi.fn(),
fetchChatMessages: vi.fn(), fetchChatMessages: vi.fn(),

View File

@@ -10,6 +10,10 @@ import {
attachChatStream, attachChatStream,
streamChatResponse, streamChatResponse,
cancelChatResponse, cancelChatResponse,
fetchChatTags,
createChatTag as apiCreateChatTag,
renameChatTag as apiRenameChatTag,
deleteChatTag as apiDeleteChatTag,
type ChatFailureInfo, type ChatFailureInfo,
type ChatSessionListResponse, type ChatSessionListResponse,
type ChatStreamErrorMeta, type ChatStreamErrorMeta,
@@ -17,7 +21,7 @@ import {
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage"; import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; 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"; const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
/** /**
@@ -74,6 +78,8 @@ export interface ChatSessionInfo {
lastMessageAt?: string; lastMessageAt?: string;
isGenerating?: boolean; isGenerating?: boolean;
inFlightGeneration?: ChatInFlightGenerationState | null; 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 * When set, this chat session is driven by a cli-agent executor (U12). The
* message-pane + composer region is delegated to <CliChatSurface> instead of * message-pane + composer region is delegated to <CliChatSurface> instead of
@@ -110,6 +116,9 @@ export interface UseChatReturn {
sessions: ChatSessionInfo[]; sessions: ChatSessionInfo[];
activeSession: ChatSessionInfo | null; activeSession: ChatSessionInfo | null;
sessionsLoading: boolean; sessionsLoading: boolean;
tags: ChatTag[];
selectedTagId: string | null;
setSelectedTagId: (id: string | null) => void;
// Message state // Message state
messages: ChatMessageInfo[]; messages: ChatMessageInfo[];
@@ -142,6 +151,10 @@ export interface UseChatReturn {
*/ */
setSessionThinkingLevel: (id: string, level: string) => Promise<void>; setSessionThinkingLevel: (id: string, level: string) => Promise<void>;
deleteSession: (id: 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 // Message operations
/** /**
@@ -405,6 +418,8 @@ export function useChat(
const [sessions, setSessions] = useState<ChatSessionInfo[]>(() => readCachedSessions(projectId)); const [sessions, setSessions] = useState<ChatSessionInfo[]>(() => readCachedSessions(projectId));
const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null); const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0); const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0);
const [tags, setTags] = useState<ChatTag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
// Message state // Message state
const [messages, setMessages] = useState<ChatMessageInfo[]>([]); const [messages, setMessages] = useState<ChatMessageInfo[]>([]);
@@ -522,6 +537,14 @@ export function useChat(
setSessionsLoading(cachedSessions.length === 0); setSessionsLoading(cachedSessions.length === 0);
}, [projectId, readCachedSessions]); }, [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 // Initial load
useEffect(() => { useEffect(() => {
refreshSessions(); refreshSessions();
@@ -1681,10 +1704,25 @@ export function useChat(
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [trimmedSearchQuery, projectId]); }, [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 // Filter sessions based on search query: title/agentId match always applies; content
// matches (from contentMatchedPreviews) are always unioned in. // matches (from contentMatchedPreviews) are always unioned in.
const filteredSessions = (() => { 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 lowerQuery = trimmedSearchQuery.toLowerCase();
const titleMatched = sessions.filter( const titleMatched = sessions.filter(
@@ -1705,7 +1743,8 @@ export function useChat(
const existing = merged.get(session.id); const existing = merged.get(session.id);
merged.set(session.id, { ...(existing ?? session), matchedMessagePreview: preview }); 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(() => { useEffect(() => {
@@ -1983,6 +2022,9 @@ export function useChat(
sessions, sessions,
activeSession, activeSession,
sessionsLoading, sessionsLoading,
tags,
selectedTagId,
setSelectedTagId,
messages, messages,
messagesLoading, messagesLoading,
isStreaming, isStreaming,
@@ -1999,6 +2041,10 @@ export function useChat(
setSessionModel, setSessionModel,
setSessionThinkingLevel, setSessionThinkingLevel,
deleteSession, deleteSession,
createTag,
renameTag,
deleteTag,
setSessionTags,
sendMessage, sendMessage,
editMessageAndResend, editMessageAndResend,
stopStreaming, 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 ──────────────────────────────────────────────────────────── // ── Chat Routes ────────────────────────────────────────────────────────────
/** /**
@@ -533,6 +587,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
modelId: rawModelId, modelId: rawModelId,
agentId: rawAgentId, agentId: rawAgentId,
pinned: rawPinned, pinned: rawPinned,
tagIds: rawTagIds,
} = req.body as { } = req.body as {
title?: string; title?: string;
status?: string; status?: string;
@@ -541,6 +596,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
modelId?: string | null; modelId?: string | null;
agentId?: string; agentId?: string;
pinned?: boolean; pinned?: boolean;
tagIds?: unknown;
}; };
// Validate status if provided // Validate status if provided
@@ -548,6 +604,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw badRequest("status must be 'active' or 'archived'"); 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") { if (rawPinned !== undefined && typeof rawPinned !== "boolean") {
throw badRequest("pinned must be a boolean"); throw badRequest("pinned must be a boolean");
} }
@@ -595,6 +655,16 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
if (!session) { if (!session) {
throw notFound(`Chat session ${sessionId} not found`); 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) { if (rawPinned !== undefined) {
try { try {
session = await chatStore.setSessionPinned(sessionId, rawPinned); session = await chatStore.setSessionPinned(sessionId, rawPinned);