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: [], + }, + ]); + }); +});