feat: add session library backend APIs

Add persisted session and pattern metadata for pinning, archiving, manual titles, and favorite patterns. Expose backend query and mutation APIs for session library features, and cover the shared helpers with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 19:33:20 +01:00
co-authored by Copilot
parent 585f2740a5
commit 4a229a1f88
10 changed files with 606 additions and 3 deletions
+1
View File
@@ -31,6 +31,7 @@ export interface PatternDefinition {
id: string;
name: string;
description: string;
isFavorite?: boolean;
mode: OrchestrationMode;
availability: PatternAvailability;
unavailabilityReason?: string;
+17 -1
View File
@@ -1,7 +1,8 @@
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
export type ChatRole = 'system' | 'user' | 'assistant';
export type SessionStatus = 'idle' | 'running' | 'error';
export type SessionTitleSource = 'auto' | 'manual';
export interface ScratchpadSessionConfig {
model: string;
@@ -22,14 +23,29 @@ export interface SessionRecord {
projectId: string;
patternId: string;
title: string;
titleSource?: SessionTitleSource;
createdAt: string;
updatedAt: string;
status: SessionStatus;
isPinned?: boolean;
isArchived?: boolean;
messages: ChatMessageRecord[];
lastError?: string;
scratchpadConfig?: ScratchpadSessionConfig;
}
export function resolveSessionTitle(
session: Pick<SessionRecord, 'title' | 'titleSource'>,
pattern: PatternDefinition,
messages: ChatMessageRecord[],
): string {
if (session.titleSource === 'manual') {
return session.title;
}
return buildSessionTitle(pattern, messages);
}
export function createScratchpadSessionConfig(
pattern: PatternDefinition,
): ScratchpadSessionConfig | undefined {
+234
View File
@@ -0,0 +1,234 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { ChatMessageRecord, SessionRecord, SessionStatus } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
export type SessionWorkspaceKind = 'project' | 'scratchpad';
export interface QuerySessionsInput {
searchText?: string;
statuses?: SessionStatus[];
projectIds?: string[];
patternIds?: string[];
workspaceKinds?: SessionWorkspaceKind[];
includeArchived?: boolean;
onlyPinned?: boolean;
}
export interface SessionQueryResult {
sessionId: string;
score: number;
matchedFields: SessionQueryMatchField[];
}
const fieldWeights: Record<SessionQueryMatchField, number> = {
title: 12,
project: 8,
pattern: 7,
message: 4,
};
const orderedMatchFields: SessionQueryMatchField[] = ['title', 'message', 'project', 'pattern'];
interface SessionSearchFields {
title: string;
message: string;
project: string;
pattern: string;
}
function normalizeSearchText(value: string): string {
return value.trim().toLocaleLowerCase();
}
function tokenizeSearchText(value?: string): string[] {
if (!value) {
return [];
}
return normalizeSearchText(value)
.split(/\s+/)
.filter((token) => token.length > 0);
}
function buildSearchFields(
session: SessionRecord,
project?: ProjectRecord,
pattern?: PatternDefinition,
): SessionSearchFields {
return {
title: normalizeSearchText(session.title),
message: normalizeSearchText(session.messages.map((message) => message.content).join('\n')),
project: normalizeSearchText([project?.name, project?.path].filter(Boolean).join('\n')),
pattern: normalizeSearchText(
[
pattern?.name,
pattern?.description,
pattern?.mode,
...(pattern?.agents.flatMap((agent) => [agent.name, agent.description]) ?? []),
]
.filter(Boolean)
.join('\n'),
),
};
}
function resolveSearchMatch(
fields: SessionSearchFields,
tokens: string[],
): Pick<SessionQueryResult, 'score' | 'matchedFields'> | undefined {
if (tokens.length === 0) {
return {
score: 0,
matchedFields: [],
};
}
const matched = new Set<SessionQueryMatchField>();
let score = 0;
for (const token of tokens) {
let tokenMatched = false;
for (const field of orderedMatchFields) {
if (!fields[field].includes(token)) {
continue;
}
tokenMatched = true;
matched.add(field);
score += fieldWeights[field];
}
if (!tokenMatched) {
return undefined;
}
}
return {
score,
matchedFields: orderedMatchFields.filter((field) => matched.has(field)),
};
}
function matchesFilters(
session: SessionRecord,
input: QuerySessionsInput,
workspaceKind: SessionWorkspaceKind,
): boolean {
if (!input.includeArchived && session.isArchived) {
return false;
}
if (input.onlyPinned && !session.isPinned) {
return false;
}
if (input.statuses && input.statuses.length > 0 && !input.statuses.includes(session.status)) {
return false;
}
if (input.projectIds && input.projectIds.length > 0 && !input.projectIds.includes(session.projectId)) {
return false;
}
if (input.patternIds && input.patternIds.length > 0 && !input.patternIds.includes(session.patternId)) {
return false;
}
if (input.workspaceKinds && input.workspaceKinds.length > 0 && !input.workspaceKinds.includes(workspaceKind)) {
return false;
}
return true;
}
export function renameSessionRecord(session: SessionRecord, title: string, updatedAt: string): SessionRecord {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
throw new Error('Session title is required.');
}
return {
...session,
title: trimmedTitle,
titleSource: 'manual',
updatedAt,
};
}
export function duplicateSessionRecord(
session: SessionRecord,
sessionId: string,
duplicatedAt: string,
): SessionRecord {
return {
...session,
id: sessionId,
title: `${session.title} (Copy)`,
titleSource: 'manual',
createdAt: duplicatedAt,
updatedAt: duplicatedAt,
status: 'idle',
isPinned: false,
isArchived: false,
lastError: undefined,
scratchpadConfig: session.scratchpadConfig ? { ...session.scratchpadConfig } : undefined,
messages: session.messages.map((message): ChatMessageRecord => ({
...message,
pending: false,
})),
};
}
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
const searchTokens = tokenizeSearchText(input.searchText);
return workspace.sessions
.flatMap((session) => {
const workspaceKind: SessionWorkspaceKind = isScratchpadProject(session.projectId) ? 'scratchpad' : 'project';
if (!matchesFilters(session, input, workspaceKind)) {
return [];
}
const searchMatch = resolveSearchMatch(
buildSearchFields(session, projectsById.get(session.projectId), patternsById.get(session.patternId)),
searchTokens,
);
if (!searchMatch) {
return [];
}
return [
{
sessionId: session.id,
score: searchMatch.score,
matchedFields: searchMatch.matchedFields,
},
];
})
.sort((left, right) => {
const leftSession = workspace.sessions.find((session) => session.id === left.sessionId);
const rightSession = workspace.sessions.find((session) => session.id === right.sessionId);
if (!leftSession || !rightSession) {
return 0;
}
if (leftSession.isPinned !== rightSession.isPinned) {
return leftSession.isPinned ? -1 : 1;
}
if (leftSession.isArchived !== rightSession.isArchived) {
return leftSession.isArchived ? 1 : -1;
}
if (left.score !== right.score) {
return right.score - left.score;
}
return rightSession.updatedAt.localeCompare(leftSession.updatedAt);
});
}