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
+61 -2
View File
@@ -15,17 +15,24 @@ import {
resolveReasoningEffort,
} from '@shared/domain/models';
import {
buildSessionTitle,
isReasoningEffort,
type PatternDefinition,
type ReasoningEffort,
validatePatternDefinition,
} from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
duplicateSessionRecord,
querySessions as queryWorkspaceSessions,
renameSessionRecord,
type QuerySessionsInput,
type SessionQueryResult,
} from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import {
applyScratchpadSessionConfig,
createScratchpadSessionConfig,
resolveSessionTitle,
type ChatMessageRecord,
type SessionRecord,
} from '@shared/domain/session';
@@ -137,6 +144,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
const candidate: PatternDefinition = {
...pattern,
isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite,
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
updatedAt: nowIso(),
};
@@ -151,6 +159,14 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const pattern = this.requirePattern(workspace, patternId);
pattern.isFavorite = isFavorite;
pattern.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async deletePattern(patternId: string): Promise<WorkspaceState> {
if (isBuiltinPattern(patternId)) {
throw new Error('Built-in patterns cannot be deleted.');
@@ -178,6 +194,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
projectId: project.id,
patternId: pattern.id,
title: pattern.name,
titleSource: 'auto',
createdAt: nowIso(),
updatedAt: nowIso(),
status: 'idle',
@@ -194,6 +211,43 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async duplicateSession(sessionId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const duplicate = duplicateSessionRecord(session, createId('session'), nowIso());
workspace.sessions.unshift(duplicate);
workspace.selectedProjectId = duplicate.projectId;
workspace.selectedPatternId = duplicate.patternId;
workspace.selectedSessionId = duplicate.id;
return this.persistAndBroadcast(workspace);
}
async renameSession(sessionId: string, title: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const renamed = renameSessionRecord(session, title, nowIso());
Object.assign(session, renamed);
return this.persistAndBroadcast(workspace);
}
async setSessionPinned(sessionId: string, isPinned: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
session.isPinned = isPinned;
session.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async setSessionArchived(sessionId: string, isArchived: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
session.isArchived = isArchived;
session.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
@@ -213,7 +267,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
content: trimmed,
createdAt: nowIso(),
});
session.title = buildSessionTitle(effectivePattern, session.messages);
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
session.status = 'running';
session.lastError = undefined;
session.updatedAt = nowIso();
@@ -301,6 +355,11 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]> {
const workspace = await this.loadWorkspace();
return queryWorkspaceSessions(workspace, input);
}
async selectProject(projectId?: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.selectedProjectId = projectId;
+22
View File
@@ -3,10 +3,16 @@ import { BrowserWindow, ipcMain } from 'electron';
import { ipcChannels } from '@shared/contracts/channels';
import type {
CreateSessionInput,
DuplicateSessionInput,
RenameSessionInput,
SavePatternInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
SetSessionArchivedInput,
SetSessionPinnedInput,
UpdateScratchpadSessionConfigInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import { KopayaAppService } from '@main/KopayaAppService';
@@ -18,9 +24,24 @@ export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppSer
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
service.setPatternFavorite(input.patternId, input.isFavorite),
);
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
service.createSession(input.projectId, input.patternId),
);
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
service.duplicateSession(input.sessionId),
);
ipcMain.handle(ipcChannels.renameSession, (_event, input: RenameSessionInput) =>
service.renameSession(input.sessionId, input.title),
);
ipcMain.handle(ipcChannels.setSessionPinned, (_event, input: SetSessionPinnedInput) =>
service.setSessionPinned(input.sessionId, input.isPinned),
);
ipcMain.handle(ipcChannels.setSessionArchived, (_event, input: SetSessionArchivedInput) =>
service.setSessionArchived(input.sessionId, input.isArchived),
);
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
service.sendSessionMessage(input.sessionId, input.content),
);
@@ -29,6 +50,7 @@ export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppSer
(_event, input: UpdateScratchpadSessionConfigInput) =>
service.updateScratchpadSessionConfig(input.sessionId, input.model, input.reasoningEffort),
);
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
+6
View File
@@ -11,10 +11,16 @@ const api: ElectronApi = {
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
updateScratchpadSessionConfig: (input) =>
ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
+6
View File
@@ -6,8 +6,14 @@ export const ipcChannels = {
removeProject: 'workspace:remove-project',
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
createSession: 'sessions:create',
duplicateSession: 'sessions:duplicate',
renameSession: 'sessions:rename',
setSessionPinned: 'sessions:set-pinned',
setSessionArchived: 'sessions:set-archived',
sendSessionMessage: 'sessions:send-message',
querySessions: 'sessions:query',
updateScratchpadSessionConfig: 'sessions:update-scratchpad-config',
selectProject: 'selection:project',
selectPattern: 'selection:pattern',
+31
View File
@@ -1,6 +1,7 @@
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import type { WorkspaceState } from '@shared/domain/workspace';
@@ -24,6 +25,30 @@ export interface UpdateScratchpadSessionConfigInput {
reasoningEffort?: ReasoningEffort;
}
export interface DuplicateSessionInput {
sessionId: string;
}
export interface RenameSessionInput {
sessionId: string;
title: string;
}
export interface SetSessionPinnedInput {
sessionId: string;
isPinned: boolean;
}
export interface SetSessionArchivedInput {
sessionId: string;
isArchived: boolean;
}
export interface SetPatternFavoriteInput {
patternId: string;
isFavorite: boolean;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -33,11 +58,17 @@ export interface ElectronApi {
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
selectProject(projectId?: string): Promise<WorkspaceState>;
selectPattern(patternId?: string): Promise<WorkspaceState>;
selectSession(sessionId?: string): Promise<WorkspaceState>;
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
}
+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);
});
}