mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-05 11:28:40 +02:00
feat: add scratchpad chat sessions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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<ChatMessageDto> Messages { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatMessage> inputMessages = command.Messages.Select(ToChatMessage).ToList();
|
||||
|
||||
@@ -436,15 +436,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
public IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public static async Task<AgentBundle> CreateAsync(
|
||||
PatternDefinitionDto pattern,
|
||||
string projectPath,
|
||||
RunTurnCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AppServiceEvents> {
|
||||
}
|
||||
|
||||
async removeProject(projectId: string): Promise<WorkspaceState> {
|
||||
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<AppServiceEvents> {
|
||||
});
|
||||
|
||||
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<AppServiceEvents> {
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: project.path,
|
||||
workspaceKind,
|
||||
pattern,
|
||||
messages: session.messages,
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceState> {
|
||||
await mkdir(this.scratchpadPath, { recursive: true });
|
||||
|
||||
const stored = await readJsonFile<WorkspaceState>(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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<WelcomePane
|
||||
hasProjects={workspace.projects.length > 0}
|
||||
hasProjects={hasUserProjects}
|
||||
onAddProject={() => void api.addProject()}
|
||||
onNewSession={() => setShowNewSession(true)}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(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) {
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2>
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
</p>
|
||||
<p className="text-[12px] text-zinc-700">
|
||||
Using <span className="text-zinc-500">{pattern.name}</span> in{' '}
|
||||
<span className="text-zinc-500">{project.name}</span>
|
||||
{isScratchpad ? (
|
||||
<>
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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({
|
||||
</option>
|
||||
))}
|
||||
</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 className="block space-y-1.5">
|
||||
@@ -75,6 +95,11 @@ export function NewSessionModal({
|
||||
{availablePatterns.find((p) => p.id === patternId)?.description}
|
||||
</p>
|
||||
)}
|
||||
{isScratchpadProject(projectId) && (
|
||||
<p className="text-[12px] leading-relaxed text-zinc-700">
|
||||
Scratchpad supports single-agent chat patterns only.
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, PatternDefinition>();
|
||||
@@ -182,7 +183,11 @@ function ProjectGroup({
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{sessions.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header — extra top padding clears the title bar overlay zone */}
|
||||
@@ -272,48 +280,69 @@ export function Sidebar({
|
||||
|
||||
{/* Project + Session Tree */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{workspace.projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 px-4 py-10 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 className="space-y-3">
|
||||
{scratchpadProject && (
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-600">
|
||||
Scratchpad
|
||||
</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
|
||||
key={project.id}
|
||||
key={scratchpadProject.id}
|
||||
onSessionSelect={onSessionSelect}
|
||||
patterns={workspace.patterns}
|
||||
project={project}
|
||||
project={scratchpadProject}
|
||||
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>
|
||||
|
||||
{/* Footer */}
|
||||
{workspace.projects.length > 0 && (
|
||||
{userProjects.length > 0 && (
|
||||
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
||||
<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"
|
||||
|
||||
@@ -23,28 +23,27 @@ export function WelcomePane({
|
||||
<div>
|
||||
<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">
|
||||
Orchestrate AI agents across your projects. Start a session to begin a conversation
|
||||
with one or more Copilot-backed agents.
|
||||
Start a scratchpad conversation for ad-hoc questions or connect a project to work with
|
||||
repo-aware Copilot agents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
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>
|
||||
) : (
|
||||
<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={onAddProject}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<Plus className="size-3.5" />
|
||||
Add Your First Project
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface RunTurnCommand {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
workspaceKind?: 'project' | 'scratchpad';
|
||||
pattern: PatternDefinition;
|
||||
messages: ChatMessageRecord[];
|
||||
}
|
||||
|
||||
@@ -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<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)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user