feat: add scratchpad chat sessions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 10:11:42 +01:00
co-authored by Copilot
parent 2f90a19736
commit 1b41cbd1e0
15 changed files with 300 additions and 73 deletions
@@ -71,6 +71,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
{ {
public string SessionId { get; init; } = string.Empty; public string SessionId { get; init; } = string.Empty;
public string ProjectPath { get; init; } = string.Empty; public string ProjectPath { get; init; } = string.Empty;
public string WorkspaceKind { get; init; } = "project";
public PatternDefinitionDto Pattern { get; init; } = new(); public PatternDefinitionDto Pattern { get; init; } = new();
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = []; public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
} }
@@ -7,12 +7,22 @@ internal static class AgentInstructionComposer
public static string Compose( public static string Compose(
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
PatternAgentDefinitionDto agent, PatternAgentDefinitionDto agent,
int agentIndex) int agentIndex,
string workspaceKind = "project")
{ {
string baseInstructions = agent.Instructions.Trim(); 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)) if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
{ {
return baseInstructions; return JoinInstructionBlocks(baseInstructions, workspaceGuidance);
} }
string runtimeGuidance = agentIndex == 0 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. 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) return JoinInstructionBlocks(baseInstructions, workspaceGuidance, runtimeGuidance);
? runtimeGuidance }
: $"{baseInstructions}\n\n{runtimeGuidance}";
private static string JoinInstructionBlocks(params string[] blocks)
{
return string.Join(
"\n\n",
blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim()));
} }
} }
@@ -40,7 +40,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
throw new InvalidOperationException(validationError.Message); 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); Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(ToChatMessage).ToList(); List<ChatMessage> inputMessages = command.Messages.Select(ToChatMessage).ToList();
@@ -436,15 +436,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
public IReadOnlyList<AIAgent> Agents { get; } public IReadOnlyList<AIAgent> Agents { get; }
public static async Task<AgentBundle> CreateAsync( public static async Task<AgentBundle> CreateAsync(
PatternDefinitionDto pattern, RunTurnCommandDto command,
string projectPath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<IAsyncDisposable> disposables = []; List<IAsyncDisposable> disposables = [];
List<AIAgent> agents = []; List<AIAgent> agents = [];
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); 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); CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false); await client.StartAsync(cancellationToken).ConfigureAwait(false);
@@ -455,13 +455,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
ReasoningEffort = definition.ReasoningEffort, ReasoningEffort = definition.ReasoningEffort,
SystemMessage = new SystemMessageConfig 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, OnPermissionRequest = ApprovePermissionAsync,
Streaming = true, Streaming = true,
}; };
if (isScratchpad)
{
sessionConfig.AvailableTools = [];
}
GitHubCopilotAgent agent = new( GitHubCopilotAgent agent = new(
client, client,
sessionConfig, sessionConfig,
@@ -67,6 +67,32 @@ public sealed class AgentInstructionComposerTests
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase); 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) private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
{ {
return new PatternAgentDefinitionDto return new PatternAgentDefinitionDto
+7 -1
View File
@@ -5,7 +5,7 @@ import { dialog } from 'electron';
import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar'; import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern'; 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 { SessionEventRecord } from '@shared/domain/event';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
@@ -75,6 +75,10 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
} }
async removeProject(projectId: string): Promise<WorkspaceState> { async removeProject(projectId: string): Promise<WorkspaceState> {
if (isScratchpadProject(projectId)) {
throw new Error('Scratchpad cannot be removed.');
}
const workspace = await this.loadWorkspace(); const workspace = await this.loadWorkspace();
workspace.projects = workspace.projects.filter((project) => project.id !== projectId); workspace.projects = workspace.projects.filter((project) => project.id !== projectId);
workspace.sessions = workspace.sessions.filter((session) => session.projectId !== projectId); workspace.sessions = workspace.sessions.filter((session) => session.projectId !== projectId);
@@ -187,6 +191,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
}); });
const requestId = createId('turn'); const requestId = createId('turn');
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
try { try {
const responseMessages = await this.sidecar.runTurn( const responseMessages = await this.sidecar.runTurn(
{ {
@@ -194,6 +199,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
requestId, requestId,
sessionId: session.id, sessionId: session.id,
projectPath: project.path, projectPath: project.path,
workspaceKind,
pattern, pattern,
messages: session.messages, messages: session.messages,
}, },
+4
View File
@@ -4,3 +4,7 @@ import { join } from 'node:path';
export function getWorkspaceFilePath(): string { export function getWorkspaceFilePath(): string {
return join(app.getPath('userData'), 'workspace.json'); return join(app.getPath('userData'), 'workspace.json');
} }
export function getScratchpadDirectoryPath(): string {
return join(app.getPath('userData'), 'scratchpad');
}
+20 -3
View File
@@ -1,9 +1,12 @@
import { mkdir } from 'node:fs/promises';
import { createBuiltinPatterns } from '@shared/domain/pattern'; import { createBuiltinPatterns } from '@shared/domain/pattern';
import type { PatternDefinition } 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 { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids'; 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'; import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] { function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
@@ -32,20 +35,34 @@ function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition
export class WorkspaceRepository { export class WorkspaceRepository {
readonly filePath = getWorkspaceFilePath(); readonly filePath = getWorkspaceFilePath();
readonly scratchpadPath = getScratchpadDirectoryPath();
async load(): Promise<WorkspaceState> { async load(): Promise<WorkspaceState> {
await mkdir(this.scratchpadPath, { recursive: true });
const stored = await readJsonFile<WorkspaceState>(this.filePath); const stored = await readJsonFile<WorkspaceState>(this.filePath);
if (!stored) { 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); await this.save(seeded);
return seeded; return seeded;
} }
const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath);
const workspace: WorkspaceState = { const workspace: WorkspaceState = {
...stored, ...stored,
patterns: mergePatterns(stored.patterns ?? []), patterns: mergePatterns(stored.patterns ?? []),
projects: stored.projects ?? [], projects,
sessions: stored.sessions ?? [], sessions: stored.sessions ?? [],
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
? stored.selectedProjectId
: projects[0]?.id,
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(), lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
}; };
+6 -1
View File
@@ -15,6 +15,7 @@ import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import { WelcomePane } from '@renderer/components/WelcomePane'; import { WelcomePane } from '@renderer/components/WelcomePane';
import { getElectronApi } from '@renderer/lib/electronApi'; import { getElectronApi } from '@renderer/lib/electronApi';
import type { PatternDefinition } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject } from '@shared/domain/project';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids'; import { createId, nowIso } from '@shared/utils/ids';
@@ -106,6 +107,10 @@ export default function App() {
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined), () => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities], [selectedSession, sessionActivities],
); );
const hasUserProjects = useMemo(
() => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false),
[workspace?.projects],
);
// Loading state // Loading state
if (!workspace) { if (!workspace) {
@@ -147,7 +152,7 @@ export default function App() {
} else { } else {
content = ( content = (
<WelcomePane <WelcomePane
hasProjects={workspace.projects.length > 0} hasProjects={hasUserProjects}
onAddProject={() => void api.addProject()} onAddProject={() => void api.addProject()}
onNewSession={() => setShowNewSession(true)} onNewSession={() => setShowNewSession(true)}
onOpenSettings={() => setShowSettings(true)} onOpenSettings={() => setShowSettings(true)}
+14 -4
View File
@@ -5,7 +5,7 @@ import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { PatternDefinition } from '@shared/domain/pattern'; 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'; import type { SessionRecord } from '@shared/domain/session';
function ThinkingDots() { function ThinkingDots() {
@@ -31,6 +31,7 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isBusy = session.status === 'running'; const isBusy = session.status === 'running';
const isScratchpad = isScratchpadProject(project);
useEffect(() => { useEffect(() => {
transcriptRef.current?.scrollTo({ transcriptRef.current?.scrollTo({
@@ -60,7 +61,7 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2> <h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2>
<p className="mt-0.5 truncate text-[12px] text-zinc-500"> <p className="mt-0.5 truncate text-[12px] text-zinc-500">
{project.name} · {pattern.name} · {pattern.mode} {isScratchpad ? `Scratchpad · ${pattern.name}` : `${project.name} · ${pattern.name} · ${pattern.mode}`}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -90,8 +91,17 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
Send a message to start the conversation Send a message to start the conversation
</p> </p>
<p className="text-[12px] text-zinc-700"> <p className="text-[12px] text-zinc-700">
Using <span className="text-zinc-500">{pattern.name}</span> in{' '} {isScratchpad ? (
<span className="text-zinc-500">{project.name}</span> <>
Scratchpad is ready for ad-hoc questions using{' '}
<span className="text-zinc-500">{pattern.name}</span>
</>
) : (
<>
Using <span className="text-zinc-500">{pattern.name}</span> in{' '}
<span className="text-zinc-500">{project.name}</span>
</>
)}
</p> </p>
</div> </div>
) : ( ) : (
+28 -3
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { PatternDefinition } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
interface NewSessionModalProps { interface NewSessionModalProps {
projects: ProjectRecord[]; projects: ProjectRecord[];
@@ -19,10 +19,24 @@ export function NewSessionModal({
onClose, onClose,
onCreate, onCreate,
}: NewSessionModalProps) { }: NewSessionModalProps) {
const availablePatterns = patterns.filter((p) => p.availability !== 'unavailable');
const [projectId, setProjectId] = useState(defaultProjectId ?? projects[0]?.id ?? ''); 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 ?? ''); 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; const canCreate = projectId && patternId;
return ( return (
@@ -55,6 +69,12 @@ export function NewSessionModal({
</option> </option>
))} ))}
</select> </select>
{isScratchpadProject(projectId) && (
<p className="text-[12px] leading-relaxed text-zinc-600">
Scratchpad is a projectless 1-on-1 chat for ad-hoc questions. It should behave like
pure Q&A rather than repo automation.
</p>
)}
</label> </label>
<label className="block space-y-1.5"> <label className="block space-y-1.5">
@@ -75,6 +95,11 @@ export function NewSessionModal({
{availablePatterns.find((p) => p.id === patternId)?.description} {availablePatterns.find((p) => p.id === patternId)?.description}
</p> </p>
)} )}
{isScratchpadProject(projectId) && (
<p className="text-[12px] leading-relaxed text-zinc-700">
Scratchpad supports single-agent chat patterns only.
</p>
)}
</label> </label>
</div> </div>
+65 -36
View File
@@ -16,7 +16,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern'; 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 { SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
@@ -161,6 +161,7 @@ function ProjectGroup({
onSessionSelect: (sessionId: string) => void; onSessionSelect: (sessionId: string) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const isScratchpad = isScratchpadProject(project);
const patternMap = useMemo(() => { const patternMap = useMemo(() => {
const map = new Map<string, PatternDefinition>(); const map = new Map<string, PatternDefinition>();
@@ -182,7 +183,11 @@ function ProjectGroup({
) : ( ) : (
<ChevronRight className="size-3 shrink-0 text-zinc-500" /> <ChevronRight className="size-3 shrink-0 text-zinc-500" />
)} )}
<FolderOpen className="size-3.5 shrink-0 text-zinc-500 transition group-hover:text-indigo-400" /> {isScratchpad ? (
<MessageSquare className="size-3.5 shrink-0 text-zinc-500 transition group-hover:text-indigo-400" />
) : (
<FolderOpen className="size-3.5 shrink-0 text-zinc-500 transition group-hover:text-indigo-400" />
)}
<span className="truncate">{project.name}</span> <span className="truncate">{project.name}</span>
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1.5">
@@ -202,7 +207,7 @@ function ProjectGroup({
<div className="ml-2 mt-0.5 space-y-0.5 border-l border-zinc-800/60 pl-2"> <div className="ml-2 mt-0.5 space-y-0.5 border-l border-zinc-800/60 pl-2">
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<div className="px-3 py-3 text-center text-[12px] text-zinc-600"> <div className="px-3 py-3 text-center text-[12px] text-zinc-600">
No sessions yet {isScratchpad ? 'No scratchpad chats yet' : 'No sessions yet'}
</div> </div>
) : ( ) : (
sessions.map((session) => ( sessions.map((session) => (
@@ -231,6 +236,9 @@ export function Sidebar({
onSessionSelect, onSessionSelect,
onOpenSettings, onOpenSettings,
}: SidebarProps) { }: SidebarProps) {
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
const userProjects = workspace.projects.filter((project) => !isScratchpadProject(project));
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header — extra top padding clears the title bar overlay zone */} {/* Header — extra top padding clears the title bar overlay zone */}
@@ -272,48 +280,69 @@ export function Sidebar({
{/* Project + Session Tree */} {/* Project + Session Tree */}
<div className="flex-1 overflow-y-auto px-2 py-2"> <div className="flex-1 overflow-y-auto px-2 py-2">
{workspace.projects.length === 0 ? ( <div className="space-y-3">
<div className="flex flex-col items-center gap-4 px-4 py-10 text-center"> {scratchpadProject && (
<div className="relative"> <div className="space-y-1">
<div className="flex size-14 items-center justify-center rounded-2xl bg-zinc-800/50 ring-1 ring-zinc-700/50"> <div className="px-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-600">
<FolderOpen className="size-7 text-zinc-600" /> Scratchpad
</div> </div>
<div className="absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full bg-indigo-600 ring-2 ring-[var(--color-surface-1)]">
<Plus className="size-3 text-white" />
</div>
</div>
<div>
<p className="text-[13px] font-medium text-zinc-300">No projects yet</p>
<p className="mt-1 text-[12px] leading-relaxed text-zinc-500">
Add a project folder to start<br />orchestrating AI agents
</p>
</div>
<button
className="rounded-lg bg-indigo-600 px-4 py-2 text-[13px] font-medium text-white transition hover:bg-indigo-500"
onClick={onAddProject}
type="button"
>
Add Project
</button>
</div>
) : (
<div className="space-y-1">
{workspace.projects.map((project) => (
<ProjectGroup <ProjectGroup
key={project.id} key={scratchpadProject.id}
onSessionSelect={onSessionSelect} onSessionSelect={onSessionSelect}
patterns={workspace.patterns} patterns={workspace.patterns}
project={project} project={scratchpadProject}
selectedSessionId={workspace.selectedSessionId} selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((s) => s.projectId === project.id)} sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
/> />
))} </div>
</div> )}
)}
{userProjects.length === 0 ? (
<div className="flex flex-col items-center gap-4 px-4 py-8 text-center">
<div className="relative">
<div className="flex size-14 items-center justify-center rounded-2xl bg-zinc-800/50 ring-1 ring-zinc-700/50">
<FolderOpen className="size-7 text-zinc-600" />
</div>
<div className="absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full bg-indigo-600 ring-2 ring-[var(--color-surface-1)]">
<Plus className="size-3 text-white" />
</div>
</div>
<div>
<p className="text-[13px] font-medium text-zinc-300">No projects yet</p>
<p className="mt-1 text-[12px] leading-relaxed text-zinc-500">
Use Scratchpad for ad-hoc chat or add a repo<br />to work against project files
</p>
</div>
<button
className="rounded-lg bg-indigo-600 px-4 py-2 text-[13px] font-medium text-white transition hover:bg-indigo-500"
onClick={onAddProject}
type="button"
>
Add Project
</button>
</div>
) : (
<div className="space-y-1">
<div className="px-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-600">
Projects
</div>
{userProjects.map((project) => (
<ProjectGroup
key={project.id}
onSessionSelect={onSessionSelect}
patterns={workspace.patterns}
project={project}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
/>
))}
</div>
)}
</div>
</div> </div>
{/* Footer */} {/* Footer */}
{workspace.projects.length > 0 && ( {userProjects.length > 0 && (
<div className="border-t border-[var(--color-border)] px-3 py-2"> <div className="border-t border-[var(--color-border)] px-3 py-2">
<button <button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] text-zinc-500 transition hover:bg-zinc-800/60 hover:text-zinc-300" className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] text-zinc-500 transition hover:bg-zinc-800/60 hover:text-zinc-300"
+13 -14
View File
@@ -23,28 +23,27 @@ export function WelcomePane({
<div> <div>
<h1 className="text-xl font-semibold text-zinc-100">Welcome to kopaya</h1> <h1 className="text-xl font-semibold text-zinc-100">Welcome to kopaya</h1>
<p className="mt-2 max-w-md text-sm leading-relaxed text-zinc-500"> <p className="mt-2 max-w-md text-sm leading-relaxed text-zinc-500">
Orchestrate AI agents across your projects. Start a session to begin a conversation Start a scratchpad conversation for ad-hoc questions or connect a project to work with
with one or more Copilot-backed agents. repo-aware Copilot agents.
</p> </p>
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{hasProjects ? ( <button
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-500"
onClick={onNewSession}
type="button"
>
<Plus className="size-4" />
New Session
</button>
{!hasProjects && (
<button <button
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-500" className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm text-zinc-500 transition hover:bg-zinc-900 hover:text-zinc-300"
onClick={onNewSession}
type="button"
>
<Plus className="size-4" />
New Session
</button>
) : (
<button
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-500"
onClick={onAddProject} onClick={onAddProject}
type="button" type="button"
> >
<Plus className="size-4" /> <Plus className="size-3.5" />
Add Your First Project Add Your First Project
</button> </button>
)} )}
+1
View File
@@ -27,6 +27,7 @@ export interface RunTurnCommand {
requestId: string; requestId: string;
sessionId: string; sessionId: string;
projectPath: string; projectPath: string;
workspaceKind?: 'project' | 'scratchpad';
pattern: PatternDefinition; pattern: PatternDefinition;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];
} }
+38
View File
@@ -1,6 +1,44 @@
import { nowIso } from '@shared/utils/ids';
export interface ProjectRecord { export interface ProjectRecord {
id: string; id: string;
name: string; name: string;
path: string; path: string;
addedAt: 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<ProjectRecord, 'id'>): 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)),
];
}
+46
View File
@@ -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');
});
});