Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/goose2/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
Expand Down
1 change: 1 addition & 0 deletions ui/goose2/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
107 changes: 107 additions & 0 deletions ui/goose2/src-tauri/src/commands/path_resolver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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 change: 1 addition & 0 deletions ui/goose2/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 5 additions & 14 deletions ui/goose2/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("useChat attachments", () => {
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: {},
});
Expand Down
18 changes: 12 additions & 6 deletions ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("useChat", () => {
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: {},
});
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions ui/goose2/src/features/chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -299,7 +302,7 @@ export function useChat(
providerOverride,
systemPromptOverride,
resolvePersonaInfo,
workingDirOverride,
getWorkingDir,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function resetStore() {
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: {},
});
Expand Down
26 changes: 13 additions & 13 deletions ui/goose2/src/features/chat/stores/chatSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface ChatSession {
userSetName?: boolean;
}

export interface WorkingContext {
export interface ActiveWorkspace {
path: string;
branch: string | null;
}
Expand All @@ -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[]>;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -300,7 +300,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
activeSessionId: null,
isLoading: false,
contextPanelOpenBySession: {},
activeWorkingContextBySession: {},
activeWorkspaceBySession: {},
modelsBySession: {},
modelCacheByProvider: loadModelCache(),

Expand Down Expand Up @@ -346,15 +346,15 @@ 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) => ({
sessions: state.sessions.filter((candidate) => candidate.id !== id),
activeSessionId:
state.activeSessionId === id ? null : state.activeSessionId,
contextPanelOpenBySession: remainingPanelState,
activeWorkingContextBySession: remainingContextState,
activeWorkspaceBySession: remainingContextState,
modelsBySession: remainingModels,
}));
removeDraftSessionRecord(id);
Expand Down Expand Up @@ -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 };
});
},

Expand Down
Loading
Loading