Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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;
112 changes: 112 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,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 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