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.",
|
"Drag-and-drop handlers for session-to-project moves and project reorder, plus activeProjectId highlight.",
|
||||||
},
|
},
|
||||||
"src/features/chat/ui/ChatView.tsx": {
|
"src/features/chat/ui/ChatView.tsx": {
|
||||||
limit: 535,
|
limit: 560,
|
||||||
justification:
|
justification:
|
||||||
"ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here.",
|
"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;
|
||||||
pub mod git_changes;
|
pub mod git_changes;
|
||||||
pub mod model_setup;
|
pub mod model_setup;
|
||||||
|
pub mod path_resolver;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod system;
|
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::check_agent_auth,
|
||||||
commands::agent_setup::install_agent,
|
commands::agent_setup::install_agent,
|
||||||
commands::agent_setup::authenticate_agent,
|
commands::agent_setup::authenticate_agent,
|
||||||
|
commands::path_resolver::resolve_path,
|
||||||
commands::system::get_home_dir,
|
commands::system::get_home_dir,
|
||||||
commands::system::save_exported_session_file,
|
commands::system::save_exported_session_file,
|
||||||
commands::system::path_exists,
|
commands::system::path_exists,
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {
|
|||||||
clearReplayBuffer,
|
clearReplayBuffer,
|
||||||
getAndDeleteReplayBuffer,
|
getAndDeleteReplayBuffer,
|
||||||
} from "@/features/chat/hooks/replayBuffer";
|
} from "@/features/chat/hooks/replayBuffer";
|
||||||
import { getHomeDir } from "@/shared/api/system";
|
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
|
||||||
import { resolveEffectiveWorkingDir } from "@/features/projects/lib/chatProjectContext";
|
|
||||||
|
|
||||||
export type AppView =
|
export type AppView =
|
||||||
| "home"
|
| "home"
|
||||||
@@ -93,11 +92,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
.projects.find((candidate) => candidate.id === session.projectId) ??
|
.projects.find((candidate) => candidate.id === session.projectId) ??
|
||||||
null)
|
null)
|
||||||
: null;
|
: null;
|
||||||
const workingDir =
|
const workingDir = await resolveSessionCwd(project);
|
||||||
resolveEffectiveWorkingDir(project) ??
|
|
||||||
(!project
|
|
||||||
? resolveEffectiveWorkingDir(null, await getHomeDir())
|
|
||||||
: undefined);
|
|
||||||
await acpLoadSession(sessionId, gooseSessionId, workingDir);
|
await acpLoadSession(sessionId, gooseSessionId, workingDir);
|
||||||
useChatStore.getState().setSessionLoading(sessionId, false);
|
useChatStore.getState().setSessionLoading(sessionId, false);
|
||||||
const buffer = getAndDeleteReplayBuffer(sessionId);
|
const buffer = getAndDeleteReplayBuffer(sessionId);
|
||||||
@@ -315,19 +310,15 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
: (useProjectStore
|
: (useProjectStore
|
||||||
.getState()
|
.getState()
|
||||||
.projects.find((project) => project.id === projectId) ?? null);
|
.projects.find((project) => project.id === projectId) ?? null);
|
||||||
const nextWorkingDir =
|
const workingDir = await resolveSessionCwd(nextProject);
|
||||||
resolveEffectiveWorkingDir(nextProject) ??
|
if (!workingDir) {
|
||||||
(nextProject == null
|
|
||||||
? resolveEffectiveWorkingDir(null, await getHomeDir())
|
|
||||||
: undefined);
|
|
||||||
if (!nextWorkingDir) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await acpPrepareSession(
|
await acpPrepareSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
session.providerId ?? agentStore.selectedProvider ?? "goose",
|
session.providerId ?? agentStore.selectedProvider ?? "goose",
|
||||||
|
workingDir,
|
||||||
{
|
{
|
||||||
workingDir: nextWorkingDir,
|
|
||||||
personaId: session.personaId,
|
personaId: session.personaId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("useChat attachments", () => {
|
|||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
contextPanelOpenBySession: {},
|
contextPanelOpenBySession: {},
|
||||||
activeWorkingContextBySession: {},
|
activeWorkspaceBySession: {},
|
||||||
modelsBySession: {},
|
modelsBySession: {},
|
||||||
modelCacheByProvider: {},
|
modelCacheByProvider: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe("useChat", () => {
|
|||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
contextPanelOpenBySession: {},
|
contextPanelOpenBySession: {},
|
||||||
activeWorkingContextBySession: {},
|
activeWorkspaceBySession: {},
|
||||||
modelsBySession: {},
|
modelsBySession: {},
|
||||||
modelCacheByProvider: {},
|
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 act(async () => {
|
||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAcpPrepareSession).toHaveBeenCalledWith("session-1", "openai", {
|
expect(mockAcpPrepareSession).toHaveBeenCalledWith(
|
||||||
workingDir: undefined,
|
"session-1",
|
||||||
personaId: undefined,
|
"openai",
|
||||||
});
|
"/tmp",
|
||||||
|
{
|
||||||
|
personaId: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(mockAcpSetModel).toHaveBeenCalledWith("session-1", "gpt-4.1");
|
expect(mockAcpSetModel).toHaveBeenCalledWith("session-1", "gpt-4.1");
|
||||||
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
|
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
|
||||||
systemPrompt: undefined,
|
systemPrompt: undefined,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function useChat(
|
|||||||
providerOverride?: string,
|
providerOverride?: string,
|
||||||
systemPromptOverride?: string,
|
systemPromptOverride?: string,
|
||||||
personaInfo?: { id: string; name: string },
|
personaInfo?: { id: string; name: string },
|
||||||
workingDirOverride?: string,
|
getWorkingDir?: () => Promise<string | undefined>,
|
||||||
) {
|
) {
|
||||||
const store = useChatStore();
|
const store = useChatStore();
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@@ -218,8 +218,11 @@ export function useChat(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (wasDraft || selectedModelId) {
|
if (wasDraft || selectedModelId) {
|
||||||
await acpPrepareSession(sessionId, providerId, {
|
const workingDir = await getWorkingDir?.();
|
||||||
workingDir: workingDirOverride,
|
if (!workingDir) {
|
||||||
|
throw new Error("Missing session working directory");
|
||||||
|
}
|
||||||
|
await acpPrepareSession(sessionId, providerId, workingDir, {
|
||||||
personaId: effectivePersonaInfo?.id,
|
personaId: effectivePersonaInfo?.id,
|
||||||
});
|
});
|
||||||
if (selectedModelId) {
|
if (selectedModelId) {
|
||||||
@@ -299,7 +302,7 @@ export function useChat(
|
|||||||
providerOverride,
|
providerOverride,
|
||||||
systemPromptOverride,
|
systemPromptOverride,
|
||||||
resolvePersonaInfo,
|
resolvePersonaInfo,
|
||||||
workingDirOverride,
|
getWorkingDir,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function resetStore() {
|
|||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
contextPanelOpenBySession: {},
|
contextPanelOpenBySession: {},
|
||||||
activeWorkingContextBySession: {},
|
activeWorkspaceBySession: {},
|
||||||
modelsBySession: {},
|
modelsBySession: {},
|
||||||
modelCacheByProvider: {},
|
modelCacheByProvider: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface ChatSession {
|
|||||||
userSetName?: boolean;
|
userSetName?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkingContext {
|
export interface ActiveWorkspace {
|
||||||
path: string;
|
path: string;
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ interface ChatSessionStoreState {
|
|||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
contextPanelOpenBySession: Record<string, boolean>;
|
contextPanelOpenBySession: Record<string, boolean>;
|
||||||
activeWorkingContextBySession: Record<string, WorkingContext>;
|
activeWorkspaceBySession: Record<string, ActiveWorkspace>;
|
||||||
modelsBySession: Record<string, ModelOption[]>;
|
modelsBySession: Record<string, ModelOption[]>;
|
||||||
modelCacheByProvider: Record<string, ModelOption[]>;
|
modelCacheByProvider: Record<string, ModelOption[]>;
|
||||||
}
|
}
|
||||||
@@ -83,8 +83,8 @@ interface ChatSessionStoreActions {
|
|||||||
|
|
||||||
setActiveSession: (sessionId: string | null) => void;
|
setActiveSession: (sessionId: string | null) => void;
|
||||||
setContextPanelOpen: (sessionId: string, open: boolean) => void;
|
setContextPanelOpen: (sessionId: string, open: boolean) => void;
|
||||||
setActiveWorkingContext: (sessionId: string, context: WorkingContext) => void;
|
setActiveWorkspace: (sessionId: string, context: ActiveWorkspace) => void;
|
||||||
clearActiveWorkingContext: (sessionId: string) => void;
|
clearActiveWorkspace: (sessionId: string) => void;
|
||||||
setSessionModels: (sessionId: string, models: ModelOption[]) => void;
|
setSessionModels: (sessionId: string, models: ModelOption[]) => void;
|
||||||
switchSessionProvider: (
|
switchSessionProvider: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -300,7 +300,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
|||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
contextPanelOpenBySession: {},
|
contextPanelOpenBySession: {},
|
||||||
activeWorkingContextBySession: {},
|
activeWorkspaceBySession: {},
|
||||||
modelsBySession: {},
|
modelsBySession: {},
|
||||||
modelCacheByProvider: loadModelCache(),
|
modelCacheByProvider: loadModelCache(),
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
|||||||
const { [id]: _ignoredPanelState, ...remainingPanelState } =
|
const { [id]: _ignoredPanelState, ...remainingPanelState } =
|
||||||
get().contextPanelOpenBySession;
|
get().contextPanelOpenBySession;
|
||||||
const { [id]: _ignoredContext, ...remainingContextState } =
|
const { [id]: _ignoredContext, ...remainingContextState } =
|
||||||
get().activeWorkingContextBySession;
|
get().activeWorkspaceBySession;
|
||||||
const remainingModels = { ...get().modelsBySession };
|
const remainingModels = { ...get().modelsBySession };
|
||||||
delete remainingModels[id];
|
delete remainingModels[id];
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -354,7 +354,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
|||||||
activeSessionId:
|
activeSessionId:
|
||||||
state.activeSessionId === id ? null : state.activeSessionId,
|
state.activeSessionId === id ? null : state.activeSessionId,
|
||||||
contextPanelOpenBySession: remainingPanelState,
|
contextPanelOpenBySession: remainingPanelState,
|
||||||
activeWorkingContextBySession: remainingContextState,
|
activeWorkspaceBySession: remainingContextState,
|
||||||
modelsBySession: remainingModels,
|
modelsBySession: remainingModels,
|
||||||
}));
|
}));
|
||||||
removeDraftSessionRecord(id);
|
removeDraftSessionRecord(id);
|
||||||
@@ -544,19 +544,19 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveWorkingContext: (sessionId, context) => {
|
setActiveWorkspace: (sessionId, context) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
activeWorkingContextBySession: {
|
activeWorkspaceBySession: {
|
||||||
...state.activeWorkingContextBySession,
|
...state.activeWorkspaceBySession,
|
||||||
[sessionId]: context,
|
[sessionId]: context,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
clearActiveWorkingContext: (sessionId) => {
|
clearActiveWorkspace: (sessionId) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const { [sessionId]: _, ...rest } = state.activeWorkingContextBySession;
|
const { [sessionId]: _, ...rest } = state.activeWorkspaceBySession;
|
||||||
return { activeWorkingContextBySession: rest };
|
return { activeWorkspaceBySession: rest };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
|
|||||||
import {
|
import {
|
||||||
buildProjectSystemPrompt,
|
buildProjectSystemPrompt,
|
||||||
composeSystemPrompt,
|
composeSystemPrompt,
|
||||||
defaultArtifactsDir,
|
defaultGlobalArtifactRoot,
|
||||||
getProjectArtifactRoots,
|
getProjectArtifactRoots,
|
||||||
resolveProjectWorkingDir,
|
resolveProjectDefaultArtifactRoot,
|
||||||
} from "@/features/projects/lib/chatProjectContext";
|
} from "@/features/projects/lib/chatProjectContext";
|
||||||
import { getHomeDir } from "@/shared/api/system";
|
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
|
||||||
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
|
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
|
||||||
import type { ModelOption } from "../types";
|
import type { ModelOption } from "../types";
|
||||||
import { ChatContextPanel } from "./ChatContextPanel";
|
import { ChatContextPanel } from "./ChatContextPanel";
|
||||||
@@ -55,11 +55,11 @@ export function ChatView({
|
|||||||
(s) => s.contextPanelOpenBySession[activeSessionId] ?? false,
|
(s) => s.contextPanelOpenBySession[activeSessionId] ?? false,
|
||||||
);
|
);
|
||||||
const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen);
|
const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen);
|
||||||
const activeWorkingContext = useChatSessionStore(
|
const activeWorkspace = useChatSessionStore(
|
||||||
(s) => s.activeWorkingContextBySession[activeSessionId],
|
(s) => s.activeWorkspaceBySession[activeSessionId],
|
||||||
);
|
);
|
||||||
const clearActiveWorkingContext = useChatSessionStore(
|
const clearActiveWorkspace = useChatSessionStore(
|
||||||
(s) => s.clearActiveWorkingContext,
|
(s) => s.clearActiveWorkspace,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -85,7 +85,7 @@ export function ChatView({
|
|||||||
? s.projects.find((candidate) => candidate.id === session.projectId)
|
? s.projects.find((candidate) => candidate.id === session.projectId)
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
const [homeArtifactsRoot, setHomeArtifactsRoot] = useState<string | null>(
|
const [globalArtifactRoot, setGlobalArtifactRoot] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const project = storedProject ?? null;
|
const project = storedProject ?? null;
|
||||||
@@ -115,36 +115,30 @@ export function ChatView({
|
|||||||
() => getProjectArtifactRoots(project),
|
() => getProjectArtifactRoots(project),
|
||||||
[project],
|
[project],
|
||||||
);
|
);
|
||||||
const resolvedProjectWorkingDir = useMemo(
|
const projectDefaultArtifactRoot = useMemo(
|
||||||
() => resolveProjectWorkingDir(project),
|
() => resolveProjectDefaultArtifactRoot(project),
|
||||||
[project],
|
[project],
|
||||||
);
|
);
|
||||||
const projectMetadataPending = Boolean(
|
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 allowedArtifactRoots = useMemo(() => {
|
||||||
const roots = [
|
const roots = [
|
||||||
...projectArtifactRoots.map((path) => path.trim()).filter(Boolean),
|
...projectArtifactRoots.map((path) => path.trim()).filter(Boolean),
|
||||||
];
|
];
|
||||||
if (homeArtifactsRoot) {
|
if (globalArtifactRoot) {
|
||||||
roots.push(homeArtifactsRoot);
|
roots.push(globalArtifactRoot);
|
||||||
}
|
}
|
||||||
return [...new Set(roots)];
|
return [...new Set(roots)];
|
||||||
}, [homeArtifactsRoot, projectArtifactRoots]);
|
}, [globalArtifactRoot, projectArtifactRoots]);
|
||||||
const projectSystemPrompt = useMemo(
|
const projectSystemPrompt = useMemo(
|
||||||
() => buildProjectSystemPrompt(project),
|
() => buildProjectSystemPrompt(project),
|
||||||
[project],
|
[project],
|
||||||
);
|
);
|
||||||
const workingContextPrompt = useMemo(() => {
|
const workingContextPrompt = useMemo(() => {
|
||||||
if (!activeWorkingContext?.branch) return undefined;
|
if (!activeWorkspace?.branch) return undefined;
|
||||||
return `<active-working-context>\nActive branch: ${activeWorkingContext.branch}\nWorking directory: ${activeWorkingContext.path}\n</active-working-context>`;
|
return `<active-working-context>\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n</active-working-context>`;
|
||||||
}, [activeWorkingContext?.branch, activeWorkingContext?.path]);
|
}, [activeWorkspace?.branch, activeWorkspace?.path]);
|
||||||
|
|
||||||
const effectiveSystemPrompt = useMemo(
|
const effectiveSystemPrompt = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -158,14 +152,14 @@ export function ChatView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getHomeDir()
|
defaultGlobalArtifactRoot()
|
||||||
.then((homeDir) => {
|
.then((artifactRoot) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setHomeArtifactsRoot(defaultArtifactsDir(homeDir));
|
setGlobalArtifactRoot(artifactRoot);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setHomeArtifactsRoot(null);
|
setGlobalArtifactRoot(null);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -177,32 +171,41 @@ export function ChatView({
|
|||||||
const prevProjectId = prevProjectIdRef.current;
|
const prevProjectId = prevProjectIdRef.current;
|
||||||
prevProjectIdRef.current = session?.projectId;
|
prevProjectIdRef.current = session?.projectId;
|
||||||
if (prevProjectId !== undefined && prevProjectId !== 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(() => {
|
useEffect(() => {
|
||||||
const prev = prevContextRef.current;
|
const prev = prevWorkspaceRef.current;
|
||||||
if (
|
if (
|
||||||
!activeWorkingContext ||
|
!activeWorkspace ||
|
||||||
!selectedProvider ||
|
!selectedProvider ||
|
||||||
session?.draft ||
|
session?.draft ||
|
||||||
activeWorkingContext === prev
|
activeWorkspace === prev
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prevContextRef.current = activeWorkingContext;
|
prevWorkspaceRef.current = activeWorkspace;
|
||||||
if (prev && prev.path === activeWorkingContext.path) return;
|
if (prev && prev.path === activeWorkspace.path) return;
|
||||||
void acpPrepareSession(activeSessionId, selectedProvider, {
|
|
||||||
workingDir: activeWorkingContext.path,
|
async function prepareWorkspaceSession() {
|
||||||
personaId: selectedPersonaId ?? undefined,
|
const workingDir = await resolveSessionCwd(project, activeWorkspace.path);
|
||||||
}).catch((error) => {
|
if (!workingDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await acpPrepareSession(activeSessionId, selectedProvider, workingDir, {
|
||||||
|
personaId: selectedPersonaId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void prepareWorkspaceSession().catch((error) => {
|
||||||
console.error("Failed to prepare ACP session:", error);
|
console.error("Failed to prepare ACP session:", error);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
activeWorkingContext,
|
activeWorkspace,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
project,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedPersonaId,
|
selectedPersonaId,
|
||||||
session?.draft,
|
session?.draft,
|
||||||
@@ -230,19 +233,32 @@ export function ChatView({
|
|||||||
.getState()
|
.getState()
|
||||||
.projects.find((candidate) => candidate.id === projectId) ??
|
.projects.find((candidate) => candidate.id === projectId) ??
|
||||||
null);
|
null);
|
||||||
const nextWorkingDir =
|
|
||||||
resolveProjectWorkingDir(nextProject) ??
|
|
||||||
(projectId == null ? (homeArtifactsRoot ?? undefined) : undefined);
|
|
||||||
|
|
||||||
useChatSessionStore
|
useChatSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.updateSession(activeSessionId, { projectId });
|
.updateSession(activeSessionId, { projectId });
|
||||||
|
|
||||||
if (!session?.draft && selectedProvider && nextWorkingDir) {
|
if (!session?.draft && selectedProvider) {
|
||||||
void acpPrepareSession(activeSessionId, selectedProvider, {
|
async function updateProjectSessionCwd() {
|
||||||
workingDir: nextWorkingDir,
|
const workingDir = await resolveSessionCwd(
|
||||||
personaId: selectedPersonaId ?? undefined,
|
nextProject,
|
||||||
}).catch((error) => {
|
activeWorkspace?.path,
|
||||||
|
);
|
||||||
|
if (!workingDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await acpPrepareSession(
|
||||||
|
activeSessionId,
|
||||||
|
selectedProvider,
|
||||||
|
workingDir,
|
||||||
|
{
|
||||||
|
personaId: selectedPersonaId ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateProjectSessionCwd().catch((error) => {
|
||||||
console.error(
|
console.error(
|
||||||
"Failed to update ACP session working directory:",
|
"Failed to update ACP session working directory:",
|
||||||
error,
|
error,
|
||||||
@@ -252,7 +268,7 @@ export function ChatView({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
homeArtifactsRoot,
|
activeWorkspace?.path,
|
||||||
selectedPersonaId,
|
selectedPersonaId,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
session?.draft,
|
session?.draft,
|
||||||
@@ -331,6 +347,10 @@ export function ChatView({
|
|||||||
const personaInfo = selectedPersona
|
const personaInfo = selectedPersona
|
||||||
? { id: selectedPersona.id, name: selectedPersona.displayName }
|
? { id: selectedPersona.id, name: selectedPersona.displayName }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const resolveCurrentSessionCwd = useCallback(
|
||||||
|
() => resolveSessionCwd(project, activeWorkspace?.path),
|
||||||
|
[project, activeWorkspace?.path],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
chatState,
|
chatState,
|
||||||
@@ -343,7 +363,7 @@ export function ChatView({
|
|||||||
selectedProvider,
|
selectedProvider,
|
||||||
effectiveSystemPrompt,
|
effectiveSystemPrompt,
|
||||||
personaInfo,
|
personaInfo,
|
||||||
effectiveWorkingDir,
|
resolveCurrentSessionCwd,
|
||||||
);
|
);
|
||||||
const isLoadingHistory = useChatStore(
|
const isLoadingHistory = useChatStore(
|
||||||
(s) =>
|
(s) =>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { CreatedWorktree } from "@/shared/types/git";
|
import type { CreatedWorktree } from "@/shared/types/git";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||||
import { useChatSessionStore } from "../stores/chatSessionStore";
|
import { useChatSessionStore } from "../stores/chatSessionStore";
|
||||||
import type { WorkingContext } from "../stores/chatSessionStore";
|
import type { ActiveWorkspace } from "../stores/chatSessionStore";
|
||||||
import { WorkspaceWidget } from "./widgets/WorkspaceWidget";
|
import { WorkspaceWidget } from "./widgets/WorkspaceWidget";
|
||||||
import { ChangesWidget } from "./widgets/ChangesWidget";
|
import { ChangesWidget } from "./widgets/ChangesWidget";
|
||||||
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
|
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
|
||||||
@@ -39,35 +39,33 @@ export function ContextPanel({
|
|||||||
}: ContextPanelProps) {
|
}: ContextPanelProps) {
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const [activeTab, setActiveTab] = useState<ContextPanelTab>("details");
|
const [activeTab, setActiveTab] = useState<ContextPanelTab>("details");
|
||||||
const primaryWorkingDir = projectWorkingDirs[0] ?? null;
|
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null;
|
||||||
|
|
||||||
const activeContext = useChatSessionStore(
|
const activeContext = useChatSessionStore(
|
||||||
(s) => s.activeWorkingContextBySession[sessionId],
|
(s) => s.activeWorkspaceBySession[sessionId],
|
||||||
);
|
|
||||||
const setActiveWorkingContext = useChatSessionStore(
|
|
||||||
(s) => s.setActiveWorkingContext,
|
|
||||||
);
|
);
|
||||||
|
const setActiveWorkspace = useChatSessionStore((s) => s.setActiveWorkspace);
|
||||||
|
|
||||||
const gitQueryPath = activeContext?.path ?? primaryWorkingDir;
|
const gitTargetPath = activeContext?.path ?? primaryWorkspaceRoot;
|
||||||
const {
|
const {
|
||||||
data: gitState,
|
data: gitState,
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
refetch,
|
refetch,
|
||||||
} = useGitState(gitQueryPath, activeTab === "details");
|
} = useGitState(gitTargetPath, activeTab === "details");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: changedFiles,
|
data: changedFiles,
|
||||||
isLoading: isFilesLoading,
|
isLoading: isFilesLoading,
|
||||||
refetch: refetchFiles,
|
refetch: refetchFiles,
|
||||||
} = useChangedFiles(gitQueryPath, activeTab === "details");
|
} = useChangedFiles(gitTargetPath, activeTab === "details");
|
||||||
|
|
||||||
const handleContextChange = useCallback(
|
const handleContextChange = useCallback(
|
||||||
(context: WorkingContext) => {
|
(context: ActiveWorkspace) => {
|
||||||
setActiveWorkingContext(sessionId, context);
|
setActiveWorkspace(sessionId, context);
|
||||||
},
|
},
|
||||||
[sessionId, setActiveWorkingContext],
|
[sessionId, setActiveWorkspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchAll = useCallback(async () => {
|
const refetchAll = useCallback(async () => {
|
||||||
@@ -149,11 +147,11 @@ export function ContextPanel({
|
|||||||
|
|
||||||
const handleOpenChangedFile = useCallback(
|
const handleOpenChangedFile = useCallback(
|
||||||
(filePath: string) => {
|
(filePath: string) => {
|
||||||
if (!gitQueryPath) return;
|
if (!gitTargetPath) return;
|
||||||
const fullPath = `${gitQueryPath}/${filePath}`;
|
const fullPath = `${gitTargetPath}/${filePath}`;
|
||||||
void openPath(fullPath);
|
void openPath(fullPath);
|
||||||
},
|
},
|
||||||
[gitQueryPath],
|
[gitTargetPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
@@ -202,7 +200,7 @@ export function ContextPanel({
|
|||||||
files={changedFiles}
|
files={changedFiles}
|
||||||
isLoading={isFilesLoading}
|
isLoading={isFilesLoading}
|
||||||
currentBranch={gitState?.currentBranch ?? null}
|
currentBranch={gitState?.currentBranch ?? null}
|
||||||
repoPath={gitQueryPath ?? ""}
|
repoPath={gitTargetPath ?? ""}
|
||||||
onOpenFile={handleOpenChangedFile}
|
onOpenFile={handleOpenChangedFile}
|
||||||
/>
|
/>
|
||||||
<ArtifactsWidget />
|
<ArtifactsWidget />
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import {
|
|||||||
import { buttonVariants } from "@/shared/ui/button";
|
import { buttonVariants } from "@/shared/ui/button";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import type { GitState } from "@/shared/types/git";
|
import type { GitState } from "@/shared/types/git";
|
||||||
import type { WorkingContext } from "../../stores/chatSessionStore";
|
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
|
||||||
|
|
||||||
interface WorkingContextPickerProps {
|
interface WorkingContextPickerProps {
|
||||||
currentProjectPath: string | null;
|
currentProjectPath: string | null;
|
||||||
gitState: GitState | undefined;
|
gitState: GitState | undefined;
|
||||||
activeContext: WorkingContext | undefined;
|
activeContext: ActiveWorkspace | undefined;
|
||||||
onSelect: (context: WorkingContext) => void;
|
onSelect: (context: ActiveWorkspace) => void;
|
||||||
onSwitchBranch: (path: string, branch: string) => Promise<void>;
|
onSwitchBranch: (path: string, branch: string) => Promise<void>;
|
||||||
onStashAndSwitch: (path: string, branch: string) => Promise<void>;
|
onStashAndSwitch: (path: string, branch: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export function WorkingContextPicker({
|
|||||||
}: WorkingContextPickerProps) {
|
}: WorkingContextPickerProps) {
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pendingSwitch, setPendingSwitch] = useState<WorkingContext | null>(
|
const [pendingSwitch, setPendingSwitch] = useState<ActiveWorkspace | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [switching, setSwitching] = useState(false);
|
const [switching, setSwitching] = useState(false);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { CreatedWorktree, GitState } from "@/shared/types/git";
|
|||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { SplitButton } from "@/shared/ui/split-button";
|
import { SplitButton } from "@/shared/ui/split-button";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
import type { WorkingContext } from "../../stores/chatSessionStore";
|
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
|
||||||
import { formatErrorMessage } from "./formatError";
|
import { formatErrorMessage } from "./formatError";
|
||||||
import {
|
import {
|
||||||
WorkspaceCreateDialog,
|
WorkspaceCreateDialog,
|
||||||
@@ -15,9 +15,9 @@ import {
|
|||||||
interface WorkspaceActionsMenuProps {
|
interface WorkspaceActionsMenuProps {
|
||||||
currentProjectPath: string;
|
currentProjectPath: string;
|
||||||
gitState: GitState;
|
gitState: GitState;
|
||||||
activeContext: WorkingContext | undefined;
|
activeContext: ActiveWorkspace | undefined;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onContextChange: (context: WorkingContext) => void;
|
onContextChange: (context: ActiveWorkspace) => void;
|
||||||
onFetch: (path: string) => Promise<void>;
|
onFetch: (path: string) => Promise<void>;
|
||||||
onPull: (path: string) => Promise<void>;
|
onPull: (path: string) => Promise<void>;
|
||||||
onCreateBranch: (
|
onCreateBranch: (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/ui/select";
|
} from "@/shared/ui/select";
|
||||||
import type { WorkingContext } from "../../stores/chatSessionStore";
|
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
|
||||||
import { formatErrorMessage } from "./formatError";
|
import { formatErrorMessage } from "./formatError";
|
||||||
import { shortenPath } from "./WorkingContextPicker";
|
import { shortenPath } from "./WorkingContextPicker";
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ interface WorkspaceCreateDialogProps {
|
|||||||
currentPath: string;
|
currentPath: string;
|
||||||
activeBranch: string | null;
|
activeBranch: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onContextChange: (context: WorkingContext) => void;
|
onContextChange: (context: ActiveWorkspace) => void;
|
||||||
onCreateBranch: (
|
onCreateBranch: (
|
||||||
path: string,
|
path: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IconFolder, IconGitBranch, IconRefresh } from "@tabler/icons-react";
|
|||||||
import type { CreatedWorktree, GitState } from "@/shared/types/git";
|
import type { CreatedWorktree, GitState } from "@/shared/types/git";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
import type { WorkingContext } from "../../stores/chatSessionStore";
|
import type { ActiveWorkspace } from "../../stores/chatSessionStore";
|
||||||
import { Widget } from "./Widget";
|
import { Widget } from "./Widget";
|
||||||
import { WorkspaceActionsMenu } from "./WorkspaceActionsMenu";
|
import { WorkspaceActionsMenu } from "./WorkspaceActionsMenu";
|
||||||
import { WorkingContextPicker, shortenPath } from "./WorkingContextPicker";
|
import { WorkingContextPicker, shortenPath } from "./WorkingContextPicker";
|
||||||
@@ -16,8 +16,8 @@ interface WorkspaceWidgetProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
activeContext: WorkingContext | undefined;
|
activeContext: ActiveWorkspace | undefined;
|
||||||
onContextChange: (context: WorkingContext) => void;
|
onContextChange: (context: ActiveWorkspace) => void;
|
||||||
onSwitchBranch: (path: string, branch: string) => Promise<void>;
|
onSwitchBranch: (path: string, branch: string) => Promise<void>;
|
||||||
onStashAndSwitch: (path: string, branch: string) => Promise<void>;
|
onStashAndSwitch: (path: string, branch: string) => Promise<void>;
|
||||||
onInitRepo: (path: string) => Promise<void>;
|
onInitRepo: (path: string) => Promise<void>;
|
||||||
@@ -58,7 +58,7 @@ export function WorkspaceWidget({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
}: WorkspaceWidgetProps) {
|
}: WorkspaceWidgetProps) {
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const primaryWorkingDir = projectWorkingDirs[0] ?? null;
|
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null;
|
||||||
|
|
||||||
const gitErrorMessage =
|
const gitErrorMessage =
|
||||||
error instanceof Error ? error.message : t("contextPanel.errors.gitRead");
|
error instanceof Error ? error.message : t("contextPanel.errors.gitRead");
|
||||||
@@ -73,7 +73,7 @@ export function WorkspaceWidget({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={!primaryWorkingDir || isFetching}
|
disabled={!primaryWorkspaceRoot || isFetching}
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
aria-label={t("contextPanel.actions.refreshGitStatus")}
|
aria-label={t("contextPanel.actions.refreshGitStatus")}
|
||||||
title={t("contextPanel.actions.refreshGitStatus")}
|
title={t("contextPanel.actions.refreshGitStatus")}
|
||||||
@@ -103,7 +103,7 @@ export function WorkspaceWidget({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!primaryWorkingDir ? (
|
{!primaryWorkspaceRoot ? (
|
||||||
<p className="truncate">{t("contextPanel.empty.folderNotSet")}</p>
|
<p className="truncate">{t("contextPanel.empty.folderNotSet")}</p>
|
||||||
) : isLoading && !gitState ? (
|
) : isLoading && !gitState ? (
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
@@ -115,7 +115,7 @@ export function WorkspaceWidget({
|
|||||||
) : gitState?.isGitRepo ? (
|
) : gitState?.isGitRepo ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<WorkingContextPicker
|
<WorkingContextPicker
|
||||||
currentProjectPath={primaryWorkingDir}
|
currentProjectPath={primaryWorkspaceRoot}
|
||||||
gitState={gitState}
|
gitState={gitState}
|
||||||
activeContext={activeContext}
|
activeContext={activeContext}
|
||||||
onSelect={onContextChange}
|
onSelect={onContextChange}
|
||||||
@@ -123,7 +123,7 @@ export function WorkspaceWidget({
|
|||||||
onStashAndSwitch={onStashAndSwitch}
|
onStashAndSwitch={onStashAndSwitch}
|
||||||
/>
|
/>
|
||||||
<WorkspaceActionsMenu
|
<WorkspaceActionsMenu
|
||||||
currentProjectPath={primaryWorkingDir}
|
currentProjectPath={primaryWorkspaceRoot}
|
||||||
gitState={gitState}
|
gitState={gitState}
|
||||||
activeContext={activeContext}
|
activeContext={activeContext}
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
@@ -137,13 +137,13 @@ export function WorkspaceWidget({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="truncate text-foreground-subtle">
|
<p className="truncate text-foreground-subtle">
|
||||||
{shortenPath(primaryWorkingDir)}
|
{shortenPath(primaryWorkspaceRoot)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => void onInitRepo(primaryWorkingDir)}
|
onClick={() => void onInitRepo(primaryWorkspaceRoot)}
|
||||||
>
|
>
|
||||||
<IconGitBranch className="size-3" />
|
<IconGitBranch className="size-3" />
|
||||||
{t("contextPanel.git.initRepo")}
|
{t("contextPanel.git.initRepo")}
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildProjectSystemPrompt,
|
buildProjectSystemPrompt,
|
||||||
composeSystemPrompt,
|
composeSystemPrompt,
|
||||||
defaultArtifactsDir,
|
|
||||||
getProjectArtifactRoots,
|
getProjectArtifactRoots,
|
||||||
getProjectFolderName,
|
getProjectFolderName,
|
||||||
getProjectFolderOption,
|
getProjectFolderOption,
|
||||||
resolveEffectiveWorkingDir,
|
|
||||||
resolveProjectWorkingDir,
|
|
||||||
} from "./chatProjectContext";
|
} from "./chatProjectContext";
|
||||||
|
|
||||||
describe("chatProjectContext", () => {
|
describe("chatProjectContext", () => {
|
||||||
@@ -111,84 +108,4 @@ describe("chatProjectContext", () => {
|
|||||||
"/Users/wesb/dev/other/artifacts",
|
"/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 type { ProjectInfo } from "../api/projects";
|
||||||
|
import { resolvePath } from "@/shared/api/pathResolver";
|
||||||
export interface ProjectFolderOption {
|
export interface ProjectFolderOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,7 +25,7 @@ function appendArtifactsSegment(path: string): string {
|
|||||||
return `${path.replace(/[\\/]+$/, "")}/artifacts`;
|
return `${path.replace(/[\\/]+$/, "")}/artifacts`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectFolderPaths(
|
function resolveProjectArtifactRoots(
|
||||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
const workingDirs = (project?.workingDirs ?? [])
|
const workingDirs = (project?.workingDirs ?? [])
|
||||||
@@ -43,25 +43,37 @@ function resolveProjectFolderPaths(
|
|||||||
export function getProjectArtifactRoots(
|
export function getProjectArtifactRoots(
|
||||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
||||||
): string[] {
|
): 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(
|
export function getProjectFolderOption(
|
||||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
||||||
): ProjectFolderOption[] {
|
): ProjectFolderOption[] {
|
||||||
return resolveProjectFolderPaths(project).map((d) => ({
|
return resolveProjectArtifactRoots(project).map((d) => ({
|
||||||
id: d,
|
id: d,
|
||||||
name: getProjectFolderName(d),
|
name: getProjectFolderName(d),
|
||||||
path: d,
|
path: d,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProjectWorkingDir(
|
|
||||||
project: Pick<ProjectInfo, "workingDirs" | "artifactsDir"> | null | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
return resolveProjectFolderPaths(project)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildProjectSystemPrompt(
|
export function buildProjectSystemPrompt(
|
||||||
project: ProjectInfo | null | undefined,
|
project: ProjectInfo | null | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@@ -69,7 +81,7 @@ export function buildProjectSystemPrompt(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifactDir = resolveProjectWorkingDir(project);
|
const artifactDir = resolveProjectDefaultArtifactRoot(project);
|
||||||
const settings: string[] = [`Project name: ${project.name}`];
|
const settings: string[] = [`Project name: ${project.name}`];
|
||||||
const description = trimValue(project.description);
|
const description = trimValue(project.description);
|
||||||
const workingDirs = (project.workingDirs ?? [])
|
const workingDirs = (project.workingDirs ?? [])
|
||||||
@@ -120,40 +132,6 @@ export function buildProjectSystemPrompt(
|
|||||||
return sections.join("\n\n");
|
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(
|
export function composeSystemPrompt(
|
||||||
...parts: Array<string | null | undefined>
|
...parts: Array<string | null | undefined>
|
||||||
): string | 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 {
|
export interface AcpPrepareSessionOptions {
|
||||||
workingDir?: string;
|
|
||||||
personaId?: string;
|
personaId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +66,9 @@ export async function acpSendMessage(
|
|||||||
export async function acpPrepareSession(
|
export async function acpPrepareSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
providerId: string,
|
providerId: string,
|
||||||
|
workingDir: string,
|
||||||
options: AcpPrepareSessionOptions = {},
|
options: AcpPrepareSessionOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const workingDir = options.workingDir ?? "~/.goose/artifacts";
|
|
||||||
await sessionTracker.prepareSession(
|
await sessionTracker.prepareSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
providerId,
|
providerId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./agents";
|
export * from "./agents";
|
||||||
export * from "./acp";
|
export * from "./acp";
|
||||||
export * from "./git";
|
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 {
|
export class GooseClient {
|
||||||
closed = Promise.resolve();
|
closed = Promise.resolve();
|
||||||
|
|
||||||
constructor(..._args: unknown[]) {}
|
|
||||||
|
|
||||||
async initialize(..._args: unknown[]): Promise<void> {}
|
async initialize(..._args: unknown[]): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user