[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:
Wes
2026-03-31 15:22:56 -07:00
committed by GitHub
parent ef9aaf6573
commit f63fa19aba
11 changed files with 301 additions and 34 deletions
+7 -1
View File
@@ -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,
+2 -2
View File
@@ -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}")
+11 -18
View File
@@ -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);
}}
/>
+10 -2
View File
@@ -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,
],
);
+1 -1
View File
@@ -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" />
)}
+91 -8
View File
@@ -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;
}
+2
View File
@@ -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,
});
}