mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 13:23:57 +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 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
|
||||||
|
|||||||
@@ -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,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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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