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:
7
.changeset/fn-8054-pin-conversations.md
Normal file
7
.changeset/fn-8054-pin-conversations.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -9987,6 +9987,7 @@ export function updateChatSession(
|
||||
modelId?: string | null;
|
||||
agentId?: string;
|
||||
thinkingLevel?: string | null;
|
||||
pinned?: boolean;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<ChatSessionResponse> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user