@tangle-network/agent-app 0.44.49 → 0.44.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,6 +84,10 @@ declare function createChatTables<TThreadExtras extends Record<string, SQLiteCol
84
84
  parts: drizzle_orm.HasDefault<drizzle_orm.$Type<drizzle_orm_sqlite_core.SQLiteTextJsonBuilderInitial<"parts">, ChatMessagePart[]>>;
85
85
  toolName: drizzle_orm_sqlite_core.SQLiteTextBuilderInitial<"tool_name", [string, ...string[]], number | undefined>;
86
86
  model: drizzle_orm_sqlite_core.SQLiteTextBuilderInitial<"model", [string, ...string[]], number | undefined>;
87
+ requestedModel: drizzle_orm_sqlite_core.SQLiteTextBuilderInitial<"requested_model", [string, ...string[]], number | undefined>;
88
+ servedModel: drizzle_orm_sqlite_core.SQLiteTextBuilderInitial<"served_model", [string, ...string[]], number | undefined>;
89
+ servedProvider: drizzle_orm_sqlite_core.SQLiteTextBuilderInitial<"served_provider", [string, ...string[]], number | undefined>;
90
+ servedSource: drizzle_orm_sqlite_core.SQLiteTextBuilderInitial<"served_model_source", [string, ...string[]], number | undefined>;
87
91
  inputTokens: drizzle_orm_sqlite_core.SQLiteIntegerBuilderInitial<"input_tokens">;
88
92
  outputTokens: drizzle_orm_sqlite_core.SQLiteIntegerBuilderInitial<"output_tokens">;
89
93
  reasoningTokens: drizzle_orm_sqlite_core.SQLiteIntegerBuilderInitial<"reasoning_tokens">;
@@ -187,6 +191,10 @@ interface AppendMessageInput {
187
191
  parts?: ChatMessagePart[];
188
192
  toolName?: string | null;
189
193
  model?: string | null;
194
+ requestedModel?: string | null;
195
+ servedModel?: string | null;
196
+ servedProvider?: string | null;
197
+ servedSource?: string | null;
190
198
  inputTokens?: number | null;
191
199
  outputTokens?: number | null;
192
200
  reasoningTokens?: number | null;
@@ -209,6 +217,10 @@ interface UpdateMessageInput {
209
217
  parts?: ChatMessagePart[];
210
218
  toolName?: string | null;
211
219
  model?: string | null;
220
+ requestedModel?: string | null;
221
+ servedModel?: string | null;
222
+ servedProvider?: string | null;
223
+ servedSource?: string | null;
212
224
  inputTokens?: number | null;
213
225
  outputTokens?: number | null;
214
226
  reasoningTokens?: number | null;
@@ -74,6 +74,10 @@ function createChatTables(options = {}) {
74
74
  parts: text("parts", { mode: "json" }).$type().default([]),
75
75
  toolName: text("tool_name"),
76
76
  model: text("model"),
77
+ requestedModel: text("requested_model"),
78
+ servedModel: text("served_model"),
79
+ servedProvider: text("served_provider"),
80
+ servedSource: text("served_model_source"),
77
81
  // Usage receipt, flattened from the harness's `step-finish` shape
78
82
  // (`tokens {input, output, reasoning, cache{read, write}}` + `cost`).
79
83
  inputTokens: integer("input_tokens"),
@@ -189,6 +193,10 @@ function createChatStore(db, tables) {
189
193
  ...input.parts !== void 0 ? { parts: input.parts } : {},
190
194
  ...input.toolName !== void 0 ? { toolName: input.toolName } : {},
191
195
  ...input.model !== void 0 ? { model: input.model } : {},
196
+ ...input.requestedModel !== void 0 ? { requestedModel: input.requestedModel } : {},
197
+ ...input.servedModel !== void 0 ? { servedModel: input.servedModel } : {},
198
+ ...input.servedProvider !== void 0 ? { servedProvider: input.servedProvider } : {},
199
+ ...input.servedSource !== void 0 ? { servedSource: input.servedSource } : {},
192
200
  ...input.inputTokens !== void 0 ? { inputTokens: input.inputTokens } : {},
193
201
  ...input.outputTokens !== void 0 ? { outputTokens: input.outputTokens } : {},
194
202
  ...input.reasoningTokens !== void 0 ? { reasoningTokens: input.reasoningTokens } : {},
@@ -211,6 +219,10 @@ function createChatStore(db, tables) {
211
219
  ...patch.parts !== void 0 ? { parts: patch.parts } : {},
212
220
  ...patch.toolName !== void 0 ? { toolName: patch.toolName } : {},
213
221
  ...patch.model !== void 0 ? { model: patch.model } : {},
222
+ ...patch.requestedModel !== void 0 ? { requestedModel: patch.requestedModel } : {},
223
+ ...patch.servedModel !== void 0 ? { servedModel: patch.servedModel } : {},
224
+ ...patch.servedProvider !== void 0 ? { servedProvider: patch.servedProvider } : {},
225
+ ...patch.servedSource !== void 0 ? { servedSource: patch.servedSource } : {},
214
226
  ...patch.inputTokens !== void 0 ? { inputTokens: patch.inputTokens } : {},
215
227
  ...patch.outputTokens !== void 0 ? { outputTokens: patch.outputTokens } : {},
216
228
  ...patch.reasoningTokens !== void 0 ? { reasoningTokens: patch.reasoningTokens } : {},
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/chat-store/core.ts","../../src/chat-store/schema.ts","../../src/chat-store/store.ts"],"sourcesContent":["/**\n * Pure (drizzle-free) pieces of the chat store: thread-title derivation, the\n * bulk-delete bound, and the typed input error. Split from `./schema`/`./store`\n * so the root barrel can re-export them without dragging the optional\n * drizzle-orm peer into every root-entry consumer.\n */\n\n/** Bounds a single bulk-delete request's write set; product surfaces cap\n * thread lists at far fewer, so a larger batch is a malformed or hostile\n * request. (Lifted from legal's api.threads.bulk-delete route.) */\nexport const BULK_DELETE_MAX_THREADS = 200\n\n/** Invalid caller input (missing/oversized ids, empty title). Products map it\n * to a 400; anything else out of the store is a real failure. */\nexport class ChatStoreInputError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ChatStoreInputError'\n }\n}\n\n/** Thread titles come from the first message — keep the list scannable by\n * storing only its first non-empty line, capped at 80 chars, never the whole\n * multi-page prompt. (Lifted verbatim from legal's chat.new route.) */\nexport function threadTitleFromMessage(message: string): string {\n const firstLine = message.split('\\n').find((l) => l.trim().length > 0)?.trim() ?? ''\n if (!firstLine) return 'New Thread'\n return firstLine.length > 80 ? `${firstLine.slice(0, 79)}…` : firstLine\n}\n","/**\n * Drizzle schema factory for the chat thread/message tables — the same\n * injection pattern as `createTeamTables`: the product owns the workspace\n * table; the factory wires the thread FK into it so the whole graph lives in\n * one drizzle schema with real cascade semantics. Column names, types,\n * defaults, enums, and indexes mirror legal's and gtm's hand-rolled `thread`/\n * `message` tables so a product with those tables adopts the factory without\n * rewriting rows; `tablePrefix` covers products that namespace (tax's\n * `chat_messages` style).\n *\n * The core is the superset the three products agree on. Divergences dropped,\n * and why:\n * - `thread.status` ('active'|'archived', legal+gtm) — archive semantics\n * diverge (tax uses `archivedAt`); product-domain lifecycle → extra column.\n * - `thread.scopeKind`/`scopeKey`/`harness` (gtm) — artifact anchoring and\n * harness pinning are product-domain → extra columns.\n * - tax's `tax_sessions` session columns (`taxYear`, `projectRef`,\n * `agentSessionId`, `agentRuntime`, `agentHarness`, `profile`, `error`,\n * `userId`) — sandbox-session state, not chat state → extra columns.\n * - `message.toolInput`/`toolOutput` (legal+gtm) — duplicate of the tool\n * part's `state.input`/`state.output` inside `parts` (the shape `/stream`'s\n * `normalizePersistedPart` owns); keeping both invites drift.\n * - `message.vaultFiles` (legal+gtm) — vault is product-domain → extra column.\n * - tax's re-declared `turn_events`/`turn_status` DDL — deliberately NOT here;\n * `/stream`'s turn-buffer owns that DDL (`TURN_BUFFER_D1_SCHEMA_SQL`).\n *\n * Kept beyond the intersection: tax's per-message `model`/`inputTokens`/\n * `outputTokens`, extended to the full usage receipt the harness actually\n * reports in `step-finish` parts (`tokens {input, output, reasoning,\n * cache{read, write}}` + `cost`) — see `./parts`.\n *\n * `threadExtraColumns`/`messageExtraColumns` merge product columns into the\n * table definitions (the `/missions` opaque-extras pattern: the store writes\n * `extras` values verbatim in the SAME insert statement and never reads,\n * validates, or defaults them).\n *\n * SERVER-side module (D1/libsql/better-sqlite3 behind a worker or server\n * route) — but free of `node:` builtins on purpose: D1 workers have none.\n */\n\nimport { sql } from 'drizzle-orm'\nimport { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'\nimport type { AnySQLiteColumn, AnySQLiteTable, SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'\nimport type { ChatMessagePart } from './parts'\n\n/** A product table referenced by FK — only the `id` column is touched. */\nexport type ChatParentTable = AnySQLiteTable & { id: AnySQLiteColumn }\n\n/** Define options to customize chat thread and message table creation including workspace and naming prefixes */\nexport interface CreateChatTablesOptions<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n> {\n /** The product's workspace table — threads reference `workspaceTable.id`\n * with cascade. Omitted: `workspace_id` stays a plain indexed text column\n * (products whose tenant table lives in another database). */\n workspaceTable?: ChatParentTable\n /** Prefixes table AND index names (`'chat_'` → `chat_thread`,\n * `idx_chat_thread_workspace`) for products that namespace chat tables in a\n * shared database. Default: unprefixed `thread`/`message` (legal/gtm row\n * compatibility). */\n tablePrefix?: string\n /** Product columns merged into the thread table (the `/missions` extras\n * pattern) — e.g. a `status` lifecycle enum or gtm's scope columns. */\n threadExtraColumns?: TThreadExtras\n /** Product columns merged into the message table — e.g. legal's\n * `vault_files`. */\n messageExtraColumns?: TMessageExtras\n}\n\nconst hexId = () => text('id').primaryKey().default(sql`(lower(hex(randomblob(16))))`)\n\nconst createdAt = () => integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\nconst updatedAt = () => integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\n/** Build chat-related SQLite tables with customizable thread and message columns */\nexport function createChatTables<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n>(options: CreateChatTablesOptions<TThreadExtras, TMessageExtras> = {}) {\n const { workspaceTable, tablePrefix = '' } = options\n const threadExtras = options.threadExtraColumns ?? ({} as TThreadExtras)\n const messageExtras = options.messageExtraColumns ?? ({} as TMessageExtras)\n\n const threads = sqliteTable(`${tablePrefix}thread`, {\n id: hexId(),\n workspaceId: workspaceTable\n ? text('workspace_id').notNull().references(() => workspaceTable.id, { onDelete: 'cascade' })\n : text('workspace_id').notNull(),\n title: text('title').notNull(),\n category: text('category'),\n isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),\n createdAt: createdAt(),\n updatedAt: updatedAt(),\n ...threadExtras,\n }, (table) => [\n index(`idx_${tablePrefix}thread_workspace`).on(table.workspaceId),\n // Supports the store's list ordering (updatedAt desc within a workspace).\n index(`idx_${tablePrefix}thread_workspace_updated`).on(table.workspaceId, table.updatedAt),\n ])\n\n const messages = sqliteTable(`${tablePrefix}message`, {\n id: hexId(),\n threadId: text('thread_id').notNull().references(() => threads.id, { onDelete: 'cascade' }),\n role: text('role', { enum: ['user', 'assistant', 'system', 'tool'] }).notNull(),\n content: text('content').notNull(),\n parts: text('parts', { mode: 'json' }).$type<ChatMessagePart[]>().default([]),\n toolName: text('tool_name'),\n model: text('model'),\n // Usage receipt, flattened from the harness's `step-finish` shape\n // (`tokens {input, output, reasoning, cache{read, write}}` + `cost`).\n inputTokens: integer('input_tokens'),\n outputTokens: integer('output_tokens'),\n reasoningTokens: integer('reasoning_tokens'),\n cacheReadTokens: integer('cache_read_tokens'),\n cacheWriteTokens: integer('cache_write_tokens'),\n costUsd: real('cost_usd'),\n createdAt: createdAt(),\n ...messageExtras,\n }, (table) => [\n index(`idx_${tablePrefix}message_thread`).on(table.threadId),\n index(`idx_${tablePrefix}message_thread_created`).on(table.threadId, table.createdAt),\n ])\n\n return { threads, messages }\n}\n\n/**\n * The base (no-extras) table pair, pinned via an instantiation expression:\n * `ReturnType<typeof createChatTables>` on the bare generic substitutes the\n * extras params with their CONSTRAINT (`Record<string,\n * SQLiteColumnBuilderBase>`), stamping an index signature into the column map\n * that widens every concrete column to `unknown`/`notNull: false` — concrete\n * factory results then fail `extends ChatTables`. (`teams`' `createTeamTables`\n * is non-generic, so its plain `ReturnType` never hits this.)\n */\nexport type ChatTables = ReturnType<typeof createChatTables<{}, {}>>\n\n/** Resolve the selected fields of a chat thread row from the chat threads table */\nexport type ChatThreadRow = ChatTables['threads']['$inferSelect']\n/** Resolve the selected structure of a chat message row from the messages table */\nexport type ChatMessageRow = ChatTables['messages']['$inferSelect']\n/** Resolve the type for inserting a new chat thread row into the threads table */\nexport type NewChatThreadRow = ChatTables['threads']['$inferInsert']\n/** Resolve the type for inserting a new chat message row into the messages table */\nexport type NewChatMessageRow = ChatTables['messages']['$inferInsert']\n","/**\n * Typed CRUD over the tables from `createChatTables`. Works against any\n * SQLite drizzle driver (D1, libsql, better-sqlite3) — builders are awaited,\n * never `.run()`/`.all()`, so sync and async drivers behave identically.\n *\n * Access control is an injected seam, never an import: single-thread routes\n * check workspace access themselves (they know the thread), while\n * `bulkDeleteThreads` REQUIRES an `assertAccess` callback because one request\n * can span workspaces — it is called once per distinct workspace and any\n * throw rejects the whole request before a single delete runs (fail-closed;\n * legal's bulk-delete semantics).\n *\n * Deletes run messages-first in ONE `db.batch` round trip when the driver has\n * one (D1, libsql), so a partial failure never leaves orphaned rows behind a\n * deleted thread; drivers without `batch` (better-sqlite3) fall back to\n * sequential awaits in the same order.\n */\n\nimport { asc, desc, eq, inArray, sql } from 'drizzle-orm'\nimport type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'\nimport {\n runSqliteStatements,\n type SqliteBatchDatabase,\n} from '../store'\nimport { BULK_DELETE_MAX_THREADS, ChatStoreInputError, threadTitleFromMessage } from './core'\nimport type { ChatMessagePart } from './parts'\nimport type { ChatMessageRow, ChatTables, ChatThreadRow, NewChatMessageRow, NewChatThreadRow } from './schema'\n\n/** Any SQLite drizzle database — `any` erases the driver-specific run-result\n * and schema generics so better-sqlite3, D1, and libsql handles all fit.\n * `batch` is structural: present on D1/libsql drizzle instances. */\nexport type ChatDatabase = BaseSQLiteDatabase<'sync' | 'async', any, any> &\n SqliteBatchDatabase\n\n/** Product-injected access check. Throw to deny; the store never interprets\n * users or roles itself. */\nexport type WorkspaceAccessCheck = (workspaceId: string) => void | Promise<void>\n\n/** Define input parameters for listing threads within a workspace with pagination options */\nexport interface ListThreadsInput {\n workspaceId: string\n /** Clamped to 1..200; default 50 (legal's list route semantics). */\n limit?: number\n /** Clamped to >= 0; default 0. */\n offset?: number\n}\n\n/** Represent a paginated collection of chat threads with total count and pagination details */\nexport interface ListThreadsResult<TThread = ChatThreadRow> {\n threads: TThread[]\n total: number\n limit: number\n offset: number\n}\n\n/** Define input parameters required to create a new thread in a workspace */\nexport interface CreateThreadInput {\n workspaceId: string\n /** Title source when `title` is absent: first non-empty line, 80-char cap\n * (`threadTitleFromMessage`). */\n firstMessage?: string\n /** Explicit title; still normalized through `threadTitleFromMessage` so a\n * multi-page paste never becomes a sidebar entry. */\n title?: string\n category?: string | null\n isPinned?: boolean\n /** Opaque product-column values written verbatim in the SAME insert (the\n * `/missions` extras pattern). Never read, validated, or defaulted here. */\n extras?: Record<string, unknown>\n}\n\n/** Define input parameters for appending a message to a chat thread with optional metadata */\nexport interface AppendMessageInput {\n /** Caller-assigned primary key. Omitted, the column default assigns a random\n * hex id (today's behavior). Incremental assistant persistence passes a\n * DETERMINISTIC id derived from the turn's own identity, so a re-entered\n * turn (crashed worker, durable-driver retry) finds and updates the row a\n * previous attempt started instead of inserting a second one. */\n id?: string\n threadId: string\n role: 'user' | 'assistant' | 'system' | 'tool'\n content: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME insert. */\n extras?: Record<string, unknown>\n}\n\n/** Fields an existing message row may be patched with. Every field is\n * optional and only DEFINED fields are written, so a partial patch never\n * clears a column it does not mention. `threadId` and `role` are absent on\n * purpose: a message never moves thread or changes speaker.\n *\n * Exists for incremental assistant persistence — the streaming turn writes\n * the row once and then patches it as content accumulates, so the durable\n * transcript is at most one cadence interval behind the live stream. */\nexport interface UpdateMessageInput {\n content?: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME update. */\n extras?: Record<string, unknown>\n}\n\n/** Define options to configure message listing with optional limit and offset parameters */\nexport interface ListMessagesOptions {\n limit?: number\n offset?: number\n}\n\n/** Define input for bulk deleting threads with access checks per workspace */\nexport interface BulkDeleteThreadsInput {\n ids: string[]\n /** Called once per distinct workspace the ids touch, before ANY delete. */\n assertAccess: WorkspaceAccessCheck\n}\n\n/** Manage chat threads and messages with operations for listing, creating, updating, and deleting data */\nexport interface ChatStore<TThread = ChatThreadRow, TMessage = ChatMessageRow> {\n listThreads(input: ListThreadsInput): Promise<ListThreadsResult<TThread>>\n getThread(threadId: string): Promise<TThread | null>\n createThread(input: CreateThreadInput): Promise<TThread>\n renameThread(threadId: string, title: string): Promise<TThread | null>\n pinThread(threadId: string, isPinned: boolean): Promise<TThread | null>\n /** Messages + thread in one batch. Resolves false when the thread does not\n * exist. `assertAccess` (optional) receives the thread's workspaceId before\n * the delete — single-thread callers usually check access themselves. */\n deleteThread(threadId: string, options?: { assertAccess?: WorkspaceAccessCheck }): Promise<boolean>\n bulkDeleteThreads(input: BulkDeleteThreadsInput): Promise<{ deleted: number }>\n /** Ordered oldest-first: `created_at`, then rowid (insertion order within a\n * same-second burst — a user+assistant pair lands in one epoch second). */\n listMessages(threadId: string, options?: ListMessagesOptions): Promise<TMessage[]>\n /** Inserts the message and bumps the thread's `updatedAt` in one batch so\n * workspace recency sorts stay truthful. */\n appendMessage(input: AppendMessageInput): Promise<TMessage>\n /** Patches an existing message and bumps its thread's `updatedAt` in the\n * same batch. Resolves `null` when the id does not exist. Only defined\n * patch fields are written. */\n updateMessage(id: string, patch: UpdateMessageInput): Promise<TMessage | null>\n /** Removes one message. Resolves false when the id does not exist. Used by\n * incremental persistence to retract a draft row for a turn that ended\n * producing nothing, so an empty assistant row is never left behind. */\n deleteMessage(id: string): Promise<boolean>\n}\n\nfunction clampLimit(limit: number | undefined, fallback: number, max: number): number {\n const value = Number.isFinite(limit) ? Math.trunc(limit as number) : fallback\n return Math.min(Math.max(value, 1), max)\n}\n\nfunction clampOffset(offset: number | undefined): number {\n const value = Number.isFinite(offset) ? Math.trunc(offset as number) : 0\n return Math.max(value, 0)\n}\n\n/** Create a chat store managing threads and messages based on the provided database and tables */\nexport function createChatStore<TTables extends ChatTables>(\n db: ChatDatabase,\n tables: TTables,\n): ChatStore<TTables['threads']['$inferSelect'], TTables['messages']['$inferSelect']> {\n type TThread = TTables['threads']['$inferSelect']\n type TMessage = TTables['messages']['$inferSelect']\n const threads = tables.threads as ChatTables['threads']\n const messages = tables.messages as ChatTables['messages']\n\n return {\n async listThreads(input) {\n const limit = clampLimit(input.limit, 50, 200)\n const offset = clampOffset(input.offset)\n const scope = eq(threads.workspaceId, input.workspaceId)\n const [list, [countRow]] = await Promise.all([\n db.select().from(threads).where(scope)\n // `id` tiebreak keeps pagination stable across same-second updates.\n .orderBy(desc(threads.updatedAt), asc(threads.id))\n .limit(limit)\n .offset(offset),\n db.select({ total: sql<number>`count(*)` }).from(threads).where(scope),\n ])\n return { threads: list as TThread[], total: countRow?.total ?? 0, limit, offset }\n },\n\n async getThread(threadId) {\n const [row] = await db.select().from(threads).where(eq(threads.id, threadId)).limit(1)\n return (row as TThread | undefined) ?? null\n },\n\n async createThread(input) {\n const title = threadTitleFromMessage(input.title ?? input.firstMessage ?? '')\n const values = {\n workspaceId: input.workspaceId,\n title,\n ...(input.category !== undefined ? { category: input.category } : {}),\n ...(input.isPinned !== undefined ? { isPinned: input.isPinned } : {}),\n ...(input.extras ?? {}),\n } as NewChatThreadRow\n const [row] = await db.insert(threads).values(values).returning()\n if (!row) throw new Error('thread insert returned no row')\n return row as TThread\n },\n\n async renameThread(threadId, title) {\n const trimmed = title.trim()\n if (!trimmed) throw new ChatStoreInputError('Missing title')\n const [row] = await db.update(threads)\n .set({ title: trimmed, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async pinThread(threadId, isPinned) {\n const [row] = await db.update(threads)\n .set({ isPinned, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async deleteThread(threadId, options) {\n const [existing] = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(eq(threads.id, threadId))\n .limit(1)\n if (!existing) return false\n if (options?.assertAccess) await options.assertAccess(existing.workspaceId)\n // Messages first so a partial failure never leaves orphaned rows behind\n // a deleted thread.\n await runSqliteStatements(db, [\n db.delete(messages).where(eq(messages.threadId, threadId)),\n db.delete(threads).where(eq(threads.id, threadId)),\n ])\n return true\n },\n\n async bulkDeleteThreads(input) {\n const { ids, assertAccess } = input\n if (typeof assertAccess !== 'function') throw new ChatStoreInputError('Missing assertAccess')\n if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string' && id.length > 0)) {\n throw new ChatStoreInputError('Missing ids')\n }\n if (ids.length > BULK_DELETE_MAX_THREADS) {\n throw new ChatStoreInputError(`Too many ids (max ${BULK_DELETE_MAX_THREADS})`)\n }\n\n const rows = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(inArray(threads.id, ids))\n if (rows.length === 0) return { deleted: 0 }\n\n // Access is verified once per workspace the ids touch. Fail-closed: one\n // inaccessible workspace rejects the whole request before any delete.\n // Sorted so the check order (and therefore which denial surfaces) is\n // deterministic — row order follows random hex ids and varies per run.\n const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort()\n for (const workspaceId of workspaceIds) {\n await assertAccess(workspaceId)\n }\n\n const foundIds = rows.map((row) => row.id)\n await runSqliteStatements(db, [\n db.delete(messages).where(inArray(messages.threadId, foundIds)),\n db.delete(threads).where(inArray(threads.id, foundIds)),\n ])\n return { deleted: foundIds.length }\n },\n\n async listMessages(threadId, options) {\n const query = db.select().from(messages)\n .where(eq(messages.threadId, threadId))\n .orderBy(asc(messages.createdAt), sql`rowid`)\n .$dynamic()\n if (options?.limit !== undefined) query.limit(clampLimit(options.limit, 1, 1000))\n if (options?.offset !== undefined) query.offset(clampOffset(options.offset))\n return await query as TMessage[]\n },\n\n async appendMessage(input) {\n const values = {\n ...(input.id !== undefined ? { id: input.id } : {}),\n threadId: input.threadId,\n role: input.role,\n content: input.content,\n ...(input.parts !== undefined ? { parts: input.parts } : {}),\n ...(input.toolName !== undefined ? { toolName: input.toolName } : {}),\n ...(input.model !== undefined ? { model: input.model } : {}),\n ...(input.inputTokens !== undefined ? { inputTokens: input.inputTokens } : {}),\n ...(input.outputTokens !== undefined ? { outputTokens: input.outputTokens } : {}),\n ...(input.reasoningTokens !== undefined ? { reasoningTokens: input.reasoningTokens } : {}),\n ...(input.cacheReadTokens !== undefined ? { cacheReadTokens: input.cacheReadTokens } : {}),\n ...(input.cacheWriteTokens !== undefined ? { cacheWriteTokens: input.cacheWriteTokens } : {}),\n ...(input.costUsd !== undefined ? { costUsd: input.costUsd } : {}),\n ...(input.extras ?? {}),\n } as NewChatMessageRow\n const [insertResult] = await runSqliteStatements(db, [\n db.insert(messages).values(values).returning(),\n db.update(threads).set({ updatedAt: new Date() }).where(eq(threads.id, input.threadId)),\n ])\n const row = (insertResult as TMessage[] | undefined)?.[0]\n if (!row) throw new Error('message insert returned no row')\n return row\n },\n\n async updateMessage(id, patch) {\n const values = {\n ...(patch.content !== undefined ? { content: patch.content } : {}),\n ...(patch.parts !== undefined ? { parts: patch.parts } : {}),\n ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),\n ...(patch.model !== undefined ? { model: patch.model } : {}),\n ...(patch.inputTokens !== undefined ? { inputTokens: patch.inputTokens } : {}),\n ...(patch.outputTokens !== undefined ? { outputTokens: patch.outputTokens } : {}),\n ...(patch.reasoningTokens !== undefined ? { reasoningTokens: patch.reasoningTokens } : {}),\n ...(patch.cacheReadTokens !== undefined ? { cacheReadTokens: patch.cacheReadTokens } : {}),\n ...(patch.cacheWriteTokens !== undefined ? { cacheWriteTokens: patch.cacheWriteTokens } : {}),\n ...(patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {}),\n ...(patch.extras ?? {}),\n }\n if (Object.keys(values).length === 0) {\n const [current] = await db.select().from(messages).where(eq(messages.id, id))\n return (current as TMessage | undefined) ?? null\n }\n // The thread bump reads the row's own `thread_id` rather than trusting a\n // caller-supplied one: a message never moves thread, so the subquery is\n // the authoritative source and one fewer parameter to get wrong.\n const [updateResult] = await runSqliteStatements(db, [\n db.update(messages).set(values).where(eq(messages.id, id)).returning(),\n db.update(threads).set({ updatedAt: new Date() })\n .where(eq(threads.id, sql`(select ${messages.threadId} from ${messages} where ${messages.id} = ${id})`)),\n ])\n return (updateResult as TMessage[] | undefined)?.[0] ?? null\n },\n\n async deleteMessage(id) {\n const deleted = await db.delete(messages).where(eq(messages.id, id)).returning()\n return (deleted as unknown[]).length > 0\n },\n }\n}\n"],"mappings":"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","names":["sql","sql"]}
1
+ {"version":3,"sources":["../../src/chat-store/core.ts","../../src/chat-store/schema.ts","../../src/chat-store/store.ts"],"sourcesContent":["/**\n * Pure (drizzle-free) pieces of the chat store: thread-title derivation, the\n * bulk-delete bound, and the typed input error. Split from `./schema`/`./store`\n * so the root barrel can re-export them without dragging the optional\n * drizzle-orm peer into every root-entry consumer.\n */\n\n/** Bounds a single bulk-delete request's write set; product surfaces cap\n * thread lists at far fewer, so a larger batch is a malformed or hostile\n * request. (Lifted from legal's api.threads.bulk-delete route.) */\nexport const BULK_DELETE_MAX_THREADS = 200\n\n/** Invalid caller input (missing/oversized ids, empty title). Products map it\n * to a 400; anything else out of the store is a real failure. */\nexport class ChatStoreInputError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ChatStoreInputError'\n }\n}\n\n/** Thread titles come from the first message — keep the list scannable by\n * storing only its first non-empty line, capped at 80 chars, never the whole\n * multi-page prompt. (Lifted verbatim from legal's chat.new route.) */\nexport function threadTitleFromMessage(message: string): string {\n const firstLine = message.split('\\n').find((l) => l.trim().length > 0)?.trim() ?? ''\n if (!firstLine) return 'New Thread'\n return firstLine.length > 80 ? `${firstLine.slice(0, 79)}…` : firstLine\n}\n","/**\n * Drizzle schema factory for the chat thread/message tables — the same\n * injection pattern as `createTeamTables`: the product owns the workspace\n * table; the factory wires the thread FK into it so the whole graph lives in\n * one drizzle schema with real cascade semantics. Column names, types,\n * defaults, enums, and indexes mirror legal's and gtm's hand-rolled `thread`/\n * `message` tables so a product with those tables adopts the factory without\n * rewriting rows; `tablePrefix` covers products that namespace (tax's\n * `chat_messages` style).\n *\n * The core is the superset the three products agree on. Divergences dropped,\n * and why:\n * - `thread.status` ('active'|'archived', legal+gtm) — archive semantics\n * diverge (tax uses `archivedAt`); product-domain lifecycle → extra column.\n * - `thread.scopeKind`/`scopeKey`/`harness` (gtm) — artifact anchoring and\n * harness pinning are product-domain → extra columns.\n * - tax's `tax_sessions` session columns (`taxYear`, `projectRef`,\n * `agentSessionId`, `agentRuntime`, `agentHarness`, `profile`, `error`,\n * `userId`) — sandbox-session state, not chat state → extra columns.\n * - `message.toolInput`/`toolOutput` (legal+gtm) — duplicate of the tool\n * part's `state.input`/`state.output` inside `parts` (the shape `/stream`'s\n * `normalizePersistedPart` owns); keeping both invites drift.\n * - `message.vaultFiles` (legal+gtm) — vault is product-domain → extra column.\n * - tax's re-declared `turn_events`/`turn_status` DDL — deliberately NOT here;\n * `/stream`'s turn-buffer owns that DDL (`TURN_BUFFER_D1_SCHEMA_SQL`).\n *\n * Kept beyond the intersection: tax's per-message `model`/`inputTokens`/\n * `outputTokens`, extended to the full usage receipt the harness actually\n * reports in `step-finish` parts (`tokens {input, output, reasoning,\n * cache{read, write}}` + `cost`) — see `./parts`.\n *\n * `threadExtraColumns`/`messageExtraColumns` merge product columns into the\n * table definitions (the `/missions` opaque-extras pattern: the store writes\n * `extras` values verbatim in the SAME insert statement and never reads,\n * validates, or defaults them).\n *\n * SERVER-side module (D1/libsql/better-sqlite3 behind a worker or server\n * route) — but free of `node:` builtins on purpose: D1 workers have none.\n */\n\nimport { sql } from 'drizzle-orm'\nimport { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'\nimport type { AnySQLiteColumn, AnySQLiteTable, SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'\nimport type { ChatMessagePart } from './parts'\n\n/** A product table referenced by FK — only the `id` column is touched. */\nexport type ChatParentTable = AnySQLiteTable & { id: AnySQLiteColumn }\n\n/** Define options to customize chat thread and message table creation including workspace and naming prefixes */\nexport interface CreateChatTablesOptions<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n> {\n /** The product's workspace table — threads reference `workspaceTable.id`\n * with cascade. Omitted: `workspace_id` stays a plain indexed text column\n * (products whose tenant table lives in another database). */\n workspaceTable?: ChatParentTable\n /** Prefixes table AND index names (`'chat_'` → `chat_thread`,\n * `idx_chat_thread_workspace`) for products that namespace chat tables in a\n * shared database. Default: unprefixed `thread`/`message` (legal/gtm row\n * compatibility). */\n tablePrefix?: string\n /** Product columns merged into the thread table (the `/missions` extras\n * pattern) — e.g. a `status` lifecycle enum or gtm's scope columns. */\n threadExtraColumns?: TThreadExtras\n /** Product columns merged into the message table — e.g. legal's\n * `vault_files`. */\n messageExtraColumns?: TMessageExtras\n}\n\nconst hexId = () => text('id').primaryKey().default(sql`(lower(hex(randomblob(16))))`)\n\nconst createdAt = () => integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\nconst updatedAt = () => integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`)\n\n/** Build chat-related SQLite tables with customizable thread and message columns */\nexport function createChatTables<\n TThreadExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n TMessageExtras extends Record<string, SQLiteColumnBuilderBase> = {},\n>(options: CreateChatTablesOptions<TThreadExtras, TMessageExtras> = {}) {\n const { workspaceTable, tablePrefix = '' } = options\n const threadExtras = options.threadExtraColumns ?? ({} as TThreadExtras)\n const messageExtras = options.messageExtraColumns ?? ({} as TMessageExtras)\n\n const threads = sqliteTable(`${tablePrefix}thread`, {\n id: hexId(),\n workspaceId: workspaceTable\n ? text('workspace_id').notNull().references(() => workspaceTable.id, { onDelete: 'cascade' })\n : text('workspace_id').notNull(),\n title: text('title').notNull(),\n category: text('category'),\n isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),\n createdAt: createdAt(),\n updatedAt: updatedAt(),\n ...threadExtras,\n }, (table) => [\n index(`idx_${tablePrefix}thread_workspace`).on(table.workspaceId),\n // Supports the store's list ordering (updatedAt desc within a workspace).\n index(`idx_${tablePrefix}thread_workspace_updated`).on(table.workspaceId, table.updatedAt),\n ])\n\n const messages = sqliteTable(`${tablePrefix}message`, {\n id: hexId(),\n threadId: text('thread_id').notNull().references(() => threads.id, { onDelete: 'cascade' }),\n role: text('role', { enum: ['user', 'assistant', 'system', 'tool'] }).notNull(),\n content: text('content').notNull(),\n parts: text('parts', { mode: 'json' }).$type<ChatMessagePart[]>().default([]),\n toolName: text('tool_name'),\n model: text('model'),\n requestedModel: text('requested_model'),\n servedModel: text('served_model'),\n servedProvider: text('served_provider'),\n servedSource: text('served_model_source'),\n // Usage receipt, flattened from the harness's `step-finish` shape\n // (`tokens {input, output, reasoning, cache{read, write}}` + `cost`).\n inputTokens: integer('input_tokens'),\n outputTokens: integer('output_tokens'),\n reasoningTokens: integer('reasoning_tokens'),\n cacheReadTokens: integer('cache_read_tokens'),\n cacheWriteTokens: integer('cache_write_tokens'),\n costUsd: real('cost_usd'),\n createdAt: createdAt(),\n ...messageExtras,\n }, (table) => [\n index(`idx_${tablePrefix}message_thread`).on(table.threadId),\n index(`idx_${tablePrefix}message_thread_created`).on(table.threadId, table.createdAt),\n ])\n\n return { threads, messages }\n}\n\n/**\n * The base (no-extras) table pair, pinned via an instantiation expression:\n * `ReturnType<typeof createChatTables>` on the bare generic substitutes the\n * extras params with their CONSTRAINT (`Record<string,\n * SQLiteColumnBuilderBase>`), stamping an index signature into the column map\n * that widens every concrete column to `unknown`/`notNull: false` — concrete\n * factory results then fail `extends ChatTables`. (`teams`' `createTeamTables`\n * is non-generic, so its plain `ReturnType` never hits this.)\n */\nexport type ChatTables = ReturnType<typeof createChatTables<{}, {}>>\n\n/** Resolve the selected fields of a chat thread row from the chat threads table */\nexport type ChatThreadRow = ChatTables['threads']['$inferSelect']\n/** Resolve the selected structure of a chat message row from the messages table */\nexport type ChatMessageRow = ChatTables['messages']['$inferSelect']\n/** Resolve the type for inserting a new chat thread row into the threads table */\nexport type NewChatThreadRow = ChatTables['threads']['$inferInsert']\n/** Resolve the type for inserting a new chat message row into the messages table */\nexport type NewChatMessageRow = ChatTables['messages']['$inferInsert']\n","/**\n * Typed CRUD over the tables from `createChatTables`. Works against any\n * SQLite drizzle driver (D1, libsql, better-sqlite3) — builders are awaited,\n * never `.run()`/`.all()`, so sync and async drivers behave identically.\n *\n * Access control is an injected seam, never an import: single-thread routes\n * check workspace access themselves (they know the thread), while\n * `bulkDeleteThreads` REQUIRES an `assertAccess` callback because one request\n * can span workspaces — it is called once per distinct workspace and any\n * throw rejects the whole request before a single delete runs (fail-closed;\n * legal's bulk-delete semantics).\n *\n * Deletes run messages-first in ONE `db.batch` round trip when the driver has\n * one (D1, libsql), so a partial failure never leaves orphaned rows behind a\n * deleted thread; drivers without `batch` (better-sqlite3) fall back to\n * sequential awaits in the same order.\n */\n\nimport { asc, desc, eq, inArray, sql } from 'drizzle-orm'\nimport type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'\nimport {\n runSqliteStatements,\n type SqliteBatchDatabase,\n} from '../store'\nimport { BULK_DELETE_MAX_THREADS, ChatStoreInputError, threadTitleFromMessage } from './core'\nimport type { ChatMessagePart } from './parts'\nimport type { ChatMessageRow, ChatTables, ChatThreadRow, NewChatMessageRow, NewChatThreadRow } from './schema'\n\n/** Any SQLite drizzle database — `any` erases the driver-specific run-result\n * and schema generics so better-sqlite3, D1, and libsql handles all fit.\n * `batch` is structural: present on D1/libsql drizzle instances. */\nexport type ChatDatabase = BaseSQLiteDatabase<'sync' | 'async', any, any> &\n SqliteBatchDatabase\n\n/** Product-injected access check. Throw to deny; the store never interprets\n * users or roles itself. */\nexport type WorkspaceAccessCheck = (workspaceId: string) => void | Promise<void>\n\n/** Define input parameters for listing threads within a workspace with pagination options */\nexport interface ListThreadsInput {\n workspaceId: string\n /** Clamped to 1..200; default 50 (legal's list route semantics). */\n limit?: number\n /** Clamped to >= 0; default 0. */\n offset?: number\n}\n\n/** Represent a paginated collection of chat threads with total count and pagination details */\nexport interface ListThreadsResult<TThread = ChatThreadRow> {\n threads: TThread[]\n total: number\n limit: number\n offset: number\n}\n\n/** Define input parameters required to create a new thread in a workspace */\nexport interface CreateThreadInput {\n workspaceId: string\n /** Title source when `title` is absent: first non-empty line, 80-char cap\n * (`threadTitleFromMessage`). */\n firstMessage?: string\n /** Explicit title; still normalized through `threadTitleFromMessage` so a\n * multi-page paste never becomes a sidebar entry. */\n title?: string\n category?: string | null\n isPinned?: boolean\n /** Opaque product-column values written verbatim in the SAME insert (the\n * `/missions` extras pattern). Never read, validated, or defaulted here. */\n extras?: Record<string, unknown>\n}\n\n/** Define input parameters for appending a message to a chat thread with optional metadata */\nexport interface AppendMessageInput {\n /** Caller-assigned primary key. Omitted, the column default assigns a random\n * hex id (today's behavior). Incremental assistant persistence passes a\n * DETERMINISTIC id derived from the turn's own identity, so a re-entered\n * turn (crashed worker, durable-driver retry) finds and updates the row a\n * previous attempt started instead of inserting a second one. */\n id?: string\n threadId: string\n role: 'user' | 'assistant' | 'system' | 'tool'\n content: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n requestedModel?: string | null\n servedModel?: string | null\n servedProvider?: string | null\n servedSource?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME insert. */\n extras?: Record<string, unknown>\n}\n\n/** Fields an existing message row may be patched with. Every field is\n * optional and only DEFINED fields are written, so a partial patch never\n * clears a column it does not mention. `threadId` and `role` are absent on\n * purpose: a message never moves thread or changes speaker.\n *\n * Exists for incremental assistant persistence — the streaming turn writes\n * the row once and then patches it as content accumulates, so the durable\n * transcript is at most one cadence interval behind the live stream. */\nexport interface UpdateMessageInput {\n content?: string\n parts?: ChatMessagePart[]\n toolName?: string | null\n model?: string | null\n requestedModel?: string | null\n servedModel?: string | null\n servedProvider?: string | null\n servedSource?: string | null\n inputTokens?: number | null\n outputTokens?: number | null\n reasoningTokens?: number | null\n cacheReadTokens?: number | null\n cacheWriteTokens?: number | null\n costUsd?: number | null\n /** Opaque product-column values written verbatim in the SAME update. */\n extras?: Record<string, unknown>\n}\n\n/** Define options to configure message listing with optional limit and offset parameters */\nexport interface ListMessagesOptions {\n limit?: number\n offset?: number\n}\n\n/** Define input for bulk deleting threads with access checks per workspace */\nexport interface BulkDeleteThreadsInput {\n ids: string[]\n /** Called once per distinct workspace the ids touch, before ANY delete. */\n assertAccess: WorkspaceAccessCheck\n}\n\n/** Manage chat threads and messages with operations for listing, creating, updating, and deleting data */\nexport interface ChatStore<TThread = ChatThreadRow, TMessage = ChatMessageRow> {\n listThreads(input: ListThreadsInput): Promise<ListThreadsResult<TThread>>\n getThread(threadId: string): Promise<TThread | null>\n createThread(input: CreateThreadInput): Promise<TThread>\n renameThread(threadId: string, title: string): Promise<TThread | null>\n pinThread(threadId: string, isPinned: boolean): Promise<TThread | null>\n /** Messages + thread in one batch. Resolves false when the thread does not\n * exist. `assertAccess` (optional) receives the thread's workspaceId before\n * the delete — single-thread callers usually check access themselves. */\n deleteThread(threadId: string, options?: { assertAccess?: WorkspaceAccessCheck }): Promise<boolean>\n bulkDeleteThreads(input: BulkDeleteThreadsInput): Promise<{ deleted: number }>\n /** Ordered oldest-first: `created_at`, then rowid (insertion order within a\n * same-second burst — a user+assistant pair lands in one epoch second). */\n listMessages(threadId: string, options?: ListMessagesOptions): Promise<TMessage[]>\n /** Inserts the message and bumps the thread's `updatedAt` in one batch so\n * workspace recency sorts stay truthful. */\n appendMessage(input: AppendMessageInput): Promise<TMessage>\n /** Patches an existing message and bumps its thread's `updatedAt` in the\n * same batch. Resolves `null` when the id does not exist. Only defined\n * patch fields are written. */\n updateMessage(id: string, patch: UpdateMessageInput): Promise<TMessage | null>\n /** Removes one message. Resolves false when the id does not exist. Used by\n * incremental persistence to retract a draft row for a turn that ended\n * producing nothing, so an empty assistant row is never left behind. */\n deleteMessage(id: string): Promise<boolean>\n}\n\nfunction clampLimit(limit: number | undefined, fallback: number, max: number): number {\n const value = Number.isFinite(limit) ? Math.trunc(limit as number) : fallback\n return Math.min(Math.max(value, 1), max)\n}\n\nfunction clampOffset(offset: number | undefined): number {\n const value = Number.isFinite(offset) ? Math.trunc(offset as number) : 0\n return Math.max(value, 0)\n}\n\n/** Create a chat store managing threads and messages based on the provided database and tables */\nexport function createChatStore<TTables extends ChatTables>(\n db: ChatDatabase,\n tables: TTables,\n): ChatStore<TTables['threads']['$inferSelect'], TTables['messages']['$inferSelect']> {\n type TThread = TTables['threads']['$inferSelect']\n type TMessage = TTables['messages']['$inferSelect']\n const threads = tables.threads as ChatTables['threads']\n const messages = tables.messages as ChatTables['messages']\n\n return {\n async listThreads(input) {\n const limit = clampLimit(input.limit, 50, 200)\n const offset = clampOffset(input.offset)\n const scope = eq(threads.workspaceId, input.workspaceId)\n const [list, [countRow]] = await Promise.all([\n db.select().from(threads).where(scope)\n // `id` tiebreak keeps pagination stable across same-second updates.\n .orderBy(desc(threads.updatedAt), asc(threads.id))\n .limit(limit)\n .offset(offset),\n db.select({ total: sql<number>`count(*)` }).from(threads).where(scope),\n ])\n return { threads: list as TThread[], total: countRow?.total ?? 0, limit, offset }\n },\n\n async getThread(threadId) {\n const [row] = await db.select().from(threads).where(eq(threads.id, threadId)).limit(1)\n return (row as TThread | undefined) ?? null\n },\n\n async createThread(input) {\n const title = threadTitleFromMessage(input.title ?? input.firstMessage ?? '')\n const values = {\n workspaceId: input.workspaceId,\n title,\n ...(input.category !== undefined ? { category: input.category } : {}),\n ...(input.isPinned !== undefined ? { isPinned: input.isPinned } : {}),\n ...(input.extras ?? {}),\n } as NewChatThreadRow\n const [row] = await db.insert(threads).values(values).returning()\n if (!row) throw new Error('thread insert returned no row')\n return row as TThread\n },\n\n async renameThread(threadId, title) {\n const trimmed = title.trim()\n if (!trimmed) throw new ChatStoreInputError('Missing title')\n const [row] = await db.update(threads)\n .set({ title: trimmed, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async pinThread(threadId, isPinned) {\n const [row] = await db.update(threads)\n .set({ isPinned, updatedAt: new Date() })\n .where(eq(threads.id, threadId))\n .returning()\n return (row as TThread | undefined) ?? null\n },\n\n async deleteThread(threadId, options) {\n const [existing] = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(eq(threads.id, threadId))\n .limit(1)\n if (!existing) return false\n if (options?.assertAccess) await options.assertAccess(existing.workspaceId)\n // Messages first so a partial failure never leaves orphaned rows behind\n // a deleted thread.\n await runSqliteStatements(db, [\n db.delete(messages).where(eq(messages.threadId, threadId)),\n db.delete(threads).where(eq(threads.id, threadId)),\n ])\n return true\n },\n\n async bulkDeleteThreads(input) {\n const { ids, assertAccess } = input\n if (typeof assertAccess !== 'function') throw new ChatStoreInputError('Missing assertAccess')\n if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string' && id.length > 0)) {\n throw new ChatStoreInputError('Missing ids')\n }\n if (ids.length > BULK_DELETE_MAX_THREADS) {\n throw new ChatStoreInputError(`Too many ids (max ${BULK_DELETE_MAX_THREADS})`)\n }\n\n const rows = await db.select({ id: threads.id, workspaceId: threads.workspaceId })\n .from(threads)\n .where(inArray(threads.id, ids))\n if (rows.length === 0) return { deleted: 0 }\n\n // Access is verified once per workspace the ids touch. Fail-closed: one\n // inaccessible workspace rejects the whole request before any delete.\n // Sorted so the check order (and therefore which denial surfaces) is\n // deterministic — row order follows random hex ids and varies per run.\n const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort()\n for (const workspaceId of workspaceIds) {\n await assertAccess(workspaceId)\n }\n\n const foundIds = rows.map((row) => row.id)\n await runSqliteStatements(db, [\n db.delete(messages).where(inArray(messages.threadId, foundIds)),\n db.delete(threads).where(inArray(threads.id, foundIds)),\n ])\n return { deleted: foundIds.length }\n },\n\n async listMessages(threadId, options) {\n const query = db.select().from(messages)\n .where(eq(messages.threadId, threadId))\n .orderBy(asc(messages.createdAt), sql`rowid`)\n .$dynamic()\n if (options?.limit !== undefined) query.limit(clampLimit(options.limit, 1, 1000))\n if (options?.offset !== undefined) query.offset(clampOffset(options.offset))\n return await query as TMessage[]\n },\n\n async appendMessage(input) {\n const values = {\n ...(input.id !== undefined ? { id: input.id } : {}),\n threadId: input.threadId,\n role: input.role,\n content: input.content,\n ...(input.parts !== undefined ? { parts: input.parts } : {}),\n ...(input.toolName !== undefined ? { toolName: input.toolName } : {}),\n ...(input.model !== undefined ? { model: input.model } : {}),\n ...(input.requestedModel !== undefined ? { requestedModel: input.requestedModel } : {}),\n ...(input.servedModel !== undefined ? { servedModel: input.servedModel } : {}),\n ...(input.servedProvider !== undefined ? { servedProvider: input.servedProvider } : {}),\n ...(input.servedSource !== undefined ? { servedSource: input.servedSource } : {}),\n ...(input.inputTokens !== undefined ? { inputTokens: input.inputTokens } : {}),\n ...(input.outputTokens !== undefined ? { outputTokens: input.outputTokens } : {}),\n ...(input.reasoningTokens !== undefined ? { reasoningTokens: input.reasoningTokens } : {}),\n ...(input.cacheReadTokens !== undefined ? { cacheReadTokens: input.cacheReadTokens } : {}),\n ...(input.cacheWriteTokens !== undefined ? { cacheWriteTokens: input.cacheWriteTokens } : {}),\n ...(input.costUsd !== undefined ? { costUsd: input.costUsd } : {}),\n ...(input.extras ?? {}),\n } as NewChatMessageRow\n const [insertResult] = await runSqliteStatements(db, [\n db.insert(messages).values(values).returning(),\n db.update(threads).set({ updatedAt: new Date() }).where(eq(threads.id, input.threadId)),\n ])\n const row = (insertResult as TMessage[] | undefined)?.[0]\n if (!row) throw new Error('message insert returned no row')\n return row\n },\n\n async updateMessage(id, patch) {\n const values = {\n ...(patch.content !== undefined ? { content: patch.content } : {}),\n ...(patch.parts !== undefined ? { parts: patch.parts } : {}),\n ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),\n ...(patch.model !== undefined ? { model: patch.model } : {}),\n ...(patch.requestedModel !== undefined ? { requestedModel: patch.requestedModel } : {}),\n ...(patch.servedModel !== undefined ? { servedModel: patch.servedModel } : {}),\n ...(patch.servedProvider !== undefined ? { servedProvider: patch.servedProvider } : {}),\n ...(patch.servedSource !== undefined ? { servedSource: patch.servedSource } : {}),\n ...(patch.inputTokens !== undefined ? { inputTokens: patch.inputTokens } : {}),\n ...(patch.outputTokens !== undefined ? { outputTokens: patch.outputTokens } : {}),\n ...(patch.reasoningTokens !== undefined ? { reasoningTokens: patch.reasoningTokens } : {}),\n ...(patch.cacheReadTokens !== undefined ? { cacheReadTokens: patch.cacheReadTokens } : {}),\n ...(patch.cacheWriteTokens !== undefined ? { cacheWriteTokens: patch.cacheWriteTokens } : {}),\n ...(patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {}),\n ...(patch.extras ?? {}),\n }\n if (Object.keys(values).length === 0) {\n const [current] = await db.select().from(messages).where(eq(messages.id, id))\n return (current as TMessage | undefined) ?? null\n }\n // The thread bump reads the row's own `thread_id` rather than trusting a\n // caller-supplied one: a message never moves thread, so the subquery is\n // the authoritative source and one fewer parameter to get wrong.\n const [updateResult] = await runSqliteStatements(db, [\n db.update(messages).set(values).where(eq(messages.id, id)).returning(),\n db.update(threads).set({ updatedAt: new Date() })\n .where(eq(threads.id, sql`(select ${messages.threadId} from ${messages} where ${messages.id} = ${id})`)),\n ])\n return (updateResult as TMessage[] | undefined)?.[0] ?? null\n },\n\n async deleteMessage(id) {\n const deleted = await db.delete(messages).where(eq(messages.id, id)).returning()\n return (deleted as unknown[]).length > 0\n },\n }\n}\n"],"mappings":"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","names":["sql","sql"]}
@@ -15,7 +15,7 @@ function buildSessionSubItems({
15
15
  const rowActions = (session) => {
16
16
  if (!actions?.canEdit) return void 0;
17
17
  const built = [];
18
- const { onRename, onDelete } = actions;
18
+ const { onRename, onDelete, extraActions } = actions;
19
19
  if (onRename) {
20
20
  built.push({
21
21
  id: "rename",
@@ -24,6 +24,7 @@ function buildSessionSubItems({
24
24
  onSelect: () => onRename(session)
25
25
  });
26
26
  }
27
+ if (extraActions) built.push(...extraActions(session));
27
28
  if (onDelete) {
28
29
  built.push({
29
30
  id: "delete",
@@ -210,4 +211,4 @@ export {
210
211
  railCollapsedCookie,
211
212
  writeRailCollapsedCookie
212
213
  };
213
- //# sourceMappingURL=chunk-YOSSGDIG.js.map
214
+ //# sourceMappingURL=chunk-GINKOZFD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/session-shell/index.ts"],"sourcesContent":["/**\n * Session shell — the app-shell mechanism every agent product needs around the\n * chat surface: a list of past sessions in the rail, an entry point for a new\n * one, and a paged history view behind it.\n *\n * `/web-react` already owns the chat SURFACE (composer, transcript, cards); it\n * owned no session SHELL, so all four products hand-rolled one and drifted.\n * This module is the shell's pure half: no React, no DOM, no peer imports, so a\n * server loader can call `readRailCollapsedCookie` without dragging React into\n * a worker bundle (`/web-react` holds the rendered half).\n *\n * Domain stays a parameter. A \"session\" here is only an id, a title and a\n * timestamp — a gtm thread, a tax session and a legal matter are all the same\n * shape to the shell, and the product supplies routing through `hrefForSession`\n * rather than the shell knowing any URL.\n */\n\n/** One session as the shell needs to see it. Products map their own row\n * (thread / session / matter) onto this before handing it over. */\nexport interface SessionSummary {\n id: string\n /** `null`/empty renders as the untitled placeholder rather than a blank row. */\n title: string | null\n /** ISO-8601. `null` when the product has no timestamp to show. */\n updatedAt: string | null\n isPinned?: boolean\n /** Unread for the viewer. Use `resolveSessionUnread` to fold live overlays in. */\n unread?: boolean\n /** Free-form product label (gtm categories, legal matter types). Passed\n * through untouched — the shell never interprets it. */\n category?: string | null\n}\n\n/** One fetched page of sessions with an optional continuation cursor. */\nexport interface SessionPage {\n items: SessionSummary[]\n /** Opaque continuation token; absent/null ⇒ no further pages. */\n nextCursor?: string | null\n}\n\n/** Sort order for the history view. The product's fetcher decides what these\n * mean against its own storage; the shell only round-trips the value. */\nexport type SessionSort = 'newest' | 'oldest'\n\n// ---------------------------------------------------------------------------\n// Rail items — structurally assignable to sandbox-ui's SidebarLayout types\n// ---------------------------------------------------------------------------\n\n/**\n * These mirror `@tangle-network/sandbox-ui/dashboard`'s `SidebarLayoutNavItem`\n * / `RailExpandableSubItem` STRUCTURALLY rather than importing them, so this\n * module stays free of the optional peer (invariant 3 — structural over\n * hard-dep when the surface is small). `tests/session-shell/rail-contract.test.ts`\n * assigns the builder output to the real sandbox-ui types, so a drift in either\n * direction fails CI instead of silently dropping a field at runtime.\n *\n * `TIcon` is the product's icon component type (lucide, custom, anything) —\n * generic so this file needs no React types.\n */\nexport interface SessionRailAction<TIcon = unknown> {\n id: string\n label: string\n icon?: TIcon\n destructive?: boolean\n onSelect: () => void\n}\n\nexport type RailPrefetch = 'none' | 'intent' | 'render' | 'viewport'\n\nexport interface SessionRailSubItem<TIcon = unknown> {\n id: string\n label: string\n href: string\n prefetch?: RailPrefetch\n /** Live working indicator — the session is mid-turn. */\n isLoading?: boolean\n /** Bold + leading dot. sandbox-ui suppresses it while `isLoading`. */\n unread?: boolean\n /** Emphasised row, used for the trailing \"view all\" overflow link. */\n emphasis?: boolean\n actions?: SessionRailAction<TIcon>[]\n}\n\nexport interface SessionRailNavItem<TIcon = unknown> {\n id: string\n /** REQUIRED, mirroring sandbox-ui — the rail renders `<Icon />` unguarded, so\n * an omitted icon is a blank/crashing row rather than a styling nit. */\n icon: TIcon\n label: string\n href: string\n badge?: number\n expandable?: boolean\n defaultOpen?: boolean\n subItems?: SessionRailSubItem<TIcon>[]\n subActiveIds?: string[]\n emptyLabel?: string\n prefetch?: RailPrefetch\n}\n\n/** Per-row rename/delete wiring. Supplied by the layout that owns the dialogs;\n * omitted (or `canEdit: false`) leaves rows read-only. */\nexport interface SessionRowActions<TIcon = unknown> {\n canEdit: boolean\n renameIcon?: TIcon\n deleteIcon?: TIcon\n renameLabel?: string\n deleteLabel?: string\n /**\n * Omit when the product cannot rename a session — the row then offers delete\n * only, instead of a menu item that does nothing.\n *\n * Independently optional, matching `SessionHistoryPanel`, which has always\n * rendered whichever of the two it was given. The rail builder used to demand\n * both, so a product with archive-but-no-rename (tax) could either fake a\n * rename or ship no row actions at all.\n */\n onRename?: (session: SessionSummary) => void\n onDelete?: (session: SessionSummary) => void\n /**\n * Row actions this shell has no opinion about — pin, categorise, duplicate,\n * share. Evaluated per session so a label can read that row's state\n * (\"Pin\" vs \"Unpin\"), and ordered between rename and delete so the\n * destructive action stays last.\n *\n * `id` must not be `rename` or `delete`; those are the shell's own.\n */\n extraActions?: (session: SessionSummary) => SessionRailAction<TIcon>[]\n}\n\nexport const UNTITLED_SESSION_LABEL = 'Untitled chat'\n\n/** Display title for a session row — trims, and falls back rather than\n * rendering an empty row the user cannot aim at. */\nexport function sessionLabel(session: SessionSummary, untitled = UNTITLED_SESSION_LABEL): string {\n return session.title?.trim() || untitled\n}\n\nexport interface BuildSessionSubItemsOptions<TIcon = unknown> {\n sessions: SessionSummary[]\n /** The product's route for one session. The shell never builds a URL itself. */\n hrefForSession: (sessionId: string) => string\n /** Ids currently mid-turn — renders the working indicator. */\n respondingSessionIds?: ReadonlySet<string>\n actions?: SessionRowActions<TIcon>\n untitledLabel?: string\n prefetch?: RailPrefetch\n /** Trailing \"view all\" row, appended when the capped list hides sessions. */\n overflow?: { href: string; label?: string }\n}\n\n/** Session rows for the rail's expandable history item. */\nexport function buildSessionSubItems<TIcon = unknown>({\n sessions,\n hrefForSession,\n respondingSessionIds,\n actions,\n untitledLabel = UNTITLED_SESSION_LABEL,\n prefetch = 'intent',\n overflow,\n}: BuildSessionSubItemsOptions<TIcon>): SessionRailSubItem<TIcon>[] {\n /**\n * Only the handlers the product actually supplied. An empty result becomes\n * `undefined` rather than `[]`, because sandbox-ui renders the kebab trigger\n * whenever `actions` is an array — an empty one is a button that opens an\n * empty menu.\n */\n const rowActions = (session: SessionSummary): SessionRailAction<TIcon>[] | undefined => {\n if (!actions?.canEdit) return undefined\n const built: SessionRailAction<TIcon>[] = []\n const { onRename, onDelete, extraActions } = actions\n if (onRename) {\n built.push({\n id: 'rename',\n label: actions.renameLabel ?? 'Rename',\n icon: actions.renameIcon,\n onSelect: () => onRename(session),\n })\n }\n if (extraActions) built.push(...extraActions(session))\n if (onDelete) {\n built.push({\n id: 'delete',\n label: actions.deleteLabel ?? 'Delete',\n icon: actions.deleteIcon,\n destructive: true,\n onSelect: () => onDelete(session),\n })\n }\n return built.length ? built : undefined\n }\n\n const rows: SessionRailSubItem<TIcon>[] = sessions.map((session) => ({\n id: session.id,\n label: sessionLabel(session, untitledLabel),\n href: hrefForSession(session.id),\n prefetch,\n isLoading: respondingSessionIds?.has(session.id) ?? false,\n unread: Boolean(session.unread),\n actions: rowActions(session),\n }))\n if (!overflow) return rows\n return [\n ...rows,\n {\n id: 'view-all',\n label: overflow.label ?? 'View all chats',\n href: overflow.href,\n prefetch,\n emphasis: true,\n },\n ]\n}\n\nexport interface BuildSessionNavItemOptions<TIcon = unknown>\n extends BuildSessionSubItemsOptions<TIcon> {\n /** Nav id the product highlights against (`activeNavId === id`). */\n id?: string\n label?: string\n /** The product's icon component. Required — see `SessionRailNavItem.icon`. */\n icon: TIcon\n /** The expandable row's own destination — the full history page. */\n href: string\n /** Session currently open, highlighted inside the expandable. */\n activeSessionId?: string | null\n emptyLabel?: string\n defaultOpen?: boolean\n}\n\n/**\n * The rail's session entry: one expandable nav row whose sub-items are the\n * recent sessions. This is the structure the owner asked for — history lives IN\n * the rail, not in a second sidebar panel beside it.\n */\nexport function buildSessionNavItem<TIcon = unknown>({\n id = 'history',\n label = 'History',\n icon,\n href,\n activeSessionId,\n emptyLabel = 'No chats yet',\n defaultOpen = true,\n ...subItemOptions\n}: BuildSessionNavItemOptions<TIcon>): SessionRailNavItem<TIcon> {\n return {\n id,\n icon,\n label,\n href,\n expandable: true,\n defaultOpen,\n subItems: buildSessionSubItems<TIcon>(subItemOptions),\n subActiveIds: activeSessionId ? [activeSessionId] : undefined,\n emptyLabel,\n prefetch: subItemOptions.prefetch ?? 'intent',\n }\n}\n\n// ---------------------------------------------------------------------------\n// Routing / selection\n// ---------------------------------------------------------------------------\n\nfunction stripTrailingSlashes(value: string): string {\n return value.replace(/\\/+$/, '')\n}\n\n/** Bare segment name, so `reserved` accepts `'/settings'` or `'settings'`. */\nfunction stripSlashes(value: string): string {\n return value.replace(/^\\/+|\\/+$/g, '')\n}\n\n/** Path with query + fragment removed and trailing slashes trimmed. A caller\n * passing a full href instead of a pathname would otherwise match nothing. */\nfunction normalizePath(pathname: string): string {\n const withoutHash = pathname.split('#')[0] ?? ''\n const withoutQuery = withoutHash.split('?')[0] ?? ''\n return stripTrailingSlashes(withoutQuery)\n}\n\n/** True when `path` is `prefix` or a segment-aligned descendant of it, so\n * `/vault` never claims `/vault-archive`. */\nfunction isUnderPrefix(path: string, prefix: string): boolean {\n const p = stripTrailingSlashes(prefix)\n if (p === '') return true\n return path === p || path.startsWith(`${p}/`)\n}\n\nexport interface ActiveSessionIdOptions {\n pathname: string\n /** Workspace-scoped route base, e.g. `/app/ws_123`. */\n base: string\n /** Route segment sessions live under. Default `chat` ⇒ `${base}/chat/:id`.\n * Pass `''` when sessions sit DIRECTLY under the base (`/app/:sessionId`),\n * which is how one product routes them — then `reserved` is mandatory. */\n segment?: string\n /** Segment that means \"composing a new session\", not an id. Default `new`. */\n newSegment?: string\n /**\n * First segments that are OTHER routes, not session ids. Only meaningful\n * with `segment: ''`, where `/app/settings` is otherwise indistinguishable\n * from a session called `settings` — and resolving it as one would highlight\n * and prefetch a session that does not exist. Pass the product's own nav\n * paths; unknown-but-reserved is a routing bug, so this fails closed.\n */\n reserved?: readonly string[]\n}\n\n/**\n * The session id the current route has open, or `null` on the new-session\n * composer / anywhere else.\n *\n * Anchored at `base` on purpose. A bare `/\\/chat\\/([^/]+)/` scan — the shape\n * three products shipped — matches the FIRST `/chat/` anywhere in the path, so\n * a workspace or vault folder named `chat` resolves a neighbouring segment as a\n * session id and the rail highlights (and prefetches) a session the user is not\n * in. Same class as attaching to a stale box: it looks right and points at the\n * wrong row.\n */\nexport function activeSessionIdFromPath({\n pathname,\n base,\n segment = 'chat',\n newSegment = 'new',\n reserved,\n}: ActiveSessionIdOptions): string | null {\n const path = normalizePath(pathname)\n const root = stripTrailingSlashes(base)\n const prefix = segment ? `${root}/${segment}` : root\n if (!isUnderPrefix(path, prefix) || path === prefix) return null\n const id = path.slice(prefix.length + 1).split('/')[0] ?? ''\n if (!id || id === newSegment) return null\n // `/app/settings` under a segment-less route is a sibling page, not a\n // session named \"settings\".\n if (reserved?.some((name) => stripSlashes(name) === id)) return null\n return decodeURIComponent(id)\n}\n\n/** One rail destination. `path` is relative to the workspace base. */\nexport interface NavRouteDef {\n id: string\n path: string\n}\n\nexport interface ResolveActiveNavIdOptions {\n pathname: string\n base: string\n /** The product's rail rows, in any order — resolution is longest-prefix. */\n routes: NavRouteDef[]\n /** Extra prefixes that light an existing row: `{ '/agents': 'integrations' }`.\n * Participates in the same longest-prefix contest. */\n aliases?: Record<string, string>\n /** Prefixes that deliberately highlight NOTHING, beating any shorter match.\n * gtm uses this so an open chat lights no rail row while `/chat/new` still\n * lights \"New\". */\n claimsNothing?: string[]\n}\n\n/**\n * The rail row to highlight for the current route.\n *\n * Longest-prefix wins, so declaration order cannot change the answer. The\n * per-product versions this replaces were first-match over an array, which made\n * `/chat/new` vs `/chat` an ordering accident rather than a rule.\n */\nexport function resolveActiveNavId({\n pathname,\n base,\n routes,\n aliases,\n claimsNothing,\n}: ResolveActiveNavIdOptions): string | undefined {\n const path = normalizePath(pathname)\n const root = stripTrailingSlashes(base)\n let bestLength = -1\n let bestId: string | undefined\n const consider = (relative: string, id: string | undefined, winsTies = false) => {\n const full = stripTrailingSlashes(`${root}${relative}`)\n if (!isUnderPrefix(path, full)) return\n if (full.length > bestLength || (winsTies && full.length === bestLength)) {\n bestLength = full.length\n bestId = id\n }\n }\n for (const route of routes) consider(route.path, route.id)\n for (const [prefix, id] of Object.entries(aliases ?? {})) consider(prefix, id)\n // Declared last and wins an exact-length tie: naming a prefix in\n // `claimsNothing` is a deliberate override of the row that owns it, so\n // `claimsNothing: ['/chat']` beats a `{ id: 'chat', path: '/chat' }` row while\n // a longer `/chat/new` still wins on specificity.\n for (const prefix of claimsNothing ?? []) consider(prefix, undefined, true)\n return bestId\n}\n\n// ---------------------------------------------------------------------------\n// Sidebar list composition\n// ---------------------------------------------------------------------------\n\nexport interface ResolveSessionUnreadOptions {\n sessionId: string\n /** Server-computed unread from the route loader. */\n loaderUnread: boolean\n /** Live \"went unread\" ids from the workspace channel. */\n liveUnreadIds?: ReadonlySet<string>\n /** Ids this tab has already opened since the loader ran. */\n locallyReadIds?: ReadonlySet<string>\n /** The open session is never unread to its own viewer. */\n currentSessionId?: string | null\n}\n\n/**\n * Effective unread for one row. The loader's value can be stale — a layout\n * loader that survives same-workspace navigation keeps reporting a session as\n * unread after the user opened it — so live and local overlays win over it, and\n * the currently-open session always reads as read.\n */\nexport function resolveSessionUnread({\n sessionId,\n loaderUnread,\n liveUnreadIds,\n locallyReadIds,\n currentSessionId,\n}: ResolveSessionUnreadOptions): boolean {\n if (sessionId === currentSessionId) return false\n if (liveUnreadIds?.has(sessionId)) return true\n if (locallyReadIds?.has(sessionId)) return false\n return loaderUnread\n}\n\nexport interface ComposeSidebarSessionsOptions {\n /** Server-rendered rows, already ordered by the product's query. */\n loaderSessions: SessionSummary[]\n /** Optimistic rows from the live channel (a chat created in another tab). */\n optimisticSessions?: SessionSummary[]\n /** Rail cap. The full list lives on the history page. */\n limit: number\n /** Total sessions the product holds, used to decide the overflow row. */\n totalCount?: number\n liveUnreadIds?: ReadonlySet<string>\n locallyReadIds?: ReadonlySet<string>\n currentSessionId?: string | null\n}\n\nexport interface ComposedSidebarSessions {\n sessions: SessionSummary[]\n /** More sessions exist than the rail shows ⇒ render the \"view all\" row. */\n hasMore: boolean\n}\n\n/**\n * The rail's session list: optimistic rows first, then the loader's, capped,\n * with unread resolved per row.\n *\n * Optimistic rows are deduped against the loader by id — once a revalidation\n * brings a live-created session back from the server it must not appear twice\n * (duplicate React keys, and the row's actions would target the same session\n * from two places).\n */\nexport function composeSidebarSessions({\n loaderSessions,\n optimisticSessions = [],\n limit,\n totalCount,\n liveUnreadIds,\n locallyReadIds,\n currentSessionId,\n}: ComposeSidebarSessionsOptions): ComposedSidebarSessions {\n const loaderIds = new Set(loaderSessions.map((session) => session.id))\n const pendingNew = optimisticSessions.filter((session) => !loaderIds.has(session.id))\n const merged = [...pendingNew, ...loaderSessions]\n const sessions = merged.slice(0, Math.max(0, limit)).map((session) => ({\n ...session,\n unread: resolveSessionUnread({\n sessionId: session.id,\n loaderUnread: Boolean(session.unread),\n liveUnreadIds,\n locallyReadIds,\n currentSessionId,\n }),\n }))\n const known = (totalCount ?? loaderSessions.length) + pendingNew.length\n return { sessions, hasMore: known > sessions.length }\n}\n\n/**\n * Append a fetched page to held rows, dropping ids already shown. A session\n * bumped to the top between two page fetches otherwise arrives twice — once in\n * the page it moved out of and once in the page it moved into.\n */\nexport function mergeSessionPages(\n existing: SessionSummary[],\n incoming: SessionSummary[],\n): SessionSummary[] {\n const seen = new Set(existing.map((session) => session.id))\n return [...existing, ...incoming.filter((session) => !seen.has(session.id))]\n}\n\n// ---------------------------------------------------------------------------\n// Rail collapse cookie (SSR-seeded so the first paint matches the client)\n// ---------------------------------------------------------------------------\n\nexport const DEFAULT_RAIL_COOKIE_NAME = 'agent-sidebar-rail-collapsed'\n\n/**\n * Read the persisted rail-collapse state from a request's `Cookie` header, so\n * the server renders the rail in the state the user left it and the first\n * client render does not re-flow.\n *\n * Parses the header rather than building a `RegExp` from the cookie name (the\n * shape the products shipped): a name containing a regex metacharacter would\n * silently match the wrong cookie or none at all.\n */\nexport function readRailCollapsedCookie(\n cookieHeader: string | null | undefined,\n name: string = DEFAULT_RAIL_COOKIE_NAME,\n): boolean {\n for (const pair of (cookieHeader ?? '').split(';')) {\n const eq = pair.indexOf('=')\n if (eq === -1) continue\n if (pair.slice(0, eq).trim() !== name) continue\n return pair.slice(eq + 1).trim() === '1'\n }\n return false\n}\n\nexport interface RailCookieOptions {\n name?: string\n /** Seconds. Default one year. */\n maxAge?: number\n path?: string\n /** Omit to auto-detect: `secure` on https, off on http://localhost — a Secure\n * cookie is dropped there and the rail state would not persist in dev. */\n secure?: boolean\n}\n\n/** The cookie string for a collapse state. Usable as `document.cookie` or as a\n * `Set-Cookie` value. Exported separately so it is testable without a DOM. */\nexport function railCollapsedCookie(\n collapsed: boolean,\n { name = DEFAULT_RAIL_COOKIE_NAME, maxAge = 31_536_000, path = '/', secure }: RailCookieOptions = {},\n): string {\n const isSecure =\n secure ?? (typeof location !== 'undefined' && location.protocol === 'https:')\n return `${name}=${collapsed ? '1' : '0'}; path=${path}; max-age=${maxAge}; samesite=lax${isSecure ? '; secure' : ''}`\n}\n\n/** Persist the rail-collapse state from the browser. No-op without a document\n * so a shared toggle handler is safe to call during SSR. */\nexport function writeRailCollapsedCookie(collapsed: boolean, options: RailCookieOptions = {}): void {\n if (typeof document === 'undefined') return\n document.cookie = railCollapsedCookie(collapsed, options)\n}\n"],"mappings":"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","names":[]}
@@ -5,7 +5,7 @@ import {
5
5
  UNTITLED_SESSION_LABEL,
6
6
  mergeSessionPages,
7
7
  sessionLabel
8
- } from "./chunk-YOSSGDIG.js";
8
+ } from "./chunk-GINKOZFD.js";
9
9
  import {
10
10
  WorkProductCard,
11
11
  workProductPartsFromMessageParts
@@ -3167,6 +3167,9 @@ function SessionHistoryPanel({
3167
3167
  respondingSessionIds,
3168
3168
  onRename,
3169
3169
  onDelete,
3170
+ renameLabel = "Rename",
3171
+ deleteLabel = "Delete",
3172
+ extraActions,
3170
3173
  newSessionHref,
3171
3174
  title = "History",
3172
3175
  untitledLabel = UNTITLED_SESSION_LABEL,
@@ -3245,7 +3248,10 @@ function SessionHistoryPanel({
3245
3248
  untitledLabel,
3246
3249
  timestamp: formatTimestamp(session.updatedAt),
3247
3250
  onRename,
3248
- onDelete
3251
+ onDelete,
3252
+ renameLabel,
3253
+ deleteLabel,
3254
+ extraActions
3249
3255
  },
3250
3256
  session.id
3251
3257
  )),
@@ -3282,9 +3288,14 @@ function SessionRow({
3282
3288
  untitledLabel,
3283
3289
  timestamp,
3284
3290
  onRename,
3285
- onDelete
3291
+ onDelete,
3292
+ renameLabel,
3293
+ deleteLabel,
3294
+ extraActions
3286
3295
  }) {
3287
3296
  const [menuOpen, setMenuOpen] = useState12(false);
3297
+ const extras = extraActions?.(session) ?? [];
3298
+ const hasMenu = Boolean(onRename) || Boolean(onDelete) || extras.length > 0;
3288
3299
  const showUnread = Boolean(session.unread) && !responding;
3289
3300
  return /* @__PURE__ */ jsxs10("div", { className: "group relative flex items-center gap-2 rounded-lg px-3 py-2.5 transition-colors hover:bg-accent/20", children: [
3290
3301
  /* @__PURE__ */ jsxs10(Link, { to: href, className: "flex min-w-0 flex-1 items-center gap-3", children: [
@@ -3300,7 +3311,7 @@ function SessionRow({
3300
3311
  )
3301
3312
  ] }),
3302
3313
  /* @__PURE__ */ jsx12("span", { className: "shrink-0 text-xs tabular-nums text-muted-foreground", children: timestamp }),
3303
- (onRename || onDelete) && /* @__PURE__ */ jsxs10("div", { className: "relative shrink-0", children: [
3314
+ hasMenu && /* @__PURE__ */ jsxs10("div", { className: "relative shrink-0", children: [
3304
3315
  /* @__PURE__ */ jsx12(
3305
3316
  "button",
3306
3317
  {
@@ -3324,9 +3335,22 @@ function SessionRow({
3324
3335
  onRename(session);
3325
3336
  },
3326
3337
  className: "block w-full px-3 py-1.5 text-left text-xs text-foreground transition hover:bg-accent/30",
3327
- children: "Rename"
3338
+ children: renameLabel
3328
3339
  }
3329
3340
  ),
3341
+ extras.map((action) => /* @__PURE__ */ jsx12(
3342
+ "button",
3343
+ {
3344
+ type: "button",
3345
+ onClick: () => {
3346
+ setMenuOpen(false);
3347
+ action.onSelect();
3348
+ },
3349
+ className: `block w-full px-3 py-1.5 text-left text-xs transition ${action.destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent/30"}`,
3350
+ children: action.label
3351
+ },
3352
+ action.id
3353
+ )),
3330
3354
  onDelete && /* @__PURE__ */ jsx12(
3331
3355
  "button",
3332
3356
  {
@@ -3336,7 +3360,7 @@ function SessionRow({
3336
3360
  onDelete(session);
3337
3361
  },
3338
3362
  className: "block w-full px-3 py-1.5 text-left text-xs text-destructive transition hover:bg-destructive/10",
3339
- children: "Delete"
3363
+ children: deleteLabel
3340
3364
  }
3341
3365
  )
3342
3366
  ] })
@@ -4276,4 +4300,4 @@ export {
4276
4300
  useThinkingSeconds,
4277
4301
  ChatMessages
4278
4302
  };
4279
- //# sourceMappingURL=chunk-D3GK2IPA.js.map
4303
+ //# sourceMappingURL=chunk-WXWU2FEB.js.map