From 4a229a1f88e315a0d3ac245764bdf0cd01a99aaf Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 22 Mar 2026 19:33:20 +0100 Subject: [PATCH] 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> --- src/main/KopayaAppService.ts | 63 +++++++- src/main/ipc/registerIpcHandlers.ts | 22 +++ src/preload/index.ts | 6 + src/shared/contracts/channels.ts | 6 + src/shared/contracts/ipc.ts | 31 ++++ src/shared/domain/pattern.ts | 1 + src/shared/domain/session.ts | 18 ++- src/shared/domain/sessionLibrary.ts | 234 ++++++++++++++++++++++++++++ tests/shared/session.test.ts | 23 +++ tests/shared/sessionLibrary.test.ts | 205 ++++++++++++++++++++++++ 10 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 src/shared/domain/sessionLibrary.ts create mode 100644 tests/shared/sessionLibrary.test.ts diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index 64bdc93..1e90e6c 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -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 { 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 { return this.persistAndBroadcast(workspace); } + async setPatternFavorite(patternId: string, isFavorite: boolean): Promise { + 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 { if (isBuiltinPattern(patternId)) { throw new Error('Built-in patterns cannot be deleted.'); @@ -178,6 +194,7 @@ export class KopayaAppService extends EventEmitter { 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 { return this.persistAndBroadcast(workspace); } + async duplicateSession(sessionId: string): Promise { + 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 { + 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 { + 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 { + 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 { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); @@ -213,7 +267,7 @@ export class KopayaAppService extends EventEmitter { 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 { return this.persistAndBroadcast(workspace); } + async querySessions(input: QuerySessionsInput): Promise { + const workspace = await this.loadWorkspace(); + return queryWorkspaceSessions(workspace, input); + } + async selectProject(projectId?: string): Promise { const workspace = await this.loadWorkspace(); workspace.selectedProjectId = projectId; diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 4f23e9f..e3a7c2a 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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)); diff --git a/src/preload/index.ts b/src/preload/index.ts index 33c8f97..329fd80 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 2751177..34ebe64 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 5dda2b7..93d2788 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -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; refreshSidecarCapabilities(): Promise; @@ -33,11 +58,17 @@ export interface ElectronApi { savePattern(input: SavePatternInput): Promise; deletePattern(patternId: string): Promise; createSession(input: CreateSessionInput): Promise; + duplicateSession(input: DuplicateSessionInput): Promise; + renameSession(input: RenameSessionInput): Promise; + setSessionPinned(input: SetSessionPinnedInput): Promise; + setSessionArchived(input: SetSessionArchivedInput): Promise; sendSessionMessage(input: SendSessionMessageInput): Promise; updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise; + querySessions(input: QuerySessionsInput): Promise; selectProject(projectId?: string): Promise; selectPattern(patternId?: string): Promise; selectSession(sessionId?: string): Promise; + setPatternFavorite(input: SetPatternFavoriteInput): Promise; onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void; onSessionEvent(listener: (event: SessionEventRecord) => void): () => void; } diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 8918533..64a8ff5 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -31,6 +31,7 @@ export interface PatternDefinition { id: string; name: string; description: string; + isFavorite?: boolean; mode: OrchestrationMode; availability: PatternAvailability; unavailabilityReason?: string; diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 55a7df5..e0c8d19 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -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, + pattern: PatternDefinition, + messages: ChatMessageRecord[], +): string { + if (session.titleSource === 'manual') { + return session.title; + } + + return buildSessionTitle(pattern, messages); +} + export function createScratchpadSessionConfig( pattern: PatternDefinition, ): ScratchpadSessionConfig | undefined { diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts new file mode 100644 index 0000000..7369a9d --- /dev/null +++ b/src/shared/domain/sessionLibrary.ts @@ -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 = { + 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 | undefined { + if (tokens.length === 0) { + return { + score: 0, + matchedFields: [], + }; + } + + const matched = new Set(); + 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(workspace.projects.map((project) => [project.id, project])); + const patternsById = new Map(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); + }); +} diff --git a/tests/shared/session.test.ts b/tests/shared/session.test.ts index 0a6ef2e..0c7dd82 100644 --- a/tests/shared/session.test.ts +++ b/tests/shared/session.test.ts @@ -4,6 +4,7 @@ import type { PatternDefinition } from '@shared/domain/pattern'; import { applyScratchpadSessionConfig, createScratchpadSessionConfig, + resolveSessionTitle, resolveScratchpadSessionConfig, type SessionRecord, } from '@shared/domain/session'; @@ -95,3 +96,25 @@ describe('scratchpad session config helpers', () => { expect(updated.agents[1]).toEqual(pattern.agents[1]); }); }); + +describe('session title helpers', () => { + test('keeps a manual title instead of recomputing it from the first user message', () => { + const pattern = createPattern(); + const session = createSession({ + title: 'Release readiness review', + titleSource: 'manual', + }); + + expect( + resolveSessionTitle(session, pattern, [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Investigate why the version badge keeps saying unknown after refresh.', + createdAt: '2026-03-23T00:00:00.000Z', + }, + ]), + ).toBe('Release readiness review'); + }); +}); diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts new file mode 100644 index 0000000..ae02951 --- /dev/null +++ b/tests/shared/sessionLibrary.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test'; + +import { querySessions, duplicateSessionRecord, renameSessionRecord } from '@shared/domain/sessionLibrary'; +import type { PatternDefinition } from '@shared/domain/pattern'; +import type { ProjectRecord } from '@shared/domain/project'; +import type { SessionRecord } from '@shared/domain/session'; +import type { WorkspaceState } from '@shared/domain/workspace'; + +function createPattern(overrides?: Partial): PatternDefinition { + return { + id: 'pattern-sequential-review', + name: 'Sequential Review', + description: 'Multi-agent review workflow.', + mode: 'sequential', + availability: 'available', + maxIterations: 1, + agents: [ + { + id: 'agent-analyst', + name: 'Analyst', + description: 'Reviews the request and finds issues.', + instructions: 'Analyze the request.', + model: 'gpt-5.4', + reasoningEffort: 'high', + }, + ], + createdAt: '2026-03-23T00:00:00.000Z', + updatedAt: '2026-03-23T00:00:00.000Z', + ...overrides, + }; +} + +function createProject(overrides?: Partial): ProjectRecord { + return { + id: 'project-alpha', + name: 'alpha', + path: 'C:\\workspace\\alpha', + addedAt: '2026-03-23T00:00:00.000Z', + ...overrides, + }; +} + +function createSession(overrides?: Partial): SessionRecord { + return { + id: 'session-1', + projectId: 'project-alpha', + patternId: 'pattern-sequential-review', + title: 'Investigate Copilot refresh bug', + createdAt: '2026-03-23T00:00:00.000Z', + updatedAt: '2026-03-23T00:05:00.000Z', + status: 'idle', + messages: [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Please debug why the Copilot CLI version keeps showing unknown.', + createdAt: '2026-03-23T00:00:00.000Z', + }, + ], + ...overrides, + }; +} + +function createWorkspace(overrides?: Partial): WorkspaceState { + return { + projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })], + patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })], + sessions: [ + createSession(), + createSession({ + id: 'session-2', + projectId: 'project-scratchpad', + patternId: 'pattern-single-chat', + title: 'Scratchpad brainstorm', + status: 'running', + updatedAt: '2026-03-23T00:10:00.000Z', + isPinned: true, + messages: [ + { + id: 'msg-2', + role: 'assistant', + authorName: 'Primary Agent', + content: 'I am thinking through a scratchpad plan.', + createdAt: '2026-03-23T00:09:00.000Z', + pending: true, + }, + ], + }), + createSession({ + id: 'session-3', + title: 'Archived error session', + status: 'error', + isArchived: true, + updatedAt: '2026-03-23T00:08:00.000Z', + }), + ], + selectedProjectId: 'project-alpha', + selectedPatternId: 'pattern-sequential-review', + selectedSessionId: 'session-1', + lastUpdatedAt: '2026-03-23T00:10:00.000Z', + ...overrides, + }; +} + +describe('session library helpers', () => { + test('renames sessions with trimmed manual titles', () => { + const session = renameSessionRecord(createSession(), ' Incident review ', '2026-03-23T00:06:00.000Z'); + + expect(session.title).toBe('Incident review'); + expect(session.titleSource).toBe('manual'); + expect(session.updatedAt).toBe('2026-03-23T00:06:00.000Z'); + }); + + test('duplicates sessions as idle unpinned copies', () => { + const session = duplicateSessionRecord( + createSession({ + status: 'error', + isPinned: true, + isArchived: true, + lastError: 'sidecar crashed', + messages: [ + { + id: 'msg-1', + role: 'assistant', + authorName: 'Reviewer', + content: 'Done.', + createdAt: '2026-03-23T00:00:00.000Z', + pending: true, + }, + ], + }), + 'session-copy', + '2026-03-23T00:07:00.000Z', + ); + + expect(session).toMatchObject({ + id: 'session-copy', + title: 'Investigate Copilot refresh bug (Copy)', + titleSource: 'manual', + status: 'idle', + isPinned: false, + isArchived: false, + lastError: undefined, + createdAt: '2026-03-23T00:07:00.000Z', + updatedAt: '2026-03-23T00:07:00.000Z', + }); + expect(session.messages[0]?.pending).toBe(false); + }); + + test('searches across session title, messages, projects, and patterns', () => { + const workspace = createWorkspace(); + + expect(querySessions(workspace, { searchText: 'copilot alpha review' })).toEqual([ + { + sessionId: 'session-1', + score: 31, + matchedFields: ['title', 'message', 'project', 'pattern'], + }, + ]); + }); + + test('filters archived sessions by default and can include them explicitly', () => { + const workspace = createWorkspace(); + + expect(querySessions(workspace, { statuses: ['error'] })).toEqual([]); + expect(querySessions(workspace, { statuses: ['error'], includeArchived: true })).toEqual([ + { + sessionId: 'session-3', + score: 0, + matchedFields: [], + }, + ]); + }); + + test('supports pinned and scratchpad filters while keeping pinned sessions first', () => { + const workspace = createWorkspace({ + sessions: [ + createSession({ id: 'session-4', title: 'Pinned idle session', isPinned: true, updatedAt: '2026-03-23T00:04:00.000Z' }), + ...createWorkspace().sessions, + ], + }); + + expect(querySessions(workspace, { onlyPinned: true })).toEqual([ + { + sessionId: 'session-2', + score: 0, + matchedFields: [], + }, + { + sessionId: 'session-4', + score: 0, + matchedFields: [], + }, + ]); + + expect(querySessions(workspace, { workspaceKinds: ['scratchpad'] })).toEqual([ + { + sessionId: 'session-2', + score: 0, + matchedFields: [], + }, + ]); + }); +});