diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 45b5bf5..ade34ca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,7 +33,7 @@ flowchart LR Renderer[Renderer UI
React + Tailwind] Preload[Preload bridge] Main[Electron main process] - Workspace[Workspace storage
JSON + scratchpad files] + Workspace[Workspace storage
workspace.json + per-session scratchpad directories] Git[Local git repositories] Sidecar[.NET sidecar] Copilot[GitHub Copilot CLI
+ agent runtime] @@ -122,7 +122,7 @@ Projects are the container for context. There are two kinds: - a special **scratchpad** project for lightweight work - normal **project-backed** entries pointing at local folders -The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. +The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations. ### Patterns @@ -278,7 +278,7 @@ This lets the application treat tooling as reusable workspace capability while s ### Project awareness -Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful. +Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful. ### Execution observability diff --git a/README.md b/README.md index cadd7cb..efcf10f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ It works especially well when you want AI help that stays grounded in an actual ### Ask quick questions in a scratchpad If you just want to think through an idea, draft something, or ask for help without connecting a project, start a scratchpad session and begin chatting. +Each scratchpad session keeps its own isolated working directory, so files created in one scratchpad do not leak into another. ### Connect a real project diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 19f921b..4164a14 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events'; -import { rm } from 'node:fs/promises'; +import { mkdir, rm } from 'node:fs/promises'; import { basename, dirname } from 'node:path'; import electron from 'electron'; @@ -101,6 +101,7 @@ import { createId, nowIso } from '@shared/utils/ids'; import { mergeStreamingText } from '@shared/utils/streamingText'; import { WorkspaceRepository } from '@main/persistence/workspaceRepository'; +import { getScratchpadSessionPath } from '@main/persistence/appPaths'; import { SecretStore } from '@main/secrets/secretStore'; import { ConfigScannerRegistry } from '@main/services/configScanner'; import { @@ -521,6 +522,7 @@ export class AryxAppService extends EventEmitter { runs: [], }; + await this.ensureScratchpadSessionDirectory(session); workspace.sessions.unshift(session); workspace.selectedProjectId = project.id; workspace.selectedPatternId = pattern.id; @@ -532,7 +534,11 @@ export class AryxAppService extends EventEmitter { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); const duplicate = duplicateSessionRecord(session, createId('session'), nowIso()); + if (isScratchpadProject(duplicate.projectId)) { + duplicate.cwd = undefined; + } + await this.ensureScratchpadSessionDirectory(duplicate); workspace.sessions.unshift(duplicate); workspace.selectedProjectId = duplicate.projectId; workspace.selectedPatternId = duplicate.patternId; @@ -572,6 +578,16 @@ export class AryxAppService extends EventEmitter { throw new Error(`Session ${sessionId} not found.`); } + const session = workspace.sessions[sessionIndex]; + if (!session) { + throw new Error(`Session ${sessionId} not found.`); + } + + const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session); + if (scratchpadDirectory) { + await rm(scratchpadDirectory, { recursive: true, force: true }); + } + workspace.sessions.splice(sessionIndex, 1); if (workspace.selectedSessionId === sessionId) { @@ -654,7 +670,7 @@ export class AryxAppService extends EventEmitter { type: 'run-turn', requestId, sessionId: session.id, - projectPath: project.path, + projectPath: session.cwd ?? project.path, workspaceKind, mode: session.interactionMode ?? 'interactive', messageMode, @@ -1225,6 +1241,26 @@ export class AryxAppService extends EventEmitter { return session; } + private resolveScratchpadSessionDirectory( + session: Pick, + ): string | undefined { + if (!isScratchpadProject(session.projectId)) { + return undefined; + } + + return session.cwd ?? getScratchpadSessionPath(session.id); + } + + private async ensureScratchpadSessionDirectory(session: SessionRecord): Promise { + const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session); + if (!scratchpadDirectory) { + return; + } + + await mkdir(scratchpadDirectory, { recursive: true }); + session.cwd = scratchpadDirectory; + } + private async applyTurnDelta( workspace: WorkspaceState, sessionId: string, diff --git a/src/main/persistence/appPaths.ts b/src/main/persistence/appPaths.ts index f8f0668..cacc51b 100644 --- a/src/main/persistence/appPaths.ts +++ b/src/main/persistence/appPaths.ts @@ -10,3 +10,7 @@ export function getWorkspaceFilePath(): string { export function getScratchpadDirectoryPath(): string { return join(app.getPath('userData'), 'scratchpad'); } + +export function getScratchpadSessionPath(sessionId: string): string { + return join(getScratchpadDirectoryPath(), sessionId); +} diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index e8081df..79cafa4 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -2,9 +2,10 @@ import { mkdir } from 'node:fs/promises'; import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern'; -import { mergeScratchpadProject } from '@shared/domain/project'; +import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project'; import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; +import type { SessionRecord } from '@shared/domain/session'; import { normalizeSessionToolingSelection, normalizeWorkspaceSettings, @@ -17,7 +18,11 @@ import { import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { nowIso } from '@shared/utils/ids'; -import { getScratchpadDirectoryPath, getWorkspaceFilePath } from '@main/persistence/appPaths'; +import { + getScratchpadDirectoryPath, + getScratchpadSessionPath, + getWorkspaceFilePath, +} from '@main/persistence/appPaths'; import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore'; function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] { @@ -71,6 +76,28 @@ export class WorkspaceRepository { })), this.scratchpadPath, ); + const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise => { + const normalizedSession: SessionRecord = { + ...session, + runs: normalizeSessionRunRecords(session.runs), + tooling: normalizeSessionToolingSelection(session.tooling), + approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings), + ...normalizePendingApprovalState({ + pendingApproval: session.pendingApproval, + pendingApprovalQueue: session.pendingApprovalQueue, + }), + }; + if (!isScratchpadProject(normalizedSession.projectId)) { + return normalizedSession; + } + + const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id); + await mkdir(cwd, { recursive: true }); + return { + ...normalizedSession, + cwd, + }; + })); const settings = normalizeWorkspaceSettings(stored.settings); const workspace: WorkspaceState = { @@ -81,16 +108,7 @@ export class WorkspaceRepository { graph: resolvePatternGraph(pattern), })), projects, - sessions: (stored.sessions ?? []).map((session) => ({ - ...session, - runs: normalizeSessionRunRecords(session.runs), - tooling: normalizeSessionToolingSelection(session.tooling), - approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings), - ...normalizePendingApprovalState({ - pendingApproval: session.pendingApproval, - pendingApprovalQueue: session.pendingApprovalQueue, - }), - })), + sessions, settings, selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId) ? stored.selectedProjectId diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 7bffc94..95a4f9c 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -48,6 +48,7 @@ export interface SessionRecord { isPinned?: boolean; isArchived?: boolean; interactionMode?: InteractionMode; + cwd?: string; messages: ChatMessageRecord[]; lastError?: string; sessionModelConfig?: SessionModelConfig; diff --git a/tests/main/appServiceScratchpadDirectories.test.ts b/tests/main/appServiceScratchpadDirectories.test.ts new file mode 100644 index 0000000..b3761ed --- /dev/null +++ b/tests/main/appServiceScratchpadDirectories.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { access, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { PatternDefinition } from '@shared/domain/pattern'; +import { createScratchpadProject } from '@shared/domain/project'; +import type { SessionRecord } from '@shared/domain/session'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +const TIMESTAMP = '2026-03-28T00:00:00.000Z'; +const USER_DATA_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures'; + +mock.module('electron', () => { + const electronMock = { + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', + getPath: () => USER_DATA_PATH, + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { AryxAppService } = await import('@main/AryxAppService'); + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function requireSinglePattern(workspace: WorkspaceState): PatternDefinition { + const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); + if (!pattern) { + throw new Error('Expected the workspace seed to include a single-agent pattern.'); + } + + return pattern; +} + +function createScratchpadSession(patternId: string, overrides?: Partial): SessionRecord { + return { + id: 'session-scratchpad', + projectId: 'project-scratchpad', + patternId, + title: 'Scratchpad', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + status: 'idle', + messages: [], + runs: [], + ...overrides, + }; +} + +function createService( + workspace: WorkspaceState, + options?: { + onDeleteSession?: (sessionId: string) => Promise; + }, +): InstanceType { + const service = new AryxAppService(); + const internals = service as unknown as Record; + internals.loadWorkspace = async () => workspace; + internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace; + internals.loadAvailableModelCatalog = async () => []; + + ( + service as unknown as { + sidecar: { + deleteSession: (sessionId: string) => Promise; + }; + } + ).sidecar = { + deleteSession: async (sessionId: string) => { + await options?.onDeleteSession?.(sessionId); + }, + }; + + return service; +} + +beforeEach(async () => { + await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true }); +}); + +afterEach(async () => { + await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true }); +}); + +describe('AryxAppService scratchpad directories', () => { + test('creates a dedicated directory for new scratchpad sessions', async () => { + const workspace = createWorkspaceSeed(); + const pattern = requireSinglePattern(workspace); + const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP); + workspace.projects = [scratchpadProject]; + workspace.selectedProjectId = scratchpadProject.id; + workspace.selectedPatternId = pattern.id; + + const service = createService(workspace); + + const result = await service.createSession(scratchpadProject.id, pattern.id); + const session = result.sessions[0]; + if (!session) { + throw new Error('Expected createSession to prepend a scratchpad session.'); + } + + expect(session.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', session.id)); + expect(await pathExists(session.cwd!)).toBe(true); + }); + + test('creates a new directory when duplicating a scratchpad session', async () => { + const workspace = createWorkspaceSeed(); + const pattern = requireSinglePattern(workspace); + const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP); + const originalDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-original'); + const originalSession = createScratchpadSession(pattern.id, { + id: 'session-original', + cwd: originalDirectory, + messages: [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Draft a plan.', + createdAt: TIMESTAMP, + }, + ], + }); + + workspace.projects = [scratchpadProject]; + workspace.sessions = [originalSession]; + workspace.selectedProjectId = scratchpadProject.id; + workspace.selectedPatternId = pattern.id; + workspace.selectedSessionId = originalSession.id; + + const service = createService(workspace); + + const result = await service.duplicateSession(originalSession.id); + const duplicate = result.sessions[0]; + if (!duplicate) { + throw new Error('Expected duplicateSession to prepend the duplicate.'); + } + + expect(duplicate.id).not.toBe(originalSession.id); + expect(duplicate.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', duplicate.id)); + expect(duplicate.cwd).not.toBe(originalSession.cwd); + expect(await pathExists(duplicate.cwd!)).toBe(true); + }); + + test('removes the scratchpad directory when deleting a session', async () => { + const workspace = createWorkspaceSeed(); + const pattern = requireSinglePattern(workspace); + const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP); + const sessionDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-scratchpad'); + const session = createScratchpadSession(pattern.id, { + cwd: sessionDirectory, + }); + + workspace.projects = [scratchpadProject]; + workspace.sessions = [session]; + workspace.selectedProjectId = scratchpadProject.id; + workspace.selectedPatternId = pattern.id; + workspace.selectedSessionId = session.id; + + const service = createService(workspace); + await mkdir(sessionDirectory, { recursive: true }); + expect(await pathExists(sessionDirectory)).toBe(true); + + const result = await service.deleteSession(session.id); + + expect(result.sessions).toHaveLength(0); + expect(await pathExists(sessionDirectory)).toBe(false); + }); +}); diff --git a/tests/main/appServiceTooling.test.ts b/tests/main/appServiceTooling.test.ts index d42e7e4..fb28826 100644 --- a/tests/main/appServiceTooling.test.ts +++ b/tests/main/appServiceTooling.test.ts @@ -8,6 +8,7 @@ import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspa const TIMESTAMP = '2026-03-25T00:00:00.000Z'; const SCRATCHPAD_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\scratchpad'; +const SCRATCHPAD_SESSION_PATH = `${SCRATCHPAD_PATH}\\session-scratchpad`; mock.module('electron', () => { const electronMock = { @@ -60,6 +61,7 @@ function createWorkspaceFixture(): { createdAt: TIMESTAMP, updatedAt: TIMESTAMP, status: 'idle', + cwd: SCRATCHPAD_SESSION_PATH, messages: [], runs: [], }; @@ -186,7 +188,7 @@ describe('AryxAppService scratchpad tooling support', () => { expect(command).toMatchObject({ sessionId: session.id, - projectPath: SCRATCHPAD_PATH, + projectPath: SCRATCHPAD_SESSION_PATH, workspaceKind: 'scratchpad', tooling: { mcpServers: [ diff --git a/tests/main/workspaceRepositoryScratchpad.test.ts b/tests/main/workspaceRepositoryScratchpad.test.ts new file mode 100644 index 0000000..20757a5 --- /dev/null +++ b/tests/main/workspaceRepositoryScratchpad.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { createScratchpadProject } from '@shared/domain/project'; +import type { SessionRecord } from '@shared/domain/session'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +const TIMESTAMP = '2026-03-28T00:00:00.000Z'; +const USER_DATA_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures'; + +mock.module('electron', () => { + const electronMock = { + app: { + getPath: () => USER_DATA_PATH, + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +const { WorkspaceRepository } = await import('@main/persistence/workspaceRepository'); + +function createStoredWorkspace(): WorkspaceState { + const workspace = createWorkspaceSeed(); + const scratchpadProject = createScratchpadProject('C:\\legacy\\scratchpad', TIMESTAMP); + const patternId = workspace.patterns[0]?.id; + if (!patternId) { + throw new Error('Expected workspace seed to include at least one pattern.'); + } + + const session: SessionRecord = { + id: 'session-scratchpad', + projectId: scratchpadProject.id, + patternId, + title: 'Scratchpad', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + status: 'idle', + messages: [], + runs: [], + }; + + return { + ...workspace, + projects: [scratchpadProject], + sessions: [session], + selectedProjectId: scratchpadProject.id, + selectedPatternId: patternId, + selectedSessionId: session.id, + }; +} + +beforeEach(async () => { + await rm(join(USER_DATA_PATH, 'workspace.json'), { force: true }); + await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true }); +}); + +afterEach(async () => { + await rm(join(USER_DATA_PATH, 'workspace.json'), { force: true }); + await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true }); +}); + +describe('WorkspaceRepository scratchpad migration', () => { + test('assigns per-session scratchpad directories while loading existing workspace state', async () => { + const workspaceFilePath = join(USER_DATA_PATH, 'workspace.json'); + await mkdir(USER_DATA_PATH, { recursive: true }); + await writeFile(workspaceFilePath, JSON.stringify(createStoredWorkspace(), null, 2), 'utf8'); + + const repository = new WorkspaceRepository(); + const loaded = await repository.load(); + const loadedSession = loaded.sessions[0]; + if (!loadedSession) { + throw new Error('Expected load() to return the migrated scratchpad session.'); + } + + const expectedScratchpadPath = join(USER_DATA_PATH, 'scratchpad'); + const expectedSessionPath = join(expectedScratchpadPath, loadedSession.id); + + expect(loaded.projects[0]?.path).toBe(expectedScratchpadPath); + expect(loadedSession.cwd).toBe(expectedSessionPath); + await access(expectedSessionPath); + + const persisted = JSON.parse(await readFile(workspaceFilePath, 'utf8')) as WorkspaceState; + expect(persisted.sessions[0]?.cwd).toBe(expectedSessionPath); + }); +});