diff --git a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs index 53ad860..5622642 100644 --- a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs @@ -71,6 +71,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope { public string SessionId { get; init; } = string.Empty; public string ProjectPath { get; init; } = string.Empty; + public string WorkspaceKind { get; init; } = "project"; public PatternDefinitionDto Pattern { get; init; } = new(); public IReadOnlyList Messages { get; init; } = []; } diff --git a/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs b/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs index cade8ce..82256a6 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/AgentInstructionComposer.cs @@ -7,12 +7,22 @@ internal static class AgentInstructionComposer public static string Compose( PatternDefinitionDto pattern, PatternAgentDefinitionDto agent, - int agentIndex) + int agentIndex, + string workspaceKind = "project") { string baseInstructions = agent.Instructions.Trim(); + string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase) + ? """ + You are operating in scratchpad mode. + Treat this session as pure ad-hoc Q&A rather than repository automation. + Do not inspect, modify, create, or delete files, and do not behave as though you are working inside a user project. + Answer conversationally and focus on the user's question directly. + """ + : string.Empty; + if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) { - return baseInstructions; + return JoinInstructionBlocks(baseInstructions, workspaceGuidance); } string runtimeGuidance = agentIndex == 0 @@ -28,8 +38,13 @@ internal static class AgentInstructionComposer Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty. """; - return string.IsNullOrWhiteSpace(baseInstructions) - ? runtimeGuidance - : $"{baseInstructions}\n\n{runtimeGuidance}"; + return JoinInstructionBlocks(baseInstructions, workspaceGuidance, runtimeGuidance); + } + + private static string JoinInstructionBlocks(params string[] blocks) + { + return string.Join( + "\n\n", + blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim())); } } diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index c4353af..c67b40f 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -40,7 +40,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner throw new InvalidOperationException(validationError.Message); } - await using AgentBundle bundle = await AgentBundle.CreateAsync(command.Pattern, command.ProjectPath, cancellationToken); + await using AgentBundle bundle = await AgentBundle.CreateAsync(command, cancellationToken); Workflow workflow = bundle.BuildWorkflow(command.Pattern); List inputMessages = command.Messages.Select(ToChatMessage).ToList(); @@ -436,15 +436,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner public IReadOnlyList Agents { get; } public static async Task CreateAsync( - PatternDefinitionDto pattern, - string projectPath, + RunTurnCommandDto command, CancellationToken cancellationToken) { List disposables = []; List agents = []; CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); + bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase); - foreach ((PatternAgentDefinitionDto definition, int agentIndex) in pattern.Agents.Select((definition, index) => (definition, index))) + foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index))) { CopilotClient client = new(clientOptions); await client.StartAsync(cancellationToken).ConfigureAwait(false); @@ -455,13 +455,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner ReasoningEffort = definition.ReasoningEffort, SystemMessage = new SystemMessageConfig { - Content = AgentInstructionComposer.Compose(pattern, definition, agentIndex), + Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind), }, - WorkingDirectory = projectPath, + WorkingDirectory = command.ProjectPath, OnPermissionRequest = ApprovePermissionAsync, Streaming = true, }; + if (isScratchpad) + { + sessionConfig.AvailableTools = []; + } + GitHubCopilotAgent agent = new( client, sessionConfig, diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs index 6c721fc..ec7e9f9 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/AgentInstructionComposerTests.cs @@ -67,6 +67,32 @@ public sealed class AgentInstructionComposerTests Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Compose_AddsScratchpadGuidanceForProjectlessQaSessions() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-single", + Name = "Single", + Mode = "single", + Availability = "available", + }; + PatternAgentDefinitionDto agent = CreateAgent( + id: "agent-primary", + name: "Primary Agent", + instructions: "You are a helpful assistant."); + + string instructions = AgentInstructionComposer.Compose( + pattern, + agent, + agentIndex: 0, + workspaceKind: "scratchpad"); + + Assert.Contains("scratchpad mode", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("pure ad-hoc Q&A", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not inspect, modify, create, or delete files", instructions, StringComparison.OrdinalIgnoreCase); + } + private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions) { return new PatternAgentDefinitionDto diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index 5e1295e..e3380d9 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -5,7 +5,7 @@ import { dialog } from 'electron'; import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar'; import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern'; -import type { ProjectRecord } from '@shared/domain/project'; +import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import type { SessionEventRecord } from '@shared/domain/event'; import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; import type { WorkspaceState } from '@shared/domain/workspace'; @@ -75,6 +75,10 @@ export class KopayaAppService extends EventEmitter { } async removeProject(projectId: string): Promise { + if (isScratchpadProject(projectId)) { + throw new Error('Scratchpad cannot be removed.'); + } + const workspace = await this.loadWorkspace(); workspace.projects = workspace.projects.filter((project) => project.id !== projectId); workspace.sessions = workspace.sessions.filter((session) => session.projectId !== projectId); @@ -187,6 +191,7 @@ export class KopayaAppService extends EventEmitter { }); const requestId = createId('turn'); + const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; try { const responseMessages = await this.sidecar.runTurn( { @@ -194,6 +199,7 @@ export class KopayaAppService extends EventEmitter { requestId, sessionId: session.id, projectPath: project.path, + workspaceKind, pattern, messages: session.messages, }, diff --git a/src/main/persistence/appPaths.ts b/src/main/persistence/appPaths.ts index 2928966..1d4cf3f 100644 --- a/src/main/persistence/appPaths.ts +++ b/src/main/persistence/appPaths.ts @@ -4,3 +4,7 @@ import { join } from 'node:path'; export function getWorkspaceFilePath(): string { return join(app.getPath('userData'), 'workspace.json'); } + +export function getScratchpadDirectoryPath(): string { + return join(app.getPath('userData'), 'scratchpad'); +} diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 2ff320c..e60db58 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -1,9 +1,12 @@ +import { mkdir } from 'node:fs/promises'; + import { createBuiltinPatterns } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern'; +import { mergeScratchpadProject } from '@shared/domain/project'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { nowIso } from '@shared/utils/ids'; -import { getWorkspaceFilePath } from '@main/persistence/appPaths'; +import { getScratchpadDirectoryPath, getWorkspaceFilePath } from '@main/persistence/appPaths'; import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore'; function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] { @@ -32,20 +35,34 @@ function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition export class WorkspaceRepository { readonly filePath = getWorkspaceFilePath(); + readonly scratchpadPath = getScratchpadDirectoryPath(); async load(): Promise { + await mkdir(this.scratchpadPath, { recursive: true }); + const stored = await readJsonFile(this.filePath); if (!stored) { - const seeded = createWorkspaceSeed(); + const seededBase = createWorkspaceSeed(); + const projects = mergeScratchpadProject([], this.scratchpadPath); + const seeded: WorkspaceState = { + ...seededBase, + projects, + selectedProjectId: projects[0]?.id, + }; await this.save(seeded); return seeded; } + const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath); + const workspace: WorkspaceState = { ...stored, patterns: mergePatterns(stored.patterns ?? []), - projects: stored.projects ?? [], + projects, sessions: stored.sessions ?? [], + selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId) + ? stored.selectedProjectId + : projects[0]?.id, lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(), }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ba48f69..d4e74f8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -15,6 +15,7 @@ import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { WelcomePane } from '@renderer/components/WelcomePane'; import { getElectronApi } from '@renderer/lib/electronApi'; import type { PatternDefinition } from '@shared/domain/pattern'; +import { isScratchpadProject } from '@shared/domain/project'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; @@ -106,6 +107,10 @@ export default function App() { () => (selectedSession ? sessionActivities[selectedSession.id] : undefined), [selectedSession, sessionActivities], ); + const hasUserProjects = useMemo( + () => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false), + [workspace?.projects], + ); // Loading state if (!workspace) { @@ -147,7 +152,7 @@ export default function App() { } else { content = ( 0} + hasProjects={hasUserProjects} onAddProject={() => void api.addProject()} onNewSession={() => setShowNewSession(true)} onOpenSettings={() => setShowSettings(true)} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index ff12b39..ee47772 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -5,7 +5,7 @@ import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { PatternDefinition } from '@shared/domain/pattern'; -import type { ProjectRecord } from '@shared/domain/project'; +import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; function ThinkingDots() { @@ -31,6 +31,7 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) { const textareaRef = useRef(null); const isBusy = session.status === 'running'; + const isScratchpad = isScratchpadProject(project); useEffect(() => { transcriptRef.current?.scrollTo({ @@ -60,7 +61,7 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {

{session.title}

- {project.name} · {pattern.name} · {pattern.mode} + {isScratchpad ? `Scratchpad · ${pattern.name}` : `${project.name} · ${pattern.name} · ${pattern.mode}`}

@@ -90,8 +91,17 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) { Send a message to start the conversation

- Using {pattern.name} in{' '} - {project.name} + {isScratchpad ? ( + <> + Scratchpad is ready for ad-hoc questions using{' '} + {pattern.name} + + ) : ( + <> + Using {pattern.name} in{' '} + {project.name} + + )}

) : ( diff --git a/src/renderer/components/NewSessionModal.tsx b/src/renderer/components/NewSessionModal.tsx index 3c16e3e..80f6194 100644 --- a/src/renderer/components/NewSessionModal.tsx +++ b/src/renderer/components/NewSessionModal.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { X } from 'lucide-react'; import type { PatternDefinition } from '@shared/domain/pattern'; -import type { ProjectRecord } from '@shared/domain/project'; +import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; interface NewSessionModalProps { projects: ProjectRecord[]; @@ -19,10 +19,24 @@ export function NewSessionModal({ onClose, onCreate, }: NewSessionModalProps) { - const availablePatterns = patterns.filter((p) => p.availability !== 'unavailable'); const [projectId, setProjectId] = useState(defaultProjectId ?? projects[0]?.id ?? ''); + const availablePatterns = useMemo( + () => + patterns.filter( + (pattern) => + pattern.availability !== 'unavailable' + && (!isScratchpadProject(projectId) || pattern.mode === 'single'), + ), + [patterns, projectId], + ); const [patternId, setPatternId] = useState(availablePatterns[0]?.id ?? ''); + useEffect(() => { + if (!availablePatterns.some((pattern) => pattern.id === patternId)) { + setPatternId(availablePatterns[0]?.id ?? ''); + } + }, [availablePatterns, patternId]); + const canCreate = projectId && patternId; return ( @@ -55,6 +69,12 @@ export function NewSessionModal({ ))} + {isScratchpadProject(projectId) && ( +

+ Scratchpad is a projectless 1-on-1 chat for ad-hoc questions. It should behave like + pure Q&A rather than repo automation. +

+ )} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 0802efa..e5cb5e3 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -16,7 +16,7 @@ import { } from 'lucide-react'; import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern'; -import type { ProjectRecord } from '@shared/domain/project'; +import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; import type { WorkspaceState } from '@shared/domain/workspace'; @@ -161,6 +161,7 @@ function ProjectGroup({ onSessionSelect: (sessionId: string) => void; }) { const [expanded, setExpanded] = useState(true); + const isScratchpad = isScratchpadProject(project); const patternMap = useMemo(() => { const map = new Map(); @@ -182,7 +183,11 @@ function ProjectGroup({ ) : ( )} - + {isScratchpad ? ( + + ) : ( + + )} {project.name}
@@ -202,7 +207,7 @@ function ProjectGroup({
{sessions.length === 0 ? (
- No sessions yet + {isScratchpad ? 'No scratchpad chats yet' : 'No sessions yet'}
) : ( sessions.map((session) => ( @@ -231,6 +236,9 @@ export function Sidebar({ onSessionSelect, onOpenSettings, }: SidebarProps) { + const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project)); + const userProjects = workspace.projects.filter((project) => !isScratchpadProject(project)); + return (
{/* Header — extra top padding clears the title bar overlay zone */} @@ -272,48 +280,69 @@ export function Sidebar({ {/* Project + Session Tree */}
- {workspace.projects.length === 0 ? ( -
-
-
- +
+ {scratchpadProject && ( +
+
+ Scratchpad
-
- -
-
-
-

No projects yet

-

- Add a project folder to start
orchestrating AI agents -

-
- -
- ) : ( -
- {workspace.projects.map((project) => ( s.projectId === project.id)} + sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)} /> - ))} -
- )} +
+ )} + + {userProjects.length === 0 ? ( +
+
+
+ +
+
+ +
+
+
+

No projects yet

+

+ Use Scratchpad for ad-hoc chat or add a repo
to work against project files +

+
+ +
+ ) : ( +
+
+ Projects +
+ {userProjects.map((project) => ( + session.projectId === project.id)} + /> + ))} +
+ )} +
{/* Footer */} - {workspace.projects.length > 0 && ( + {userProjects.length > 0 && (
- {hasProjects ? ( + + {!hasProjects && ( - ) : ( - )} diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index ff7f270..3de40c1 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -27,6 +27,7 @@ export interface RunTurnCommand { requestId: string; sessionId: string; projectPath: string; + workspaceKind?: 'project' | 'scratchpad'; pattern: PatternDefinition; messages: ChatMessageRecord[]; } diff --git a/src/shared/domain/project.ts b/src/shared/domain/project.ts index b22ddf6..b8fbe88 100644 --- a/src/shared/domain/project.ts +++ b/src/shared/domain/project.ts @@ -1,6 +1,44 @@ +import { nowIso } from '@shared/utils/ids'; + export interface ProjectRecord { id: string; name: string; path: string; addedAt: string; } + +export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad'; +export const SCRATCHPAD_PROJECT_NAME = 'Scratchpad'; + +export function createScratchpadProject(path: string, addedAt = nowIso()): ProjectRecord { + return { + id: SCRATCHPAD_PROJECT_ID, + name: SCRATCHPAD_PROJECT_NAME, + path, + addedAt, + }; +} + +export function isScratchpadProject(projectIdOrProject?: string | Pick): boolean { + if (!projectIdOrProject) { + return false; + } + + return ( + (typeof projectIdOrProject === 'string' ? projectIdOrProject : projectIdOrProject.id) + === SCRATCHPAD_PROJECT_ID + ); +} + +export function mergeScratchpadProject(existingProjects: ProjectRecord[], scratchpadPath: string): ProjectRecord[] { + const existingScratchpad = existingProjects.find((project) => isScratchpadProject(project)); + const scratchpadProject = createScratchpadProject( + scratchpadPath, + existingScratchpad?.addedAt ?? nowIso(), + ); + + return [ + scratchpadProject, + ...existingProjects.filter((project) => !isScratchpadProject(project.id)), + ]; +} diff --git a/tests/shared/project.test.ts b/tests/shared/project.test.ts new file mode 100644 index 0000000..90b5a70 --- /dev/null +++ b/tests/shared/project.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; + +import { + SCRATCHPAD_PROJECT_ID, + SCRATCHPAD_PROJECT_NAME, + createScratchpadProject, + isScratchpadProject, + mergeScratchpadProject, +} from '@shared/domain/project'; + +describe('scratchpad project helpers', () => { + test('creates a stable built-in scratchpad project', () => { + const project = createScratchpadProject('C:\\Users\\me\\AppData\\Roaming\\kopaya\\scratchpad'); + + expect(project.id).toBe(SCRATCHPAD_PROJECT_ID); + expect(project.name).toBe(SCRATCHPAD_PROJECT_NAME); + expect(project.path).toContain('scratchpad'); + }); + + test('recognizes scratchpad project ids and records', () => { + expect(isScratchpadProject(SCRATCHPAD_PROJECT_ID)).toBe(true); + expect( + isScratchpadProject({ + id: SCRATCHPAD_PROJECT_ID, + }), + ).toBe(true); + expect(isScratchpadProject('project-normal')).toBe(false); + }); + + test('merges scratchpad project ahead of normal user projects and preserves user projects', () => { + const merged = mergeScratchpadProject( + [ + { + id: 'project-a', + name: 'Repo A', + path: 'C:\\repo-a', + addedAt: '2026-03-23T00:00:00.000Z', + }, + ], + 'C:\\Users\\me\\AppData\\Roaming\\kopaya\\scratchpad', + ); + + expect(merged[0].id).toBe(SCRATCHPAD_PROJECT_ID); + expect(merged[1].id).toBe('project-a'); + }); +});