goose2 session working dir (#8624)

This commit is contained in:
Lifei Zhou
2026-04-18 04:50:37 +10:00
committed by GitHub
parent d52cde3fb9
commit 2533765359
24 changed files with 448 additions and 257 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ const EXCEPTIONS = {
"Drag-and-drop handlers for session-to-project moves and project reorder, plus activeProjectId highlight.",
},
"src/features/chat/ui/ChatView.tsx": {
limit: 535,
limit: 560,
justification:
"ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here.",
},
+1
View File
@@ -7,6 +7,7 @@ pub mod extensions;
pub mod git;
pub mod git_changes;
pub mod model_setup;
pub mod path_resolver;
pub mod projects;
pub mod skills;
pub mod system;
@@ -0,0 +1,112 @@
use std::path::PathBuf;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvePathRequest {
pub parts: Vec<String>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvePathResponse {
pub path: String,
}
fn trim_part(part: &str) -> Option<&str> {
let trimmed = part.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn expand_home_prefix(part: &str) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match part {
"~" => Some(home),
_ => part
.strip_prefix("~/")
.or_else(|| part.strip_prefix("~\\"))
.map(|relative| home.join(relative)),
}
}
fn resolve_path_parts(parts: Vec<String>) -> Result<String, String> {
let mut normalized_parts = parts.iter().filter_map(|part| trim_part(part)).peekable();
let first = normalized_parts
.next()
.ok_or_else(|| "Path parts must include at least one non-empty segment".to_string())?;
let mut path = expand_home_prefix(first).unwrap_or_else(|| PathBuf::from(first));
for part in normalized_parts {
path.push(part);
}
Ok(path.to_string_lossy().into_owned())
}
#[tauri::command]
pub fn resolve_path(request: ResolvePathRequest) -> Result<ResolvePathResponse, String> {
Ok(ResolvePathResponse {
path: resolve_path_parts(request.parts)?,
})
}
#[cfg(test)]
mod tests {
use super::resolve_path_parts;
#[test]
fn joins_absolute_path_and_subpath() {
assert_eq!(
resolve_path_parts(vec!["/tmp/project".to_string(), "artifacts".to_string()]),
Ok("/tmp/project/artifacts".to_string())
);
}
#[test]
fn ignores_empty_parts() {
assert_eq!(
resolve_path_parts(vec![" ".to_string(), "/tmp/project".to_string()]),
Ok("/tmp/project".to_string())
);
}
#[test]
fn expands_home_segments() {
let Some(home) = dirs::home_dir() else {
return;
};
assert_eq!(
resolve_path_parts(vec![
"~".to_string(),
".goose".to_string(),
"artifacts".to_string()
]),
Ok(home
.join(".goose")
.join("artifacts")
.to_string_lossy()
.into_owned())
);
assert_eq!(
resolve_path_parts(vec!["~/artifacts".to_string()]),
Ok(home.join("artifacts").to_string_lossy().into_owned())
);
assert_eq!(
resolve_path_parts(vec!["~\\artifacts".to_string()]),
Ok(home.join("artifacts").to_string_lossy().into_owned())
);
}
#[test]
fn errors_when_no_non_empty_parts_exist() {
assert_eq!(
resolve_path_parts(vec![" ".to_string(), "".to_string()]),
Err("Path parts must include at least one non-empty segment".to_string())
);
}
}
+1
View File
@@ -84,6 +84,7 @@ pub fn run() {
commands::agent_setup::check_agent_auth,
commands::agent_setup::install_agent,
commands::agent_setup::authenticate_agent,
commands::path_resolver::resolve_path,
commands::system::get_home_dir,
commands::system::save_exported_session_file,
commands::system::path_exists,
+5 -14
View File
@@ -21,8 +21,7 @@ import {
clearReplayBuffer,
getAndDeleteReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { getHomeDir } from "@/shared/api/system";
import { resolveEffectiveWorkingDir } from "@/features/projects/lib/chatProjectContext";
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
export type AppView =
| "home"
@@ -93,11 +92,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
.projects.find((candidate) => candidate.id === session.projectId) ??
null)
: null;
const workingDir =
resolveEffectiveWorkingDir(project) ??
(!project
? resolveEffectiveWorkingDir(null, await getHomeDir())
: undefined);
const workingDir = await resolveSessionCwd(project);
await acpLoadSession(sessionId, gooseSessionId, workingDir);
useChatStore.getState().setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
@@ -315,19 +310,15 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
: (useProjectStore
.getState()
.projects.find((project) => project.id === projectId) ?? null);
const nextWorkingDir =
resolveEffectiveWorkingDir(nextProject) ??
(nextProject == null
? resolveEffectiveWorkingDir(null, await getHomeDir())
: undefined);
if (!nextWorkingDir) {
const workingDir = await resolveSessionCwd(nextProject);
if (!workingDir) {
return;
}
await acpPrepareSession(
sessionId,
session.providerId ?? agentStore.selectedProvider ?? "goose",
workingDir,
{
workingDir: nextWorkingDir,
personaId: session.personaId,
},
);
@@ -32,7 +32,7 @@ describe("useChat attachments", () => {
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: {},
});
@@ -65,7 +65,7 @@ describe("useChat", () => {
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: {},
});
@@ -353,16 +353,22 @@ describe("useChat", () => {
],
});
const { result } = renderHook(() => useChat("session-1", "openai"));
const { result } = renderHook(() =>
useChat("session-1", "openai", undefined, undefined, async () => "/tmp"),
);
await act(async () => {
await result.current.sendMessage("Hello");
});
expect(mockAcpPrepareSession).toHaveBeenCalledWith("session-1", "openai", {
workingDir: undefined,
personaId: undefined,
});
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
"session-1",
"openai",
"/tmp",
{
personaId: undefined,
},
);
expect(mockAcpSetModel).toHaveBeenCalledWith("session-1", "gpt-4.1");
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
systemPrompt: undefined,
+7 -4
View File
@@ -83,7 +83,7 @@ export function useChat(
providerOverride?: string,
systemPromptOverride?: string,
personaInfo?: { id: string; name: string },
workingDirOverride?: string,
getWorkingDir?: () => Promise<string | undefined>,
) {
const store = useChatStore();
const abortRef = useRef<AbortController | null>(null);
@@ -218,8 +218,11 @@ export function useChat(
try {
if (wasDraft || selectedModelId) {
await acpPrepareSession(sessionId, providerId, {
workingDir: workingDirOverride,
const workingDir = await getWorkingDir?.();
if (!workingDir) {
throw new Error("Missing session working directory");
}
await acpPrepareSession(sessionId, providerId, workingDir, {
personaId: effectivePersonaInfo?.id,
});
if (selectedModelId) {
@@ -299,7 +302,7 @@ export function useChat(
providerOverride,
systemPromptOverride,
resolvePersonaInfo,
workingDirOverride,
getWorkingDir,
],
);
@@ -20,7 +20,7 @@ function resetStore() {
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: {},
});
@@ -37,7 +37,7 @@ export interface ChatSession {
userSetName?: boolean;
}
export interface WorkingContext {
export interface ActiveWorkspace {
path: string;
branch: string | null;
}
@@ -47,7 +47,7 @@ interface ChatSessionStoreState {
activeSessionId: string | null;
isLoading: boolean;
contextPanelOpenBySession: Record<string, boolean>;
activeWorkingContextBySession: Record<string, WorkingContext>;
activeWorkspaceBySession: Record<string, ActiveWorkspace>;
modelsBySession: Record<string, ModelOption[]>;
modelCacheByProvider: Record<string, ModelOption[]>;
}
@@ -83,8 +83,8 @@ interface ChatSessionStoreActions {
setActiveSession: (sessionId: string | null) => void;
setContextPanelOpen: (sessionId: string, open: boolean) => void;
setActiveWorkingContext: (sessionId: string, context: WorkingContext) => void;
clearActiveWorkingContext: (sessionId: string) => void;
setActiveWorkspace: (sessionId: string, context: ActiveWorkspace) => void;
clearActiveWorkspace: (sessionId: string) => void;
setSessionModels: (sessionId: string, models: ModelOption[]) => void;
switchSessionProvider: (
sessionId: string,
@@ -300,7 +300,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: loadModelCache(),
@@ -346,7 +346,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
const { [id]: _ignoredPanelState, ...remainingPanelState } =
get().contextPanelOpenBySession;
const { [id]: _ignoredContext, ...remainingContextState } =
get().activeWorkingContextBySession;
get().activeWorkspaceBySession;
const remainingModels = { ...get().modelsBySession };
delete remainingModels[id];
set((state) => ({
@@ -354,7 +354,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
activeSessionId:
state.activeSessionId === id ? null : state.activeSessionId,
contextPanelOpenBySession: remainingPanelState,
activeWorkingContextBySession: remainingContextState,
activeWorkspaceBySession: remainingContextState,
modelsBySession: remainingModels,
}));
removeDraftSessionRecord(id);
@@ -544,19 +544,19 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
}));
},
setActiveWorkingContext: (sessionId, context) => {
setActiveWorkspace: (sessionId, context) => {
set((state) => ({
activeWorkingContextBySession: {
...state.activeWorkingContextBySession,
activeWorkspaceBySession: {
...state.activeWorkspaceBySession,
[sessionId]: context,
},
}));
},
clearActiveWorkingContext: (sessionId) => {
clearActiveWorkspace: (sessionId) => {
set((state) => {
const { [sessionId]: _, ...rest } = state.activeWorkingContextBySession;
return { activeWorkingContextBySession: rest };
const { [sessionId]: _, ...rest } = state.activeWorkspaceBySession;
return { activeWorkspaceBySession: rest };
});
},
+70 -50
View File
@@ -17,11 +17,11 @@ import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import {
buildProjectSystemPrompt,
composeSystemPrompt,
defaultArtifactsDir,
defaultGlobalArtifactRoot,
getProjectArtifactRoots,
resolveProjectWorkingDir,
resolveProjectDefaultArtifactRoot,
} from "@/features/projects/lib/chatProjectContext";
import { getHomeDir } from "@/shared/api/system";
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
import type { ModelOption } from "../types";
import { ChatContextPanel } from "./ChatContextPanel";
@@ -55,11 +55,11 @@ export function ChatView({
(s) => s.contextPanelOpenBySession[activeSessionId] ?? false,
);
const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen);
const activeWorkingContext = useChatSessionStore(
(s) => s.activeWorkingContextBySession[activeSessionId],
const activeWorkspace = useChatSessionStore(
(s) => s.activeWorkspaceBySession[activeSessionId],
);
const clearActiveWorkingContext = useChatSessionStore(
(s) => s.clearActiveWorkingContext,
const clearActiveWorkspace = useChatSessionStore(
(s) => s.clearActiveWorkspace,
);
const {
@@ -85,7 +85,7 @@ export function ChatView({
? s.projects.find((candidate) => candidate.id === session.projectId)
: undefined,
);
const [homeArtifactsRoot, setHomeArtifactsRoot] = useState<string | null>(
const [globalArtifactRoot, setGlobalArtifactRoot] = useState<string | null>(
null,
);
const project = storedProject ?? null;
@@ -115,36 +115,30 @@ export function ChatView({
() => getProjectArtifactRoots(project),
[project],
);
const resolvedProjectWorkingDir = useMemo(
() => resolveProjectWorkingDir(project),
const projectDefaultArtifactRoot = useMemo(
() => resolveProjectDefaultArtifactRoot(project),
[project],
);
const projectMetadataPending = Boolean(
session?.projectId && !resolvedProjectWorkingDir && projectsLoading,
session?.projectId && !projectDefaultArtifactRoot && projectsLoading,
);
const defaultWorkingDir = resolvedProjectWorkingDir
? resolvedProjectWorkingDir
: !session?.projectId
? (homeArtifactsRoot ?? undefined)
: undefined;
const effectiveWorkingDir = activeWorkingContext?.path ?? defaultWorkingDir;
const allowedArtifactRoots = useMemo(() => {
const roots = [
...projectArtifactRoots.map((path) => path.trim()).filter(Boolean),
];
if (homeArtifactsRoot) {
roots.push(homeArtifactsRoot);
if (globalArtifactRoot) {
roots.push(globalArtifactRoot);
}
return [...new Set(roots)];
}, [homeArtifactsRoot, projectArtifactRoots]);
}, [globalArtifactRoot, projectArtifactRoots]);
const projectSystemPrompt = useMemo(
() => buildProjectSystemPrompt(project),
[project],
);
const workingContextPrompt = useMemo(() => {
if (!activeWorkingContext?.branch) return undefined;
return `<active-working-context>\nActive branch: ${activeWorkingContext.branch}\nWorking directory: ${activeWorkingContext.path}\n</active-working-context>`;
}, [activeWorkingContext?.branch, activeWorkingContext?.path]);
if (!activeWorkspace?.branch) return undefined;
return `<active-working-context>\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n</active-working-context>`;
}, [activeWorkspace?.branch, activeWorkspace?.path]);
const effectiveSystemPrompt = useMemo(
() =>
@@ -158,14 +152,14 @@ export function ChatView({
useEffect(() => {
let cancelled = false;
getHomeDir()
.then((homeDir) => {
defaultGlobalArtifactRoot()
.then((artifactRoot) => {
if (cancelled) return;
setHomeArtifactsRoot(defaultArtifactsDir(homeDir));
setGlobalArtifactRoot(artifactRoot);
})
.catch(() => {
if (cancelled) return;
setHomeArtifactsRoot(null);
setGlobalArtifactRoot(null);
});
return () => {
cancelled = true;
@@ -177,32 +171,41 @@ export function ChatView({
const prevProjectId = prevProjectIdRef.current;
prevProjectIdRef.current = session?.projectId;
if (prevProjectId !== undefined && prevProjectId !== session?.projectId) {
clearActiveWorkingContext(activeSessionId);
clearActiveWorkspace(activeSessionId);
}
}, [session?.projectId, activeSessionId, clearActiveWorkingContext]);
}, [session?.projectId, activeSessionId, clearActiveWorkspace]);
const prevContextRef = useRef(activeWorkingContext);
const prevWorkspaceRef = useRef(activeWorkspace);
useEffect(() => {
const prev = prevContextRef.current;
const prev = prevWorkspaceRef.current;
if (
!activeWorkingContext ||
!activeWorkspace ||
!selectedProvider ||
session?.draft ||
activeWorkingContext === prev
activeWorkspace === prev
) {
return;
}
prevContextRef.current = activeWorkingContext;
if (prev && prev.path === activeWorkingContext.path) return;
void acpPrepareSession(activeSessionId, selectedProvider, {
workingDir: activeWorkingContext.path,
personaId: selectedPersonaId ?? undefined,
}).catch((error) => {
prevWorkspaceRef.current = activeWorkspace;
if (prev && prev.path === activeWorkspace.path) return;
async function prepareWorkspaceSession() {
const workingDir = await resolveSessionCwd(project, activeWorkspace.path);
if (!workingDir) {
return;
}
await acpPrepareSession(activeSessionId, selectedProvider, workingDir, {
personaId: selectedPersonaId ?? undefined,
});
}
void prepareWorkspaceSession().catch((error) => {
console.error("Failed to prepare ACP session:", error);
});
}, [
activeWorkingContext,
activeWorkspace,
activeSessionId,
project,
selectedProvider,
selectedPersonaId,
session?.draft,
@@ -230,19 +233,32 @@ export function ChatView({
.getState()
.projects.find((candidate) => candidate.id === projectId) ??
null);
const nextWorkingDir =
resolveProjectWorkingDir(nextProject) ??
(projectId == null ? (homeArtifactsRoot ?? undefined) : undefined);
useChatSessionStore
.getState()
.updateSession(activeSessionId, { projectId });
if (!session?.draft && selectedProvider && nextWorkingDir) {
void acpPrepareSession(activeSessionId, selectedProvider, {
workingDir: nextWorkingDir,
personaId: selectedPersonaId ?? undefined,
}).catch((error) => {
if (!session?.draft && selectedProvider) {
async function updateProjectSessionCwd() {
const workingDir = await resolveSessionCwd(
nextProject,
activeWorkspace?.path,
);
if (!workingDir) {
return;
}
await acpPrepareSession(
activeSessionId,
selectedProvider,
workingDir,
{
personaId: selectedPersonaId ?? undefined,
},
);
}
void updateProjectSessionCwd().catch((error) => {
console.error(
"Failed to update ACP session working directory:",
error,
@@ -252,7 +268,7 @@ export function ChatView({
},
[
activeSessionId,
homeArtifactsRoot,
activeWorkspace?.path,
selectedPersonaId,
selectedProvider,
session?.draft,
@@ -331,6 +347,10 @@ export function ChatView({
const personaInfo = selectedPersona
? { id: selectedPersona.id, name: selectedPersona.displayName }
: undefined;
const resolveCurrentSessionCwd = useCallback(
() => resolveSessionCwd(project, activeWorkspace?.path),
[project, activeWorkspace?.path],
);
const {
messages,
chatState,
@@ -343,7 +363,7 @@ export function ChatView({
selectedProvider,
effectiveSystemPrompt,
personaInfo,
effectiveWorkingDir,
resolveCurrentSessionCwd,
);
const isLoadingHistory = useChatStore(
(s) =>
+14 -16
View File
@@ -15,7 +15,7 @@ import {
import type { CreatedWorktree } from "@/shared/types/git";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { useChatSessionStore } from "../stores/chatSessionStore";
import type { WorkingContext } from "../stores/chatSessionStore";
import type { ActiveWorkspace } from "../stores/chatSessionStore";
import { WorkspaceWidget } from "./widgets/WorkspaceWidget";
import { ChangesWidget } from "./widgets/ChangesWidget";
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
@@ -39,35 +39,33 @@ export function ContextPanel({
}: ContextPanelProps) {
const { t } = useTranslation("chat");
const [activeTab, setActiveTab] = useState<ContextPanelTab>("details");
const primaryWorkingDir = projectWorkingDirs[0] ?? null;
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null;
const activeContext = useChatSessionStore(
(s) => s.activeWorkingContextBySession[sessionId],
);
const setActiveWorkingContext = useChatSessionStore(
(s) => s.setActiveWorkingContext,
(s) => s.activeWorkspaceBySession[sessionId],
);
const setActiveWorkspace = useChatSessionStore((s) => s.setActiveWorkspace);
const gitQueryPath = activeContext?.path ?? primaryWorkingDir;
const gitTargetPath = activeContext?.path ?? primaryWorkspaceRoot;
const {
data: gitState,
error,
isLoading,
isFetching,
refetch,
} = useGitState(gitQueryPath, activeTab === "details");
} = useGitState(gitTargetPath, activeTab === "details");
const {
data: changedFiles,
isLoading: isFilesLoading,
refetch: refetchFiles,
} = useChangedFiles(gitQueryPath, activeTab === "details");
} = useChangedFiles(gitTargetPath, activeTab === "details");
const handleContextChange = useCallback(
(context: WorkingContext) => {
setActiveWorkingContext(sessionId, context);
(context: ActiveWorkspace) => {
setActiveWorkspace(sessionId, context);
},
[sessionId, setActiveWorkingContext],
[sessionId, setActiveWorkspace],
);
const refetchAll = useCallback(async () => {
@@ -149,11 +147,11 @@ export function ContextPanel({
const handleOpenChangedFile = useCallback(
(filePath: string) => {
if (!gitQueryPath) return;
const fullPath = `${gitQueryPath}/${filePath}`;
if (!gitTargetPath) return;
const fullPath = `${gitTargetPath}/${filePath}`;
void openPath(fullPath);
},
[gitQueryPath],
[gitTargetPath],
);
const handleRefresh = useCallback(() => {
@@ -202,7 +200,7 @@ export function ContextPanel({
files={changedFiles}
isLoading={isFilesLoading}
currentBranch={gitState?.currentBranch ?? null}
repoPath={gitQueryPath ?? ""}
repoPath={gitTargetPath ?? ""}
onOpenFile={handleOpenChangedFile}
/>
<ArtifactsWidget />
@@ -20,13 +20,13 @@ import {
import { buttonVariants } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import type { GitState } from "@/shared/types/git";
import type { WorkingContext } from "../../stores/chatSessionStore";
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
interface WorkingContextPickerProps {
currentProjectPath: string | null;
gitState: GitState | undefined;
activeContext: WorkingContext | undefined;
onSelect: (context: WorkingContext) => void;
activeContext: ActiveWorkspace | undefined;
onSelect: (context: ActiveWorkspace) => void;
onSwitchBranch: (path: string, branch: string) => Promise<void>;
onStashAndSwitch: (path: string, branch: string) => Promise<void>;
}
@@ -63,7 +63,7 @@ export function WorkingContextPicker({
}: WorkingContextPickerProps) {
const { t } = useTranslation("chat");
const [open, setOpen] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<WorkingContext | null>(
const [pendingSwitch, setPendingSwitch] = useState<ActiveWorkspace | null>(
null,
);
const [switching, setSwitching] = useState(false);
@@ -5,7 +5,7 @@ import type { CreatedWorktree, GitState } from "@/shared/types/git";
import { Button } from "@/shared/ui/button";
import { SplitButton } from "@/shared/ui/split-button";
import { Spinner } from "@/shared/ui/spinner";
import type { WorkingContext } from "../../stores/chatSessionStore";
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
import { formatErrorMessage } from "./formatError";
import {
WorkspaceCreateDialog,
@@ -15,9 +15,9 @@ import {
interface WorkspaceActionsMenuProps {
currentProjectPath: string;
gitState: GitState;
activeContext: WorkingContext | undefined;
activeContext: ActiveWorkspace | undefined;
disabled?: boolean;
onContextChange: (context: WorkingContext) => void;
onContextChange: (context: ActiveWorkspace) => void;
onFetch: (path: string) => Promise<void>;
onPull: (path: string) => Promise<void>;
onCreateBranch: (
@@ -21,7 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import type { WorkingContext } from "../../stores/chatSessionStore";
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
import { formatErrorMessage } from "./formatError";
import { shortenPath } from "./WorkingContextPicker";
@@ -35,7 +35,7 @@ interface WorkspaceCreateDialogProps {
currentPath: string;
activeBranch: string | null;
onClose: () => void;
onContextChange: (context: WorkingContext) => void;
onContextChange: (context: ActiveWorkspace) => void;
onCreateBranch: (
path: string,
name: string,
@@ -3,7 +3,7 @@ import { IconFolder, IconGitBranch, IconRefresh } from "@tabler/icons-react";
import type { CreatedWorktree, GitState } from "@/shared/types/git";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import type { WorkingContext } from "../../stores/chatSessionStore";
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
import { Widget } from "./Widget";
import { WorkspaceActionsMenu } from "./WorkspaceActionsMenu";
import { WorkingContextPicker, shortenPath } from "./WorkingContextPicker";
@@ -16,8 +16,8 @@ interface WorkspaceWidgetProps {
isLoading: boolean;
isFetching: boolean;
error: Error | null;
activeContext: WorkingContext | undefined;
onContextChange: (context: WorkingContext) => void;
activeContext: ActiveWorkspace | undefined;
onContextChange: (context: ActiveWorkspace) => void;
onSwitchBranch: (path: string, branch: string) => Promise<void>;
onStashAndSwitch: (path: string, branch: string) => Promise<void>;
onInitRepo: (path: string) => Promise<void>;
@@ -58,7 +58,7 @@ export function WorkspaceWidget({
onRefresh,
}: WorkspaceWidgetProps) {
const { t } = useTranslation("chat");
const primaryWorkingDir = projectWorkingDirs[0] ?? null;
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null;
const gitErrorMessage =
error instanceof Error ? error.message : t("contextPanel.errors.gitRead");
@@ -73,7 +73,7 @@ export function WorkspaceWidget({
variant="ghost"
size="icon-xs"
onClick={onRefresh}
disabled={!primaryWorkingDir || isFetching}
disabled={!primaryWorkspaceRoot || isFetching}
className="rounded-md"
aria-label={t("contextPanel.actions.refreshGitStatus")}
title={t("contextPanel.actions.refreshGitStatus")}
@@ -103,7 +103,7 @@ export function WorkspaceWidget({
</p>
)}
{!primaryWorkingDir ? (
{!primaryWorkspaceRoot ? (
<p className="truncate">{t("contextPanel.empty.folderNotSet")}</p>
) : isLoading && !gitState ? (
<div className="flex items-center gap-2 text-foreground">
@@ -115,7 +115,7 @@ export function WorkspaceWidget({
) : gitState?.isGitRepo ? (
<div className="space-y-2">
<WorkingContextPicker
currentProjectPath={primaryWorkingDir}
currentProjectPath={primaryWorkspaceRoot}
gitState={gitState}
activeContext={activeContext}
onSelect={onContextChange}
@@ -123,7 +123,7 @@ export function WorkspaceWidget({
onStashAndSwitch={onStashAndSwitch}
/>
<WorkspaceActionsMenu
currentProjectPath={primaryWorkingDir}
currentProjectPath={primaryWorkspaceRoot}
gitState={gitState}
activeContext={activeContext}
disabled={isFetching}
@@ -137,13 +137,13 @@ export function WorkspaceWidget({
) : (
<div className="space-y-3">
<p className="truncate text-foreground-subtle">
{shortenPath(primaryWorkingDir)}
{shortenPath(primaryWorkspaceRoot)}
</p>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void onInitRepo(primaryWorkingDir)}
onClick={() => void onInitRepo(primaryWorkspaceRoot)}
>
<IconGitBranch className="size-3" />
{t("contextPanel.git.initRepo")}
@@ -2,12 +2,9 @@ import { describe, expect, it } from "vitest";
import {
buildProjectSystemPrompt,
composeSystemPrompt,
defaultArtifactsDir,
getProjectArtifactRoots,
getProjectFolderName,
getProjectFolderOption,
resolveEffectiveWorkingDir,
resolveProjectWorkingDir,
} from "./chatProjectContext";
describe("chatProjectContext", () => {
@@ -111,84 +108,4 @@ describe("chatProjectContext", () => {
"/Users/wesb/dev/other/artifacts",
]);
});
it("resolves the first working directory to an artifacts subfolder", () => {
expect(
resolveProjectWorkingDir({
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
}),
).toBe("/Users/wesb/dev/goose2/artifacts");
});
it("falls back to the project artifacts dir when no working dirs exist", () => {
expect(
resolveProjectWorkingDir({
workingDirs: [],
artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts",
}),
).toBe("/Users/wesb/.goose/projects/sample-project/artifacts");
});
describe("defaultArtifactsDir", () => {
it("normalises path separators and appends .goose/artifacts", () => {
expect(defaultArtifactsDir("/Users/wesb")).toBe(
"/Users/wesb/.goose/artifacts",
);
});
it("normalises backslashes on Windows-style paths", () => {
expect(defaultArtifactsDir("C:\\Users\\wesb\\")).toBe(
"C:/Users/wesb/.goose/artifacts",
);
});
it("strips trailing slashes", () => {
expect(defaultArtifactsDir("/Users/wesb/")).toBe(
"/Users/wesb/.goose/artifacts",
);
});
});
describe("resolveEffectiveWorkingDir", () => {
it("returns the project working dir without requiring homeDir", () => {
expect(
resolveEffectiveWorkingDir({
workingDirs: ["/Users/wesb/dev/goose2"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
}),
).toBe("/Users/wesb/dev/goose2/artifacts");
});
it("returns the project working dir when available", () => {
expect(
resolveEffectiveWorkingDir(
{
workingDirs: ["/Users/wesb/dev/goose2"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
},
"/Users/wesb",
),
).toBe("/Users/wesb/dev/goose2/artifacts");
});
it("returns undefined when a project exists but has no working dirs", () => {
expect(
resolveEffectiveWorkingDir(
{ workingDirs: [], artifactsDir: "" },
"/Users/wesb",
),
).toBeUndefined();
});
it("falls back to home artifacts dir when no project", () => {
expect(resolveEffectiveWorkingDir(null, "/Users/wesb")).toBe(
"/Users/wesb/.goose/artifacts",
);
});
it("does not resolve a non-project fallback without homeDir", () => {
expect(resolveEffectiveWorkingDir(null)).toBeUndefined();
});
});
});
@@ -1,5 +1,5 @@
import type { ProjectInfo } from "../api/projects";
import { resolvePath } from "@/shared/api/pathResolver";
export interface ProjectFolderOption {
id: string;
name: string;
@@ -25,7 +25,7 @@ function appendArtifactsSegment(path: string): string {
return `${path.replace(/[\\/]+$/, "")}/artifacts`;
}
function resolveProjectFolderPaths(
function resolveProjectArtifactRoots(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
): string[] {
const workingDirs = (project?.workingDirs ?? [])
@@ -43,25 +43,37 @@ function resolveProjectFolderPaths(
export function getProjectArtifactRoots(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
): string[] {
return resolveProjectFolderPaths(project);
return resolveProjectArtifactRoots(project);
}
export function resolveProjectDefaultArtifactRoot(
project: ProjectInfo | null | undefined,
): string | undefined {
const workingDirs = (project?.workingDirs ?? [])
.map((directory) => trimValue(directory))
.filter((directory): directory is string => directory !== null);
if (workingDirs.length > 0) {
return appendArtifactsSegment(workingDirs[0]);
}
return trimValue(project?.artifactsDir) ?? undefined;
}
export async function defaultGlobalArtifactRoot(): Promise<string> {
return (await resolvePath({ parts: ["~", ".goose", "artifacts"] })).path;
}
export function getProjectFolderOption(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
): ProjectFolderOption[] {
return resolveProjectFolderPaths(project).map((d) => ({
return resolveProjectArtifactRoots(project).map((d) => ({
id: d,
name: getProjectFolderName(d),
path: d,
}));
}
export function resolveProjectWorkingDir(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
): string | undefined {
return resolveProjectFolderPaths(project)[0];
}
export function buildProjectSystemPrompt(
project: ProjectInfo | null | undefined,
): string | undefined {
@@ -69,7 +81,7 @@ export function buildProjectSystemPrompt(
return undefined;
}
const artifactDir = resolveProjectWorkingDir(project);
const artifactDir = resolveProjectDefaultArtifactRoot(project);
const settings: string[] = [`Project name: ${project.name}`];
const description = trimValue(project.description);
const workingDirs = (project.workingDirs ?? [])
@@ -120,40 +132,6 @@ export function buildProjectSystemPrompt(
return sections.join("\n\n");
}
/**
* Builds the default artifacts directory from a raw home-dir string.
* Normalises path separators and trailing slashes before appending `.goose/artifacts`.
*/
export function defaultArtifactsDir(homeDir: string): string {
const normalizedHome = homeDir.replace(/\\/g, "/").replace(/\/+$/, "");
return `${normalizedHome}/.goose/artifacts`;
}
/**
* Resolves the effective working directory for a session.
* Uses the project's working dir if available, otherwise falls back to
* `~/.goose/artifacts` using the provided home directory.
*
* When a project is provided but has no configured working dirs, returns
* `undefined` so the caller can decide how to handle it.
*/
export function resolveEffectiveWorkingDir(
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
homeDir?: string,
): string | undefined {
const projectDir = resolveProjectWorkingDir(project);
if (projectDir) {
return projectDir;
}
if (project) {
return undefined;
}
if (!homeDir) {
return undefined;
}
return defaultArtifactsDir(homeDir);
}
export function composeSystemPrompt(
...parts: Array<string | null | undefined>
): string | undefined {
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePath } from "@/shared/api/pathResolver";
import type { ProjectInfo } from "../api/projects";
import { resolveSessionCwd } from "./sessionCwdSelection";
import {
defaultGlobalArtifactRoot,
resolveProjectDefaultArtifactRoot,
} from "./chatProjectContext";
vi.mock("@/shared/api/pathResolver", () => ({
resolvePath: vi.fn(),
}));
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "project-1",
name: "Project",
description: "",
prompt: "",
icon: "folder",
color: "#000000",
preferredProvider: null,
preferredModel: null,
workingDirs: [],
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
artifactsDir: "",
...overrides,
};
}
describe("sessionCwdSelection", () => {
beforeEach(() => {
vi.mocked(resolvePath).mockReset();
});
it("resolves the first workspace root to the default artifact root", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"],
artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts",
}),
),
).toBe("/Users/wesb/dev/goose2/artifacts");
});
it("falls back to the stored project artifact root when no workspace roots exist", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: [],
artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts",
}),
),
).toBe("/Users/wesb/.goose/projects/sample-project/artifacts");
});
it("returns undefined for a pathless project artifact root", () => {
expect(
resolveProjectDefaultArtifactRoot(
makeProject({
workingDirs: [],
artifactsDir: " ",
}),
),
).toBeUndefined();
});
it("falls back to global artifacts for a pathless project session cwd", async () => {
vi.mocked(resolvePath).mockResolvedValue({
path: "/Users/wesb/.goose/artifacts",
});
await expect(
resolveSessionCwd(
makeProject({
workingDirs: [],
artifactsDir: " ",
}),
),
).resolves.toBe("/Users/wesb/.goose/artifacts");
expect(resolvePath).toHaveBeenCalledWith({
parts: ["~", ".goose", "artifacts"],
});
});
describe("defaultGlobalArtifactRoot", () => {
it("resolves the global artifact root through the path resolver", async () => {
vi.mocked(resolvePath).mockResolvedValue({
path: "/Users/wesb/.goose/artifacts",
});
await expect(defaultGlobalArtifactRoot()).resolves.toBe(
"/Users/wesb/.goose/artifacts",
);
expect(resolvePath).toHaveBeenCalledWith({
parts: ["~", ".goose", "artifacts"],
});
});
});
});
@@ -0,0 +1,42 @@
import type { ProjectInfo } from "../api/projects";
import { resolvePath } from "@/shared/api/pathResolver";
function trimValue(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function buildSessionCwdParts(
project: ProjectInfo | null | undefined,
activeWorkspacePath?: string | null,
): string[] {
const trimmedWorkspacePath = trimValue(activeWorkspacePath);
if (trimmedWorkspacePath) {
return [trimmedWorkspacePath];
}
const workingDirs = (project?.workingDirs ?? [])
.map((directory) => trimValue(directory))
.filter((directory): directory is string => directory !== null);
if (workingDirs.length > 0) {
return [workingDirs[0], "artifacts"];
}
const artifactRoot = trimValue(project?.artifactsDir);
if (artifactRoot) {
return [artifactRoot];
}
return ["~", ".goose", "artifacts"];
}
export async function resolveSessionCwd(
project: ProjectInfo | null | undefined,
activeWorkspacePath?: string | null,
): Promise<string> {
return (
await resolvePath({
parts: buildSessionCwdParts(project, activeWorkspacePath),
})
).path;
}
+1 -2
View File
@@ -21,7 +21,6 @@ export interface AcpSendMessageOptions {
}
export interface AcpPrepareSessionOptions {
workingDir?: string;
personaId?: string;
}
@@ -67,9 +66,9 @@ export async function acpSendMessage(
export async function acpPrepareSession(
sessionId: string,
providerId: string,
workingDir: string,
options: AcpPrepareSessionOptions = {},
): Promise<void> {
const workingDir = options.workingDir ?? "~/.goose/artifacts";
await sessionTracker.prepareSession(
sessionId,
providerId,
+1
View File
@@ -1,3 +1,4 @@
export * from "./agents";
export * from "./acp";
export * from "./git";
export * from "./pathResolver";
+17
View File
@@ -0,0 +1,17 @@
import { invoke } from "@tauri-apps/api/core";
export interface ResolvePathParams {
parts: string[];
}
export interface ResolvedPath {
path: string;
}
export async function resolvePath({
parts,
}: ResolvePathParams): Promise<ResolvedPath> {
return invoke("resolve_path", {
request: { parts },
});
}
-2
View File
@@ -1,7 +1,5 @@
export class GooseClient {
closed = Promise.resolve();
constructor(..._args: unknown[]) {}
async initialize(..._args: unknown[]): Promise<void> {}
}