FN-8054: add pinned chat conversations

Add durable, scoped pinning for Direct chat conversations.

- Add pinned session persistence, migration coverage, and archive-safe row locking.
- Enforce a three-conversation per-project pin limit through the chat API.
- Add desktop and mobile pin controls, sorting, indicators, and regression tests.

Files changed:
 .changeset/fn-8054-pin-conversations.md            |  7 ++
 docs/dashboard-guide.md                            |  2 +
 .../postgres/satellite-db-injected-stores.test.ts  | 13 ++++
 packages/core/src/async-chat-store.ts              | 27 ++++++++
 packages/core/src/chat-store.ts                    | 63 ++++++++++++++++--
 packages/core/src/chat-types.ts                    |  9 +++
 .../core/src/postgres/migrations/0000_initial.sql  |  1 +
 .../postgres/migrations/0012_chat_session_pins.sql |  8 +++
 packages/core/src/postgres/postgres-health.ts      |  3 +
 packages/core/src/postgres/schema-applier.ts       | 30 ++++++++-
 packages/core/src/postgres/schema/project.ts       |  3 +
 packages/dashboard/app/api/legacy.ts               |  1 +
 packages/dashboard/app/components/ChatView.css     | 32 +++++++++-
 packages/dashboard/app/components/ChatView.tsx     | 74 ++++++++++++++++++++--
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 21 ++++++
 packages/dashboard/app/hooks/useChat.ts            | 72 +++++++++++++++++----
 .../dashboard/src/routes/register-chat-routes.ts   | 25 +++++++-
 17 files changed, 366 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-8054

Fusion-Task-Lineage: 088cb01c-582b-4f56-a222-214da90ff356

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 02:35:29 -07:00
parent e40f76fef9
commit e87b51bd07
17 changed files with 366 additions and 25 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Pin up to 3 chat conversations to keep important ones at the top.
category: feature
dev: Adds nullable chat_sessions.pinned_at (self-heals on boot); PATCH /chat/sessions/:id accepts `pinned`; ChatStore.setSessionPinned enforces the max-3 per-project-scope limit (null projectId scoped as "default" via isNull predicate + non-null advisory-lock key) with a per-scope advisory lock; archiving clears pinnedAt on both archive paths and archived sessions cannot be pinned.

View File

@@ -552,6 +552,8 @@ Chat view provides project-scoped conversations with agents.
- On mobile direct-chat threads, the top Chat header collapses into one compact row: the back button is the far-left visible control and the active conversation dropdown stays beside it, while the visible Chat icon/title shell is hidden to preserve transcript space. The dropdown trigger shows the conversation title with the provider/model logo only (no model-name text), and tapping it opens a lightweight dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- On mobile direct-chat threads, the single thread-wide Markdown/plain eye toggle floats above the transcript/composer area instead of occupying a second header row; desktop/tablet keeps the toggle in the thread header.
- Direct chat sessions can be renamed from the sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
<!-- FNXC:ChatPinned 2026-07-16-12:00: Document the Direct-only pin contract across desktop and mobile surfaces, including the durable server-side scope limit. -->
- You can pin up to **3** active Direct conversations per project scope from a sidebar row, desktop right-click menu, or the mobile session switcher. Pinned conversations show an indicator, sort above recent unpinned conversations, and appear under **Pinned** on both desktop and mobile. The server serializes each scope's pin changes (including null-project/default sessions) so concurrent requests cannot exceed the limit. Archiving always removes a pin, whether archived through either archive path; archived conversations cannot be pinned.
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->
<!-- FNXC:TaskDetailPlannerChat 2026-07-01-22:02: Done-task planner Chat remains available for retrospective Q&A and can create a task-scoped refinement through the planner tool, while common Chat feed visibility remains opt-in. -->
- Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task.

View File

@@ -354,6 +354,19 @@ pgDescribe("PostgreSQL satellite DB-injected stores (VAL-DATA-016)", () => {
await addChatRoomMessage(ctx.layer.db, { id: "rmsg-1", roomId: "room-1", role: "user", content: "Room hello", thinkingOutput: null, metadata: null, attachments: null, senderAgentId: "agent-1", mentions: ["agent-2"], createdAt: now });
expect((await getChatRoomMessages(ctx.layer.db, "room-1"))).toHaveLength(1);
/*
FNXC:ChatPinned 2026-07-16-12:30:
A pin must never survive archiving, and an archived session must reject a
later pin request. The store's row lock makes these invariants hold when
archive and pin requests overlap as well as in this serial regression case.
*/
const chatStore = new (await import("../../chat-store.js")).ChatStore(ctx.layer);
const pinned = await chatStore.setSessionPinned("chat-1", true);
expect(pinned?.pinnedAt).not.toBeNull();
const archived = await chatStore.archiveSession("chat-1");
expect(archived).toMatchObject({ status: "archived", pinnedAt: null });
await expect(chatStore.setSessionPinned("chat-1", true)).rejects.toThrow("Archived conversations cannot be pinned");
const cleared = await clearChatRoomMessages(ctx.layer.db, "room-1");
expect(cleared).toBe(1);
});

View File

@@ -51,6 +51,7 @@ function rowToSession(row: Record<string, unknown>): ChatSession {
thinkingLevel: (row.thinkingLevel as string | null) ?? null,
createdAt: row.createdAt as string,
updatedAt: row.updatedAt as string,
pinnedAt: (row.pinnedAt as string | null) ?? null,
cliSessionFile: (row.cliSessionFile as string | null) ?? null,
inFlightGeneration: (row.inFlightGeneration as ChatInFlightGenerationState | null) ?? null,
cliExecutorAdapterId: (row.cliExecutorAdapterId as string | null) ?? null,
@@ -129,6 +130,7 @@ export async function createChatSession(handle: QueryHandle, session: ChatSessio
thinkingLevel: session.thinkingLevel ?? null,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
pinnedAt: session.pinnedAt,
cliSessionFile: session.cliSessionFile,
inFlightGeneration: session.inFlightGeneration,
cliExecutorAdapterId: session.cliExecutorAdapterId,
@@ -503,6 +505,26 @@ export async function clearChatRoomMessages(handle: QueryHandle, roomId: string)
// These mirror the sync SQLite semantics in chat-store.ts exactly. The matching
// backend-mode branches in chat-store.ts call these helpers instead of throwing.
/**
* Lock a session row before a pin or archive mutation.
*
* FNXC:ChatPinned 2026-07-16-12:30: Pinning and archiving must serialize on
* the same session row. Reading under this lock prevents an archive from
* clearing a pin before a concurrent pin request writes it back.
*/
export async function getChatSessionForUpdate(
tx: DbTransaction,
id: string,
): Promise<ChatSession | undefined> {
const rows = await tx
.select()
.from(schema.project.chatSessions)
.where(eq(schema.project.chatSessions.id, id))
.for("update");
const row = rows[0];
return row ? rowToSession(row) : undefined;
}
/**
* FNXC:ChatStore 2026-06-24-22:05:
* Update a chat session's mutable fields (title, status, modelProvider,
@@ -518,6 +540,7 @@ export async function updateChatSession(
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: string | null;
pinnedAt?: string | null;
},
): Promise<ChatSession | undefined> {
const existing = await getChatSession(handle, id);
@@ -530,6 +553,10 @@ export async function updateChatSession(
if (input.modelProvider !== undefined) setValues.modelProvider = input.modelProvider;
if (input.modelId !== undefined) setValues.modelId = input.modelId;
if (input.thinkingLevel !== undefined) setValues.thinkingLevel = input.thinkingLevel;
if (input.pinnedAt !== undefined) setValues.pinnedAt = input.pinnedAt;
// FNXC:ChatPinned 2026-07-16-12:00: archiving clears the persisted pin in
// this same update, including callers that bypass archiveChatSession.
if (input.status === "archived") setValues.pinnedAt = null;
await handle
.update(schema.project.chatSessions)

View File

@@ -11,8 +11,7 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import { sql } from "drizzle-orm";
import { asc } from "drizzle-orm";
import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import * as schema from "./postgres/schema/index.js";
import * as asyncChatStore from "./async-chat-store.js";
import type {
@@ -120,6 +119,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
thinkingLevel: input.thinkingLevel ?? null,
createdAt: now,
updatedAt: now,
pinnedAt: null,
cliSessionFile: null,
inFlightGeneration: null,
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
@@ -177,7 +177,22 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
* @returns The updated session, or undefined if not found
*/
async updateSession(id: string, input: ChatSessionUpdateInput): Promise<ChatSession | undefined> {
const updated = await asyncChatStore.updateChatSession(this.asyncLayer.db, id, input);
const update = {
...input,
...(input.status === "archived" ? { pinnedAt: null } : {}),
};
/*
FNXC:ChatPinned 2026-07-16-12:30:
Archive and pin operations lock the target row in their transactions.
This prevents an archive from clearing a pin while a prior pin read later
writes it back, preserving both archived-session pin invariants.
*/
const updated = input.status === "archived"
? await this.asyncLayer.transactionImmediate(async (tx) => {
const session = await asyncChatStore.getChatSessionForUpdate(tx, id);
return session ? asyncChatStore.updateChatSession(tx, id, update) : undefined;
})
: await asyncChatStore.updateChatSession(this.asyncLayer.db, id, update);
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
@@ -190,7 +205,47 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
* @returns The archived session, or undefined if not found
*/
async archiveSession(id: string): Promise<ChatSession | undefined> {
return this.updateSession(id, { status: "archived" });
return this.updateSession(id, { status: "archived", pinnedAt: null });
}
/**
* Pin or unpin a Direct chat session.
*
* FNXC:ChatPinned 2026-07-16-12:00:
* PostgreSQL READ COMMITTED transactions do not serialize count-and-write
* operations. The non-null, namespaced advisory key serializes mutations for
* one scope; null ownerProjectId is counted with isNull and canonicalized as
* `default`, while a real project named default remains collision-safe.
*/
async setSessionPinned(id: string, pinned: boolean, _options?: { projectId?: string }): Promise<ChatSession | undefined> {
const updated = await this.asyncLayer.transactionImmediate(async (tx) => {
const session = await asyncChatStore.getChatSessionForUpdate(tx, id);
if (!session) return undefined;
if (!pinned) return asyncChatStore.updateChatSession(tx, id, { pinnedAt: null });
if (session.status === "archived") {
throw new Error("Archived conversations cannot be pinned");
}
const scopeKey = session.projectId ?? "default";
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`chat-pin:${scopeKey}`}, 0))`);
const scopePredicate = session.projectId === null
? isNull(schema.project.chatSessions.ownerProjectId)
: eq(schema.project.chatSessions.ownerProjectId, session.projectId);
const existingPins = await tx
.select({ id: schema.project.chatSessions.id })
.from(schema.project.chatSessions)
.where(and(
scopePredicate,
eq(schema.project.chatSessions.status, "active"),
isNotNull(schema.project.chatSessions.pinnedAt),
));
if (existingPins.length >= 3 && session.pinnedAt === null) {
throw new Error("You can pin up to 3 conversations per project");
}
return asyncChatStore.updateChatSession(tx, id, { pinnedAt: session.pinnedAt ?? new Date().toISOString() });
});
if (updated) this.emit("chat:session:updated", updated);
return updated;
}
/**

View File

@@ -62,6 +62,13 @@ export interface ChatSession {
createdAt: string;
/** When the session was last updated */
updatedAt: string;
/**
* FNXC:ChatPinned 2026-07-16-12:00:
* Null means unpinned. Active Direct sessions may have at most three pins per
* project scope; null projectId uses the canonical default scope and the store
* serializes pin changes before counting and writing this timestamp.
*/
pinnedAt: string | null;
/**
* Absolute path to the pi/Claude CLI session file backing this chat, if
* any. Set on the first assistant turn (when SessionManager.create
@@ -237,6 +244,8 @@ export interface ChatSessionUpdateInput {
agentId?: string;
/** Thinking/reasoning-effort override */
thinkingLevel?: string | null;
/** Pin timestamp, or null to remove a pin. */
pinnedAt?: string | null;
}
/**

View File

@@ -1273,6 +1273,7 @@ CREATE TABLE IF NOT EXISTS project.chat_sessions (
planning_thinking_level text,
created_at text NOT NULL,
updated_at text NOT NULL,
pinned_at text,
cli_session_file text,
in_flight_generation jsonb,
cli_executor_adapter_id text

View File

@@ -0,0 +1,8 @@
/*
FNXC:ChatPinned 2026-07-16-12:30:
Persist a nullable pin timestamp for Direct conversations. This additive,
idempotent upgrade runs independently so databases that already recorded the
initial schema gain the field before chat-session reads and writes use it.
*/
ALTER TABLE IF EXISTS project.chat_sessions
ADD COLUMN IF NOT EXISTS pinned_at text;

View File

@@ -193,6 +193,9 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ schema?: string; table: s
// ADD COLUMN IF NOT EXISTS on boot (CREATE TABLE IF NOT EXISTS alone never
// upgrades an existing table).
{ table: "chat_sessions", column: "thinking_level", type: "text" },
// FNXC:ChatPinned 2026-07-16-12:00: CREATE TABLE IF NOT EXISTS cannot add
// this nullable persisted Direct-chat pin timestamp to existing embedded DBs.
{ table: "chat_sessions", column: "pinned_at", type: "text" },
{ table: "chat_sessions", column: "validator_thinking_level", type: "text" },
{ table: "chat_sessions", column: "planning_thinking_level", type: "text" },
// FNXC:Settings-ThinkingLevel 2026-07-13 (merge port): sqlite v143-145 additive

View File

@@ -29,9 +29,9 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
/** The latest PostgreSQL schema version known to this applier. */
/*
FNXC:MultiProjectIsolation 2026-07-15-23:40:
Advances to 0011 with the owner_project_id domain/partition split. Per-migration identities above stay fixed; only this latest-version marker moves.
Advances to 0012 after the owner_project_id domain/partition split and chat pin timestamp. Per-migration identities above stay fixed; only this latest-version marker moves.
*/
export const SCHEMA_BASELINE_VERSION = "0011";
export const SCHEMA_BASELINE_VERSION = "0012";
const INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
@@ -66,6 +66,12 @@ the composite FKs (SQLSTATE 23503). Keep this identity fixed when
SCHEMA_BASELINE_VERSION advances.
*/
export const OWNER_PROJECT_ID_SPLIT_VERSION = "0011";
/*
FNXC:ChatPinned 2026-07-16-12:30:
Version 0012 makes the persisted pin timestamp available on databases that
already applied the baseline before Direct conversations can be pinned.
*/
export const CHAT_SESSION_PINS_VERSION = "0012";
/** Bookkeeping table for the fresh Drizzle migration history. */
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
@@ -127,6 +133,11 @@ const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join(
"migrations",
"0011_owner_project_id.sql",
);
const CHAT_SESSION_PINS_MIGRATION_PATH = join(
__dirname,
"migrations",
"0012_chat_session_pins.sql",
);
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -207,6 +218,7 @@ export async function applySchemaBaseline(
const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION);
const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION);
const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION);
const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION);
let schemaChanged = false;
if (!baselineAlreadyApplied) {
@@ -459,6 +471,20 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
/*
FNXC:ChatPinned 2026-07-16-12:30:
Apply the pin timestamp separately from the baseline so all pre-existing
databases can safely read and write Direct chat pins after this rollout.
*/
if (!chatSessionPinsAlreadyApplied) {
const chatSessionPinsSql = await readFile(CHAT_SESSION_PINS_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(chatSessionPinsSql));
await tx.execute(
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CHAT_SESSION_PINS_VERSION}) ON CONFLICT (version) DO NOTHING`,
);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -1715,6 +1715,9 @@ export const chatSessions = projectSchema.table("chat_sessions", {
planningThinkingLevel: text("planning_thinking_level"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
// FNXC:ChatPinned 2026-07-16-12:00: nullable timestamp persists the active
// Direct-session pin; the ChatStore enforces the per-scope max-three invariant.
pinnedAt: text("pinned_at"),
cliSessionFile: text("cli_session_file"),
inFlightGeneration: jsonb("in_flight_generation"),
cliExecutorAdapterId: text("cli_executor_adapter_id"),

View File

@@ -9987,6 +9987,7 @@ export function updateChatSession(
modelId?: string | null;
agentId?: string;
thinkingLevel?: string | null;
pinned?: boolean;
},
projectId?: string,
): Promise<ChatSessionResponse> {

View File

@@ -418,6 +418,31 @@ Direct conversation rows expose edit and delete as a compact action pair. Keep t
color: var(--text);
}
.chat-session-action-btn:disabled,
.chat-mobile-session-pin:disabled {
color: var(--text-dim);
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.chat-session-pinned-indicator {
flex: 0 0 auto;
color: var(--color-warning);
}
.chat-pinned-divider {
display: flex;
align-items: center;
min-height: calc(var(--space-md) * 2);
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
border-bottom: 1px solid var(--border);
}
.chat-session-delete-btn:hover,
.chat-session-delete-btn:focus-visible {
color: var(--color-error);
@@ -696,12 +721,15 @@ Mobile chat session switching needs a dedicated rename tap target beside each se
line-height: normal;
}
.chat-mobile-session-pin,
.chat-mobile-session-rename {
flex-shrink: 0;
align-self: stretch;
color: var(--text-muted);
}
.chat-mobile-session-pin:hover,
.chat-mobile-session-pin:focus-visible,
.chat-mobile-session-rename:hover {
color: var(--text);
background: var(--card-hover);
@@ -753,7 +781,9 @@ Mobile chat session switching needs a dedicated rename tap target beside each se
.chat-mobile-session-option-title {
width: 100%;
display: block;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
line-height: normal;
white-space: normal;
overflow-wrap: anywhere;

View File

@@ -18,6 +18,8 @@ import {
Minimize2,
X,
Hash,
Pin,
PinOff,
} from "lucide-react";
import { FN_AGENT_ID, useChat, type ChatMessageInfo } from "../hooks/useChat";
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
@@ -580,6 +582,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
createSession,
archiveSession,
renameSession,
pinSession,
pinnedCount,
setSessionModel,
setSessionThinkingLevel,
deleteSession,
@@ -2248,6 +2252,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
}
}, [addToast, renameDialog, renameSession, renameTitle, t]);
const handlePin = useCallback(
async (id: string, pinned: boolean) => {
setContextMenu(null);
setMobileSessionMenuOpen(false);
try {
await pinSession(id, pinned);
addToast(pinned ? t("chat.conversationPinned", "Conversation pinned") : t("chat.conversationUnpinned", "Conversation unpinned"), "success");
} catch {
// useChat restores optimistic state and reports the server rejection.
}
},
[addToast, pinSession, t],
);
// Handle delete
const handleDelete = useCallback(
async (id: string) => {
@@ -2925,6 +2943,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
FNXC:ChatHeader 2026-06-22-18:44:
Very narrow chat headers collapse Direct/Rooms to icons while retaining aria-selected tabs and text labels for wider headers. The segmented control must stay height-aligned with the ViewHeader action row, so icon+label markup is stable and CSS hides only the label.
*/
const pinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt != null);
const unpinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt == null);
const contextMenuSession = contextMenu
? filteredSessions.find((session) => session.id === contextMenu.sessionId) ?? (activeSession?.id === contextMenu.sessionId ? activeSession : undefined)
: undefined;
const mobileDirectSessionSwitcher = showMobileSessionSwitcher ? (
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}>
<button
@@ -2945,7 +2969,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</button>
{mobileSessionMenuOpen && (
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
{filteredSessions.map((session) => (
{pinnedFilteredSessions.length > 0 ? <div className="chat-pinned-divider" data-testid="chat-mobile-pinned-divider">{t("chat.pinned", "Pinned")}</div> : null}
{[...pinnedFilteredSessions, ...unpinnedFilteredSessions].map((session) => (
<div
key={session.id}
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
@@ -2958,7 +2983,18 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
data-testid={`chat-mobile-session-option-${session.id}`}
onClick={() => handleSessionClick(session.id)}
>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}{session.pinnedAt ? <Pin className="chat-session-pinned-indicator" size={14} data-testid={`chat-session-pinned-indicator-${session.id}`} aria-label={t("chat.pinned", "Pinned")} /> : null}</span>
</button>
<button
type="button"
className="btn-icon chat-mobile-session-pin"
data-testid={`chat-mobile-session-pin-${session.id}`}
aria-label={session.pinnedAt ? t("chat.unpinConversationAria", "Unpin conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") }) : t("chat.pinConversationAria", "Pin conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") })}
title={!session.pinnedAt && pinnedCount >= 3 ? t("chat.pinLimit", "You can pin up to 3 conversations") : undefined}
disabled={!session.pinnedAt && pinnedCount >= 3}
onClick={() => handlePin(session.id, !session.pinnedAt)}
>
{session.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />}
</button>
<button
type="button"
@@ -3144,7 +3180,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
) : filteredSessions.length === 0 ? (
<div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div>
) : (
filteredSessions.map((session) => {
<>
{/* FNXC:ChatPinned 2026-07-16-12:00: Direct-session pins are grouped on desktop and mobile; the store caps each scope at three. */}
{pinnedFilteredSessions.length > 0 ? <div className="chat-pinned-divider" data-testid="chat-pinned-divider">{t("chat.pinned", "Pinned")}</div> : null}
{filteredSessions.map((session) => {
const isActive = activeSession?.id === session.id;
const showUnreadDot = !isActive && isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt);
const sessionResolvedModel = resolveSessionProvider(
@@ -3171,6 +3210,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
Direct conversation rows need an always-discoverable rename affordance before delete while preserving row selection. Keep edit/delete as sibling buttons that stop propagation and share the existing rename/delete flows.
*/}
<div className="chat-session-actions">
<button
type="button"
className="btn-icon chat-session-action-btn chat-session-pin-btn"
onClick={(e) => {
e.stopPropagation();
void handlePin(session.id, !session.pinnedAt);
}}
data-testid="chat-session-pin-btn"
aria-label={session.pinnedAt ? t("chat.unpinConversationAria", "Unpin conversation {{title}}", { title: sessionTitle }) : t("chat.pinConversationAria", "Pin conversation {{title}}", { title: sessionTitle })}
title={!session.pinnedAt && pinnedCount >= 3 ? t("chat.pinLimit", "You can pin up to 3 conversations") : undefined}
disabled={!session.pinnedAt && pinnedCount >= 3}
>
{session.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />}
</button>
<button
type="button"
className="btn-icon chat-session-action-btn chat-session-rename-btn"
@@ -3198,6 +3251,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
<div className="chat-session-title">
{sessionTitle}
{session.pinnedAt ? <Pin className="chat-session-pinned-indicator" size={14} data-testid={`chat-session-pinned-indicator-${session.id}`} aria-label={t("chat.pinned", "Pinned")} /> : null}
{showUnreadDot ? (
<span
className="chat-unread-dot"
@@ -3226,7 +3280,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
</div>
);
})
})}
</>
)}
</div>
</>
@@ -3367,6 +3422,17 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => handlePin(
contextMenu.sessionId,
!contextMenuSession?.pinnedAt,
)}
data-testid="chat-context-pin"
disabled={pinnedCount >= 3 && !contextMenuSession?.pinnedAt}
>
{contextMenuSession?.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />}
{contextMenuSession?.pinnedAt ? t("chat.unpin", "Unpin") : t("chat.pin", "Pin")}
</button>
<button
onClick={() => openRenameDialog(contextMenu.sessionId)}
data-testid="chat-context-rename"

View File

@@ -73,6 +73,10 @@ function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" |
thinkingLevel: overrides.thinkingLevel ?? null,
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
pinnedAt: overrides.pinnedAt ?? null,
cliSessionFile: null,
cliExecutorAdapterId: null,
inFlightGeneration: null,
};
}
@@ -1022,6 +1026,23 @@ describe("useChat", () => {
expect(addToast).toHaveBeenCalledWith("Failed to rename conversation", "error");
});
it("pins optimistically, reconciles the server timestamp, and sorts pinned sessions first", async () => {
const newer = makeSession({ id: "newer", agentId: "agent-001", updatedAt: "2026-04-10T00:00:00.000Z" });
const older = makeSession({ id: "older", agentId: "agent-001", updatedAt: "2026-04-09T00:00:00.000Z" });
const pinnedAt = "2026-04-08T00:00:00.000Z";
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [newer, older] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockUpdateChatSession.mockResolvedValueOnce({ session: makeSession({ ...older, pinnedAt }) });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
await act(async () => { await result.current.pinSession("older", true); });
expect(mockUpdateChatSession).toHaveBeenCalledWith("older", { pinned: true }, "proj-123");
expect(result.current.sessions.map((session) => session.id)).toEqual(["older", "newer"]);
expect(result.current.pinnedCount).toBe(1);
});
describe("setSessionModel", () => {
it("switches an active session to a model optimistically and reconciles with the server", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001", modelProvider: null, modelId: null });

View File

@@ -27,6 +27,22 @@ const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
export const FN_AGENT_ID = "__fn_agent__";
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
/** FNXC:ChatPinned 2026-07-16-12:00: one comparator keeps refresh, cache,
* optimistic mutations, SSE updates, and search results pinned-first. */
export function compareChatSessions(a: ChatSessionInfo, b: ChatSessionInfo): number {
const aPinned = a.pinnedAt !== null && a.pinnedAt !== undefined;
const bPinned = b.pinnedAt !== null && b.pinnedAt !== undefined;
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const primary = aPinned
? new Date(b.pinnedAt!).getTime() - new Date(a.pinnedAt!).getTime()
: new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
return primary || new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
function sortChatSessions(sessions: ChatSessionInfo[]): ChatSessionInfo[] {
return [...sessions].sort(compareChatSessions);
}
function isTaskPlannerSession(session: ChatSessionInfo): boolean {
return session.agentId.startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX);
}
@@ -43,6 +59,7 @@ export interface ChatSessionInfo {
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: string | null;
pinnedAt?: string | null;
createdAt: string;
updatedAt: string;
lastMessagePreview?: string;
@@ -102,6 +119,8 @@ export interface UseChatReturn {
) => Promise<ChatSessionInfo>;
archiveSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
pinSession: (id: string, pinned: boolean) => Promise<void>;
pinnedCount: number;
setSessionModel: (
id: string,
selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null },
@@ -442,10 +461,7 @@ export function useChat(
}
try {
const data: ChatSessionListResponse = await fetchChatSessions(projectId);
// Sort by updatedAt descending
const sorted = [...data.sessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
const sorted = sortChatSessions(data.sessions);
setSessions(sorted);
const cacheKey = getChatSessionsCacheKey(projectId);
if (cacheKey) {
@@ -466,7 +482,7 @@ export function useChat(
}, [getChatSessionsCacheKey, projectId]);
useEffect(() => {
const cachedSessions = readCachedSessions(projectId);
const cachedSessions = sortChatSessions(readCachedSessions(projectId));
setSessions(cachedSessions);
setSessionsLoading(cachedSessions.length === 0);
}, [projectId, readCachedSessions]);
@@ -954,13 +970,14 @@ export function useChat(
modelProvider: data.session.modelProvider,
modelId: data.session.modelId,
thinkingLevel: data.session.thinkingLevel,
pinnedAt: data.session.pinnedAt,
createdAt: data.session.createdAt,
updatedAt: data.session.updatedAt,
};
setSessions((prev) => {
if (prev.some((s) => s.id === newSession.id)) return prev;
return [newSession, ...prev];
return sortChatSessions([newSession, ...prev]);
});
removePersistedPendingChatMessages(previousSessionId);
@@ -1035,6 +1052,36 @@ export function useChat(
[activeSession, addToast, projectId, sessions],
);
/**
* FNXC:ChatPinned 2026-07-16-12:00:
* Optimistically pin/unpin both displayed session state and the active header;
* the server remains authoritative for the advisory-locked limit and restores
* the prior snapshot when that limit rejects a fourth conversation.
*/
const pinSession = useCallback(
async (id: string, pinned: boolean) => {
const previousSessions = sessions;
const previousActiveSession = activeSession;
const optimisticPinnedAt = pinned ? new Date().toISOString() : null;
setSessions((prev) => sortChatSessions(prev.map((session) =>
session.id === id ? { ...session, pinnedAt: optimisticPinnedAt } : session,
)));
setActiveSession((prev) => prev?.id === id ? { ...prev, pinnedAt: optimisticPinnedAt } : prev);
try {
const data = await updateChatSession(id, { pinned }, projectId);
const patch = { pinnedAt: data.session.pinnedAt, updatedAt: data.session.updatedAt };
setSessions((prev) => sortChatSessions(prev.map((session) => session.id === id ? { ...session, ...patch } : session)));
setActiveSession((prev) => prev?.id === id ? { ...prev, ...patch } : prev);
} catch (error) {
setSessions(previousSessions);
setActiveSession(previousActiveSession);
addToast?.(error instanceof Error ? error.message : "You can pin up to 3 conversations", "error");
throw error;
}
},
[activeSession, addToast, projectId, sessions],
);
/**
* FNXC:Chat-ModelSwitch 2026-07-12-00:00:
* The brain-icon popup can retarget an active direct conversation to either a model pair or a real agent mid-conversation. Optimistically patch both session collections so the next send and visible header use the new persisted target without creating a replacement chat.
@@ -1535,9 +1582,7 @@ export function useChat(
const existing = merged.get(session.id);
merged.set(session.id, { ...(existing ?? session), matchedMessagePreview: preview });
}
return Array.from(merged.values()).sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
return sortChatSessions(Array.from(merged.values()));
})();
useEffect(() => {
@@ -1657,8 +1702,7 @@ export function useChat(
// Avoid duplicates
setSessions((prev) => {
if (prev.some((s) => s.id === session.id)) return prev;
// Add at the top (sessions are sorted by updatedAt desc)
return [session, ...prev];
return sortChatSessions([session, ...prev]);
});
};
@@ -1667,7 +1711,7 @@ export function useChat(
const updatedSession: ChatSessionInfo = JSON.parse(e.data);
setSessions((prev) => {
const updated = prev.map((s) => (s.id === updatedSession.id ? updatedSession : s));
return [...updated];
return sortChatSessions(updated);
});
// If this is the active session, update it too
if (activeSessionRef.current?.id === updatedSession.id) {
@@ -1791,6 +1835,8 @@ export function useChat(
};
}, []);
const pinnedCount = sessions.filter((session) => session.status === "active" && session.pinnedAt != null).length;
return {
sessions,
activeSession,
@@ -1806,6 +1852,8 @@ export function useChat(
createSession,
archiveSession,
renameSession,
pinSession,
pinnedCount,
setSessionModel,
setSessionThinkingLevel,
deleteSession,

View File

@@ -491,7 +491,12 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
* PATCH /api/chat/sessions/:id
* Update a chat session (title, status, thinkingLevel, model, or agent target).
* Body: { title?: string, status?: "active" | "archived", thinkingLevel?: string | null,
* modelProvider?: string | null, modelId?: string | null, agentId?: string }
* modelProvider?: string | null, modelId?: string | null, agentId?: string, pinned?: boolean }
*
* FNXC:ChatPinned 2026-07-16-12:00:
* `pinned` delegates to ChatStore's advisory-lock-protected max-three check.
* Null project sessions use its default scope safely, and archiving clears
* pinnedAt in the same store update so archived sessions cannot retain pins.
*
* FNXC:Chat-ThinkingLevel 2026-07-12-19:30:
* FN-7775 only let a user pick a session's thinking level at creation time
@@ -526,6 +531,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
modelProvider: rawModelProvider,
modelId: rawModelId,
agentId: rawAgentId,
pinned: rawPinned,
} = req.body as {
title?: string;
status?: string;
@@ -533,6 +539,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
modelProvider?: string | null;
modelId?: string | null;
agentId?: string;
pinned?: boolean;
};
// Validate status if provided
@@ -540,6 +547,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw badRequest("status must be 'active' or 'archived'");
}
if (rawPinned !== undefined && typeof rawPinned !== "boolean") {
throw badRequest("pinned must be a boolean");
}
// Normalize thinkingLevel before persisting: undefined leaves the field
// untouched (key omitted below), null/empty-string is an explicit clear
// to inherit the default, and any other value is validated against
@@ -572,7 +583,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
normalizedAgentId = rawAgentId.trim();
}
const session = await chatStore.updateSession(sessionId, {
let session = await chatStore.updateSession(sessionId, {
...(title !== undefined && { title: title?.trim() || null }),
...(status !== undefined && { status }),
...(normalizedThinkingLevel !== undefined && { thinkingLevel: normalizedThinkingLevel }),
@@ -583,6 +594,16 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
if (!session) {
throw notFound(`Chat session ${sessionId} not found`);
}
if (rawPinned !== undefined) {
try {
session = await chatStore.setSessionPinned(sessionId, rawPinned);
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to update conversation pin";
if (message.includes("pin") || message.includes("Archived")) throw badRequest(message);
throw err;
}
if (!session) throw notFound(`Chat session ${sessionId} not found`);
}
res.json({ session });
} catch (err: unknown) {