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:
7
.changeset/fn-8562-chat-conversation-tags.md
Normal file
7
.changeset/fn-8562-chat-conversation-tags.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -2148,6 +2148,9 @@ export type {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
ChatSessionStatus,
|
ChatSessionStatus,
|
||||||
|
ChatTag,
|
||||||
|
ChatTagCreateInput,
|
||||||
|
ChatTagUpdateInput,
|
||||||
ChatMessageRole,
|
ChatMessageRole,
|
||||||
ChatInFlightToolCall,
|
ChatInFlightToolCall,
|
||||||
ChatInFlightGenerationState,
|
ChatInFlightGenerationState,
|
||||||
|
|||||||
@@ -2330,6 +2330,9 @@ export type {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
ChatSessionStatus,
|
ChatSessionStatus,
|
||||||
|
ChatTag,
|
||||||
|
ChatTagCreateInput,
|
||||||
|
ChatTagUpdateInput,
|
||||||
ChatMessageRole,
|
ChatMessageRole,
|
||||||
ChatInFlightToolCall,
|
ChatInFlightToolCall,
|
||||||
ChatInFlightGenerationState,
|
ChatInFlightGenerationState,
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user