From e87b51bd07ba70ae685cd7da4ce78d6e005c0af9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 02:35:29 -0700 Subject: [PATCH] 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) --- .changeset/fn-8054-pin-conversations.md | 7 ++ docs/dashboard-guide.md | 2 + .../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 +++ .../src/postgres/migrations/0000_initial.sql | 1 + .../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 + .../dashboard/app/components/ChatView.css | 32 +++++++- .../dashboard/app/components/ChatView.tsx | 74 ++++++++++++++++++- .../app/hooks/__tests__/useChat.test.ts | 21 ++++++ packages/dashboard/app/hooks/useChat.ts | 72 +++++++++++++++--- .../src/routes/register-chat-routes.ts | 25 ++++++- 17 files changed, 366 insertions(+), 25 deletions(-) create mode 100644 .changeset/fn-8054-pin-conversations.md create mode 100644 packages/core/src/postgres/migrations/0012_chat_session_pins.sql diff --git a/.changeset/fn-8054-pin-conversations.md b/.changeset/fn-8054-pin-conversations.md new file mode 100644 index 0000000000..3074ae02e5 --- /dev/null +++ b/.changeset/fn-8054-pin-conversations.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 39db24af96..71458385c2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +- 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. - 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. diff --git a/packages/core/src/__tests__/postgres/satellite-db-injected-stores.test.ts b/packages/core/src/__tests__/postgres/satellite-db-injected-stores.test.ts index e284cabad8..2f32d52607 100644 --- a/packages/core/src/__tests__/postgres/satellite-db-injected-stores.test.ts +++ b/packages/core/src/__tests__/postgres/satellite-db-injected-stores.test.ts @@ -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); }); diff --git a/packages/core/src/async-chat-store.ts b/packages/core/src/async-chat-store.ts index 32e040ffe7..8b6ee7216e 100644 --- a/packages/core/src/async-chat-store.ts +++ b/packages/core/src/async-chat-store.ts @@ -51,6 +51,7 @@ function rowToSession(row: Record): 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 { + 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 { 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) diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 5773908658..4141dd8f60 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -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 { 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 { * @returns The updated session, or undefined if not found */ async updateSession(id: string, input: ChatSessionUpdateInput): Promise { - 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 { * @returns The archived session, or undefined if not found */ async archiveSession(id: string): Promise { - 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 { + 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; } /** diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index d611a51af5..dc7625558c 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -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; } /** diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 9ba5fa6258..730717ecad 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -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 diff --git a/packages/core/src/postgres/migrations/0012_chat_session_pins.sql b/packages/core/src/postgres/migrations/0012_chat_session_pins.sql new file mode 100644 index 0000000000..5fa62cca60 --- /dev/null +++ b/packages/core/src/postgres/migrations/0012_chat_session_pins.sql @@ -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; diff --git a/packages/core/src/postgres/postgres-health.ts b/packages/core/src/postgres/postgres-health.ts index 68047b5513..ae6d4baa1c 100644 --- a/packages/core/src/postgres/postgres-health.ts +++ b/packages/core/src/postgres/postgres-health.ts @@ -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 diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 10dac9c19d..1118143e69 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -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 }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 78f4e36fba..e2deab66bb 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -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"), diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 890044b7b5..f988c29176 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -9987,6 +9987,7 @@ export function updateChatSession( modelId?: string | null; agentId?: string; thinkingLevel?: string | null; + pinned?: boolean; }, projectId?: string, ): Promise { diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index d01f5b27e4..f26d66c561 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 2b32173cad..740788e82a 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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 ? (
+
) : ( - 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 ?
{t("chat.pinned", "Pinned")}
: 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. */}
+
@@ -3367,6 +3422,17 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout style={{ top: contextMenu.y, left: contextMenu.x }} onClick={(e) => e.stopPropagation()} > +