mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
[codex] Use project settings in project chats (#29)
* Use project settings in project chats * Allow clippy on ACP send command
This commit is contained in:
@@ -30,6 +30,7 @@ pub async fn discover_acp_providers() -> Vec<AcpProviderResponse> {
|
||||
///
|
||||
/// The actual content arrives asynchronously through `acp:text`, `acp:tool_call`,
|
||||
/// `acp:tool_result`, and `acp:done` events.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[tauri::command]
|
||||
pub async fn acp_send_message(
|
||||
app_handle: AppHandle,
|
||||
@@ -39,8 +40,13 @@ pub async fn acp_send_message(
|
||||
provider_id: String,
|
||||
prompt: String,
|
||||
system_prompt: Option<String>,
|
||||
working_dir: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let working_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let working_dir = working_dir
|
||||
.map(|dir| dir.trim().to_string())
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")));
|
||||
|
||||
AcpService::send_prompt(
|
||||
app_handle,
|
||||
|
||||
@@ -454,8 +454,8 @@ impl AcpService {
|
||||
let store: Arc<dyn Store> = Arc::new(TauriStore::new(session_store));
|
||||
let cancel_token = registry.register(&session_id, &provider_id);
|
||||
|
||||
// Prepend the persona's system prompt to the user's message so the
|
||||
// agent sees the persona instructions as context for this turn.
|
||||
// Prepend the effective system prompt so the agent sees persona and
|
||||
// project instructions as context for this turn.
|
||||
let effective_prompt = match &system_prompt {
|
||||
Some(sp) if !sp.is_empty() => {
|
||||
format!("<persona-instructions>\n{sp}\n</persona-instructions>\n\n{prompt}")
|
||||
|
||||
@@ -146,11 +146,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
const createNewTab = useCallback(
|
||||
async (title = "New Chat", project?: ProjectInfo) => {
|
||||
const agentId = agentStore.activeAgentId ?? undefined;
|
||||
const providerId = project?.preferredProvider ?? homeSelectedProvider;
|
||||
|
||||
const session = await sessionStore.createSession({
|
||||
title,
|
||||
projectId: project?.id,
|
||||
agentId,
|
||||
providerId,
|
||||
personaId: homeSelectedPersonaId,
|
||||
});
|
||||
|
||||
@@ -160,26 +162,15 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
// Set the active session in chatStore
|
||||
chatStore.setActiveSession(session.id);
|
||||
|
||||
// Inject project context as a system message if starting from a project
|
||||
if (project?.prompt) {
|
||||
chatStore.addMessage(session.id, {
|
||||
id: crypto.randomUUID(),
|
||||
role: "system",
|
||||
created: Date.now(),
|
||||
content: [
|
||||
{
|
||||
type: "systemNotification",
|
||||
notificationType: "info",
|
||||
text: `Project: ${project.name}\n\n${project.prompt}`,
|
||||
},
|
||||
],
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
[chatStore, sessionStore, agentStore.activeAgentId, homeSelectedPersonaId],
|
||||
[
|
||||
chatStore,
|
||||
sessionStore,
|
||||
agentStore.activeAgentId,
|
||||
homeSelectedPersonaId,
|
||||
homeSelectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
const handleStartChatFromProject = useCallback(
|
||||
@@ -282,6 +273,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
createNewTab(initialMessage?.slice(0, 40) || "New Chat").catch(() => {
|
||||
// Clear pending message if tab creation fails to avoid stale state
|
||||
setPendingInitialMessage(undefined);
|
||||
setHomeSelectedProvider(undefined);
|
||||
setHomeSelectedPersonaId(undefined);
|
||||
});
|
||||
},
|
||||
@@ -358,6 +350,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
initialMessage={pendingInitialMessage}
|
||||
onInitialMessageConsumed={() => {
|
||||
setPendingInitialMessage(undefined);
|
||||
setHomeSelectedProvider(undefined);
|
||||
setHomeSelectedPersonaId(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useChat(
|
||||
providerOverride?: string,
|
||||
systemPromptOverride?: string,
|
||||
personaInfo?: { id: string; name: string },
|
||||
workingDirOverride?: string,
|
||||
) {
|
||||
const store = useChatStore();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -67,9 +68,15 @@ export function useChat(
|
||||
systemPromptOverride ?? agent?.systemPrompt ?? undefined;
|
||||
|
||||
// Send via ACP — response streams back through Tauri events
|
||||
// which are handled by useAcpStream in ChatView
|
||||
// which are handled by useAcpStream in ChatView.
|
||||
store.setChatState("streaming");
|
||||
await acpSendMessage(sessionId, providerId, text, systemPrompt);
|
||||
await acpSendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
text,
|
||||
systemPrompt,
|
||||
workingDirOverride,
|
||||
);
|
||||
// Note: setChatState("idle") is handled by useAcpStream on "acp:done"
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
@@ -92,6 +99,7 @@ export function useChat(
|
||||
providerOverride,
|
||||
systemPromptOverride,
|
||||
personaInfo,
|
||||
workingDirOverride,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ interface ChatInputProps {
|
||||
onModelChange?: (modelId: string) => void;
|
||||
// Folder
|
||||
folder?: string | null;
|
||||
availableFolders?: Array<{ id: string; name: string }>;
|
||||
availableFolders?: Array<{ id: string; name: string; path?: string }>;
|
||||
onFolderChange?: (folderId: string | null) => void;
|
||||
// Context
|
||||
contextTokens?: number;
|
||||
|
||||
@@ -39,7 +39,7 @@ interface ChatInputToolbarProps {
|
||||
onModelChange?: (modelId: string) => void;
|
||||
// Folder
|
||||
folder: string | null;
|
||||
availableFolders: Array<{ id: string; name: string }>;
|
||||
availableFolders: Array<{ id: string; name: string; path?: string }>;
|
||||
onFolderChange?: (folderId: string | null) => void;
|
||||
// Context
|
||||
contextTokens: number;
|
||||
@@ -77,6 +77,7 @@ export function ChatInputToolbar({
|
||||
}: ChatInputToolbarProps) {
|
||||
const selectedFolder = availableFolders.find((f) => f.id === folder);
|
||||
const folderLabel = selectedFolder?.name ?? "Folder";
|
||||
const folderTitle = selectedFolder?.path ?? undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -179,6 +180,7 @@ export function ChatInputToolbar({
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-foreground-tertiary transition-colors hover:bg-background-tertiary hover:text-foreground"
|
||||
aria-label="Select folder"
|
||||
title={folderTitle}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{!isCompact && <span>{folderLabel}</span>}
|
||||
@@ -195,7 +197,14 @@ export function ChatInputToolbar({
|
||||
onSelect={() => onFolderChange?.(f.id)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="text-sm">{f.name}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{f.name}</span>
|
||||
{f.path && (
|
||||
<span className="block truncate text-xs text-foreground-tertiary">
|
||||
{f.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{f.id === folder && (
|
||||
<Check className="h-4 w-4 shrink-0 text-foreground-secondary" />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { MessageTimeline } from "./MessageTimeline";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { LoadingGoose } from "./LoadingGoose";
|
||||
@@ -8,6 +8,13 @@ import { useChatStore } from "../stores/chatStore";
|
||||
import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useChatSessionStore } from "../stores/chatSessionStore";
|
||||
import { getProject, type ProjectInfo } from "@/features/projects/api/projects";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import {
|
||||
buildProjectSystemPrompt,
|
||||
composeSystemPrompt,
|
||||
getProjectFolderOption,
|
||||
} from "@/features/projects/lib/chatProjectContext";
|
||||
|
||||
interface ChatViewProps {
|
||||
sessionId?: string;
|
||||
@@ -30,17 +37,70 @@ export function ChatView({
|
||||
}: ChatViewProps) {
|
||||
const [activeSessionId] = useState(() => sessionId ?? crypto.randomUUID());
|
||||
const [providers, setProviders] = useState<AcpProvider[]>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState(
|
||||
initialProvider ?? "goose",
|
||||
);
|
||||
|
||||
// Persona state
|
||||
const personas = useAgentStore((s) => s.personas);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string>(
|
||||
initialPersonaId ?? "builtin-goose",
|
||||
);
|
||||
const session = useChatSessionStore((s) =>
|
||||
s.sessions.find((candidate) => candidate.id === activeSessionId),
|
||||
);
|
||||
const storedProject = useProjectStore((s) =>
|
||||
session?.projectId
|
||||
? s.projects.find((candidate) => candidate.id === session.projectId)
|
||||
: undefined,
|
||||
);
|
||||
const [fallbackProject, setFallbackProject] = useState<ProjectInfo | null>(
|
||||
null,
|
||||
);
|
||||
const project = storedProject ?? fallbackProject;
|
||||
const effectiveProvider =
|
||||
session?.providerId ??
|
||||
initialProvider ??
|
||||
project?.preferredProvider ??
|
||||
"goose";
|
||||
const [selectedProvider, setSelectedProvider] = useState(effectiveProvider);
|
||||
|
||||
const selectedPersona = personas.find((p) => p.id === selectedPersonaId);
|
||||
const projectFolder = useMemo(
|
||||
() => getProjectFolderOption(project),
|
||||
[project],
|
||||
);
|
||||
const projectSystemPrompt = useMemo(
|
||||
() => buildProjectSystemPrompt(project),
|
||||
[project],
|
||||
);
|
||||
const effectiveSystemPrompt = useMemo(
|
||||
() =>
|
||||
composeSystemPrompt(selectedPersona?.systemPrompt, projectSystemPrompt),
|
||||
[selectedPersona?.systemPrompt, projectSystemPrompt],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!session?.projectId || storedProject) {
|
||||
setFallbackProject(null);
|
||||
return;
|
||||
}
|
||||
|
||||
getProject(session.projectId)
|
||||
.then((projectInfo) => {
|
||||
if (!cancelled) {
|
||||
setFallbackProject(projectInfo);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFallbackProject(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session?.projectId, storedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
discoverAcpProviders()
|
||||
@@ -59,6 +119,26 @@ export function ChatView({
|
||||
.catch(() => setProviders([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProvider((current) =>
|
||||
current === effectiveProvider ? current : effectiveProvider,
|
||||
);
|
||||
}, [effectiveProvider]);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(providerId: string) => {
|
||||
if (providerId === selectedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedProvider(providerId);
|
||||
useChatSessionStore
|
||||
.getState()
|
||||
.updateSession(activeSessionId, { providerId });
|
||||
},
|
||||
[activeSessionId, selectedProvider],
|
||||
);
|
||||
|
||||
// When persona changes, update the provider to match persona's default
|
||||
const handlePersonaChange = useCallback(
|
||||
(personaId: string) => {
|
||||
@@ -71,7 +151,7 @@ export function ChatView({
|
||||
p.label.toLowerCase().includes(persona.provider ?? ""),
|
||||
);
|
||||
if (matchingProvider) {
|
||||
setSelectedProvider(matchingProvider.id);
|
||||
handleProviderChange(matchingProvider.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +169,7 @@ export function ChatView({
|
||||
.getState()
|
||||
.updateSession(activeSessionId, { personaId });
|
||||
},
|
||||
[personas, providers, activeSessionId],
|
||||
[personas, providers, activeSessionId, handleProviderChange],
|
||||
);
|
||||
|
||||
// Validate persona still exists — fall back to default if deleted
|
||||
@@ -124,8 +204,9 @@ export function ChatView({
|
||||
} = useChat(
|
||||
activeSessionId,
|
||||
selectedProvider,
|
||||
selectedPersona?.systemPrompt,
|
||||
effectiveSystemPrompt,
|
||||
personaInfo,
|
||||
projectFolder?.path,
|
||||
);
|
||||
|
||||
// Listen for ACP streaming events
|
||||
@@ -235,7 +316,9 @@ export function ChatView({
|
||||
// Providers (secondary)
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onProviderChange={setSelectedProvider}
|
||||
onProviderChange={handleProviderChange}
|
||||
folder={projectFolder?.id ?? null}
|
||||
availableFolders={projectFolder ? [projectFolder] : []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -72,6 +72,23 @@ describe("ChatInput", () => {
|
||||
expect(screen.getByText("Goose")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selected folder name in the toolbar", () => {
|
||||
render(
|
||||
<ChatInput
|
||||
onSend={vi.fn()}
|
||||
folder="/Users/wesb/dev/goose2"
|
||||
availableFolders={[
|
||||
{
|
||||
id: "/Users/wesb/dev/goose2",
|
||||
name: "goose2",
|
||||
path: "/Users/wesb/dev/goose2",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("goose2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows stop button when streaming", () => {
|
||||
render(<ChatInput onSend={vi.fn()} onStop={vi.fn()} isStreaming />);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildProjectSystemPrompt,
|
||||
composeSystemPrompt,
|
||||
getProjectFolderName,
|
||||
getProjectFolderOption,
|
||||
} from "./chatProjectContext";
|
||||
|
||||
describe("chatProjectContext", () => {
|
||||
it("builds project instructions from stored project settings", () => {
|
||||
const systemPrompt = buildProjectSystemPrompt({
|
||||
id: "project-1",
|
||||
name: "Goose2",
|
||||
description: "Desktop app",
|
||||
prompt: "Always read AGENTS.md before editing.",
|
||||
icon: "folder",
|
||||
color: "#000000",
|
||||
preferredProvider: "goose",
|
||||
preferredModel: "claude-sonnet-4",
|
||||
workingDir: "/Users/wesb/dev/goose2",
|
||||
useWorktrees: true,
|
||||
order: 0,
|
||||
archivedAt: null,
|
||||
createdAt: "now",
|
||||
updatedAt: "now",
|
||||
});
|
||||
|
||||
expect(systemPrompt).toContain("<project-settings>");
|
||||
expect(systemPrompt).toContain("Project name: Goose2");
|
||||
expect(systemPrompt).toContain("Working directory: /Users/wesb/dev/goose2");
|
||||
expect(systemPrompt).toContain("Preferred provider: goose");
|
||||
expect(systemPrompt).toContain(
|
||||
"Use git worktrees for branch isolation: yes",
|
||||
);
|
||||
expect(systemPrompt).toContain("<project-instructions>");
|
||||
expect(systemPrompt).toContain("Always read AGENTS.md before editing.");
|
||||
});
|
||||
|
||||
it("combines persona and project prompts without empty sections", () => {
|
||||
expect(
|
||||
composeSystemPrompt("Persona prompt", undefined, "Project prompt"),
|
||||
).toBe("Persona prompt\n\nProject prompt");
|
||||
});
|
||||
|
||||
it("extracts the folder name from a path", () => {
|
||||
expect(getProjectFolderName("/Users/wesb/dev/goose2")).toBe("goose2");
|
||||
expect(getProjectFolderName("C:\\Users\\wesb\\goose2\\")).toBe("goose2");
|
||||
});
|
||||
|
||||
it("creates a folder option from the project's working directory", () => {
|
||||
expect(
|
||||
getProjectFolderOption({
|
||||
workingDir: "/Users/wesb/dev/goose2",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "/Users/wesb/dev/goose2",
|
||||
name: "goose2",
|
||||
path: "/Users/wesb/dev/goose2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ProjectInfo } from "../api/projects";
|
||||
|
||||
export interface ProjectFolderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
function trimValue(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function getProjectFolderName(path: string): string {
|
||||
const normalized = path.replace(/[\\/]+$/, "");
|
||||
if (!normalized) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const parts = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
export function getProjectFolderOption(
|
||||
project: Pick<ProjectInfo, "workingDir"> | null | undefined,
|
||||
): ProjectFolderOption | null {
|
||||
const workingDir = trimValue(project?.workingDir);
|
||||
if (!workingDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: workingDir,
|
||||
name: getProjectFolderName(workingDir),
|
||||
path: workingDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjectSystemPrompt(
|
||||
project: ProjectInfo | null | undefined,
|
||||
): string | undefined {
|
||||
if (!project) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings: string[] = [`Project name: ${project.name}`];
|
||||
const description = trimValue(project.description);
|
||||
const workingDir = trimValue(project.workingDir);
|
||||
const prompt = trimValue(project.prompt);
|
||||
|
||||
if (description) {
|
||||
settings.push(`Project description: ${description}`);
|
||||
}
|
||||
if (workingDir) {
|
||||
settings.push(`Working directory: ${workingDir}`);
|
||||
}
|
||||
if (project.preferredProvider) {
|
||||
settings.push(`Preferred provider: ${project.preferredProvider}`);
|
||||
}
|
||||
if (project.preferredModel) {
|
||||
settings.push(`Preferred model: ${project.preferredModel}`);
|
||||
}
|
||||
settings.push(
|
||||
`Use git worktrees for branch isolation: ${
|
||||
project.useWorktrees ? "yes" : "no"
|
||||
}`,
|
||||
);
|
||||
|
||||
const sections = [
|
||||
`<project-settings>\n${settings.join("\n")}\n</project-settings>`,
|
||||
];
|
||||
|
||||
if (prompt) {
|
||||
sections.push(`<project-instructions>\n${prompt}\n</project-instructions>`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
export function composeSystemPrompt(
|
||||
...parts: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
const combined = parts
|
||||
.map((part) => trimValue(part))
|
||||
.filter((part): part is string => part !== null);
|
||||
|
||||
return combined.length > 0 ? combined.join("\n\n") : undefined;
|
||||
}
|
||||
@@ -16,12 +16,14 @@ export async function acpSendMessage(
|
||||
providerId: string,
|
||||
prompt: string,
|
||||
systemPrompt?: string,
|
||||
workingDir?: string,
|
||||
): Promise<void> {
|
||||
return invoke("acp_send_message", {
|
||||
sessionId,
|
||||
providerId,
|
||||
prompt,
|
||||
systemPrompt: systemPrompt ?? null,
|
||||
workingDir: workingDir ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user