From f63fa19aba43f0ca8b614677fe2945dc2a48b011 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 31 Mar 2026 15:22:56 -0700 Subject: [PATCH] [codex] Use project settings in project chats (#29) * Use project settings in project chats * Allow clippy on ACP send command --- ui/goose2/src-tauri/src/commands/acp.rs | 8 +- ui/goose2/src-tauri/src/services/acp.rs | 4 +- ui/goose2/src/app/AppShell.tsx | 29 +++--- ui/goose2/src/features/chat/hooks/useChat.ts | 12 ++- ui/goose2/src/features/chat/ui/ChatInput.tsx | 2 +- .../src/features/chat/ui/ChatInputToolbar.tsx | 13 ++- ui/goose2/src/features/chat/ui/ChatView.tsx | 99 +++++++++++++++++-- .../chat/ui/__tests__/ChatInput.test.tsx | 17 ++++ .../projects/lib/chatProjectContext.test.ts | 61 ++++++++++++ .../projects/lib/chatProjectContext.ts | 88 +++++++++++++++++ ui/goose2/src/shared/api/acp.ts | 2 + 11 files changed, 301 insertions(+), 34 deletions(-) create mode 100644 ui/goose2/src/features/projects/lib/chatProjectContext.test.ts create mode 100644 ui/goose2/src/features/projects/lib/chatProjectContext.ts diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index f96b1b8bc1..4ca149c814 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -30,6 +30,7 @@ pub async fn discover_acp_providers() -> Vec { /// /// 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, + working_dir: Option, ) -> 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, diff --git a/ui/goose2/src-tauri/src/services/acp.rs b/ui/goose2/src-tauri/src/services/acp.rs index 3b95704bad..eca546f850 100644 --- a/ui/goose2/src-tauri/src/services/acp.rs +++ b/ui/goose2/src-tauri/src/services/acp.rs @@ -454,8 +454,8 @@ impl AcpService { let store: Arc = 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!("\n{sp}\n\n\n{prompt}") diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index fbc21572f2..1bb38886b4 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -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); }} /> diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 4923f3c4c7..b37404e2a2 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -16,6 +16,7 @@ export function useChat( providerOverride?: string, systemPromptOverride?: string, personaInfo?: { id: string; name: string }, + workingDirOverride?: string, ) { const store = useChatStore(); const abortRef = useRef(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, ], ); diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index 063507d7db..085cf5d35e 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -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; diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 6be778c442..e06ea91c76 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -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 (
@@ -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} > {!isCompact && {folderLabel}} @@ -195,7 +197,14 @@ export function ChatInputToolbar({ onSelect={() => onFolderChange?.(f.id)} className="flex items-center justify-between" > - {f.name} +
+ {f.name} + {f.path && ( + + {f.path} + + )} +
{f.id === folder && ( )} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index eb79cface6..97832b5670 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -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([]); - const [selectedProvider, setSelectedProvider] = useState( - initialProvider ?? "goose", - ); // Persona state const personas = useAgentStore((s) => s.personas); const [selectedPersonaId, setSelectedPersonaId] = useState( 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( + 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] : []} />
); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 4d996f259c..3a261b32c9 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -72,6 +72,23 @@ describe("ChatInput", () => { expect(screen.getByText("Goose")).toBeInTheDocument(); }); + it("shows the selected folder name in the toolbar", () => { + render( + , + ); + expect(screen.getByText("goose2")).toBeInTheDocument(); + }); + it("shows stop button when streaming", () => { render(); expect( diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts new file mode 100644 index 0000000000..d26f391a21 --- /dev/null +++ b/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts @@ -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(""); + 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(""); + 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", + }); + }); +}); diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.ts new file mode 100644 index 0000000000..3c7a697c9d --- /dev/null +++ b/ui/goose2/src/features/projects/lib/chatProjectContext.ts @@ -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 | 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 = [ + `\n${settings.join("\n")}\n`, + ]; + + if (prompt) { + sections.push(`\n${prompt}\n`); + } + + return sections.join("\n\n"); +} + +export function composeSystemPrompt( + ...parts: Array +): 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; +} diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 08fd8dc54c..70e6727676 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -16,12 +16,14 @@ export async function acpSendMessage( providerId: string, prompt: string, systemPrompt?: string, + workingDir?: string, ): Promise { return invoke("acp_send_message", { sessionId, providerId, prompt, systemPrompt: systemPrompt ?? null, + workingDir: workingDir ?? null, }); }