goose2 session working dir (#8624)
This commit is contained in:
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./agents";
|
||||
export * from "./acp";
|
||||
export * from "./git";
|
||||
export * from "./pathResolver";
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
export class GooseClient {
|
||||
closed = Promise.resolve();
|
||||
|
||||
constructor(..._args: unknown[]) {}
|
||||
|
||||
async initialize(..._args: unknown[]): Promise<void> {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user