Preserve selected branch across project chats (#9010)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
morgmart
2026-05-22 06:48:46 -07:00
committed by GitHub
parent 2045e63d8d
commit 3ac39573de
17 changed files with 200 additions and 20 deletions
+33 -3
View File
@@ -47,6 +47,7 @@ import { perfLog } from "@/shared/lib/perfLog";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
import type { SkillInfo } from "@/features/skills/api/skills"; import type { SkillInfo } from "@/features/skills/api/skills";
import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt"; import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt";
import { resolveInheritedProjectWorkspace } from "@/features/chat/lib/workspaceContext";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { useOnboardingGate } from "@/features/onboarding/hooks/useOnboardingGate"; import { useOnboardingGate } from "@/features/onboarding/hooks/useOnboardingGate";
import { Spinner } from "@/shared/ui/spinner"; import { Spinner } from "@/shared/ui/spinner";
@@ -166,6 +167,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const createSession = useChatSessionStore((s) => s.createSession); const createSession = useChatSessionStore((s) => s.createSession);
const patchSession = useChatSessionStore((s) => s.patchSession); const patchSession = useChatSessionStore((s) => s.patchSession);
const setActiveSession = useChatSessionStore((s) => s.setActiveSession); const setActiveSession = useChatSessionStore((s) => s.setActiveSession);
const setActiveWorkspace = useChatSessionStore((s) => s.setActiveWorkspace);
const archiveSession = useChatSessionStore((s) => s.archiveSession); const archiveSession = useChatSessionStore((s) => s.archiveSession);
const selectedProvider = useAgentStore(selectSelectedProvider); const selectedProvider = useAgentStore(selectSelectedProvider);
const projects = useProjectStore(selectProjects); const projects = useProjectStore(selectProjects);
@@ -205,7 +207,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
.getState() .getState()
.projects.find((p) => p.id === session.projectId) ?? null) .projects.find((p) => p.id === session.projectId) ?? null)
: null; : null;
const workingDir = await resolveSessionCwd(project); const activeWorkspace =
session?.id != null
? useChatSessionStore.getState().activeWorkspaceBySession[session.id]
: undefined;
const workingDir = await resolveSessionCwd(
project,
activeWorkspace?.path ?? session?.workingDir,
);
await acpLoadSession(sessionId, workingDir); await acpLoadSession(sessionId, workingDir);
const tFlush = performance.now(); const tFlush = performance.now();
useChatStore.getState().setSessionLoading(sessionId, false); useChatStore.getState().setSessionLoading(sessionId, false);
@@ -411,6 +420,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
); );
const sessionState = useChatSessionStore.getState(); const sessionState = useChatSessionStore.getState();
const chatState = useChatStore.getState(); const chatState = useChatStore.getState();
const inheritedWorkspace = resolveInheritedProjectWorkspace({
projectId: project?.id,
sessions: sessionState.sessions,
activeSessionId: sessionState.activeSessionId,
activeWorkspaceBySession: sessionState.activeWorkspaceBySession,
});
const existingDraft = findExistingDraft({ const existingDraft = findExistingDraft({
sessions: sessionState.sessions, sessions: sessionState.sessions,
activeSessionId: sessionState.activeSessionId, activeSessionId: sessionState.activeSessionId,
@@ -423,6 +438,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}); });
if (existingDraft) { if (existingDraft) {
if (inheritedWorkspace) {
setActiveWorkspace(existingDraft.id, inheritedWorkspace);
patchSession(existingDraft.id, {
workingDir: inheritedWorkspace.path,
});
}
setActiveSession(existingDraft.id); setActiveSession(existingDraft.id);
clearSettingsSectionUrl(); clearSettingsSectionUrl();
setActiveView("chat"); setActiveView("chat");
@@ -433,7 +454,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
return existingDraft; return existingDraft;
} }
const workingDir = await resolveSessionCwd(project); const workingDir = await resolveSessionCwd(
project,
inheritedWorkspace?.path,
);
const session = await createSession({ const session = await createSession({
title, title,
projectId: project?.id, projectId: project?.id,
@@ -442,6 +466,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
modelId: sessionModelPreference.modelId, modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName, modelName: sessionModelPreference.modelName,
}); });
if (inheritedWorkspace) {
setActiveWorkspace(session.id, inheritedWorkspace);
}
setActiveSession(session.id); setActiveSession(session.id);
clearSettingsSectionUrl(); clearSettingsSectionUrl();
setActiveView("chat"); setActiveView("chat");
@@ -454,8 +481,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
[ [
selectedProvider, selectedProvider,
createSession, createSession,
patchSession,
providerInventoryEntries, providerInventoryEntries,
setActiveSession, setActiveSession,
setActiveWorkspace,
setChatActiveSession, setChatActiveSession,
], ],
); );
@@ -639,6 +668,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
workingDir, workingDir,
modelId: session.modelId, modelId: session.modelId,
}); });
patchSession(sessionId, { workingDir });
})().catch((error) => { })().catch((error) => {
console.error( console.error(
"Failed to update ACP session project working directory:", "Failed to update ACP session project working directory:",
@@ -646,7 +676,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
); );
}); });
}, },
[selectedProvider], [selectedProvider, patchSession],
); );
const handleRenameChat = useCallback( const handleRenameChat = useCallback(
@@ -31,6 +31,7 @@ describe("useChat attachments", () => {
sessions: [], sessions: [],
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
}); });
@@ -38,6 +38,7 @@ describe("useChat skill chips", () => {
sessions: [], sessions: [],
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
}); });
@@ -73,6 +73,7 @@ describe("useChat", () => {
sessions: [], sessions: [],
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
}); });
@@ -186,6 +186,7 @@ describe("useChatSessionController", () => {
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
hasHydratedSessions: true, hasHydratedSessions: true,
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
}); });
+7 -5
View File
@@ -369,11 +369,13 @@ export function useChat(
setPendingAssistantProvider, setPendingAssistantProvider,
]); ]);
const getWorkingDir = useCallback( const getWorkingDir = useCallback(() => {
() => const sessionStore = useChatSessionStore.getState();
useChatSessionStore.getState().activeWorkspaceBySession[sessionId]?.path, return (
[sessionId], sessionStore.activeWorkspaceBySession[sessionId]?.path ??
); sessionStore.getSession(sessionId)?.workingDir
);
}, [sessionId]);
const compactConversation = useCallback( const compactConversation = useCallback(
async (overridePersona?: { id: string; name?: string }) => { async (overridePersona?: { id: string; name?: string }) => {
@@ -170,6 +170,7 @@ export function useChatSessionController({
workingDir, workingDir,
modelId: modelSelection?.id, modelId: modelSelection?.id,
}); });
useChatSessionStore.getState().patchSession(sessionId, { workingDir });
if (!result.applied || !modelSelection?.id) { if (!result.applied || !modelSelection?.id) {
return result.applied; return result.applied;
} }
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { ChatSession } from "../stores/chatSessionStore";
import { resolveInheritedProjectWorkspace } from "./workspaceContext";
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
return {
id: "session-1",
title: "New chat",
projectId: "project-1",
workingDir: "/repo/main",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
messageCount: 0,
...overrides,
};
}
describe("resolveInheritedProjectWorkspace", () => {
it("inherits the active workspace for another chat in the same project", () => {
expect(
resolveInheritedProjectWorkspace({
projectId: "project-1",
sessions: [makeSession()],
activeSessionId: "session-1",
activeWorkspaceBySession: {
"session-1": { path: "/repo/feature", branch: "feature" },
},
}),
).toEqual({ path: "/repo/feature", branch: "feature" });
});
it("falls back to the active session working directory after reload", () => {
expect(
resolveInheritedProjectWorkspace({
projectId: "project-1",
sessions: [makeSession({ workingDir: "/repo/feature" })],
activeSessionId: "session-1",
activeWorkspaceBySession: {},
}),
).toEqual({ path: "/repo/feature", branch: null });
});
it("does not inherit workspace across projects", () => {
expect(
resolveInheritedProjectWorkspace({
projectId: "project-2",
sessions: [makeSession()],
activeSessionId: "session-1",
activeWorkspaceBySession: {
"session-1": { path: "/repo/feature", branch: "feature" },
},
}),
).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import type {
ActiveWorkspace,
ChatSession,
} from "@/features/chat/stores/chatSessionStore";
interface ResolveInheritedProjectWorkspaceOptions {
projectId: string | undefined;
sessions: ChatSession[];
activeSessionId: string | null;
activeWorkspaceBySession: Record<string, ActiveWorkspace>;
}
export function resolveInheritedProjectWorkspace({
projectId,
sessions,
activeSessionId,
activeWorkspaceBySession,
}: ResolveInheritedProjectWorkspaceOptions): ActiveWorkspace | undefined {
if (!projectId || !activeSessionId) {
return undefined;
}
const activeSession = sessions.find(
(session) => session.id === activeSessionId,
);
if (activeSession?.projectId !== projectId) {
return undefined;
}
const activeWorkspace = activeWorkspaceBySession[activeSessionId];
if (activeWorkspace?.path) {
return activeWorkspace;
}
return activeSession.workingDir
? { path: activeSession.workingDir, branch: null }
: undefined;
}
@@ -23,6 +23,7 @@ function resetStore() {
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
hasHydratedSessions: false, hasHydratedSessions: false,
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
}); });
@@ -49,6 +50,7 @@ function seedSession(overrides: Partial<ChatSession> = {}): ChatSession {
describe("chatSessionStore", () => { describe("chatSessionStore", () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.removeItem("goose:context-panel-open");
resetStore(); resetStore();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -271,6 +273,20 @@ describe("chatSessionStore", () => {
}); });
}); });
describe("context panel preference", () => {
it("stores context panel open state as a global preference", () => {
useChatSessionStore.getState().setContextPanelOpen("session-1", true);
expect(useChatSessionStore.getState().isContextPanelOpen).toBe(true);
expect(window.localStorage.getItem("goose:context-panel-open")).toBe("1");
useChatSessionStore.getState().setContextPanelOpen("session-2", false);
expect(useChatSessionStore.getState().isContextPanelOpen).toBe(false);
expect(window.localStorage.getItem("goose:context-panel-open")).toBe("0");
});
});
describe("archiveSession", () => { describe("archiveSession", () => {
it("sets archivedAt on the session", async () => { it("sets archivedAt on the session", async () => {
const session = seedSession(); const session = seedSession();
@@ -14,6 +14,8 @@ import {
unarchiveSession as acpUnarchiveSession, unarchiveSession as acpUnarchiveSession,
} from "@/shared/api/acpApi"; } from "@/shared/api/acpApi";
const CONTEXT_PANEL_OPEN_STORAGE_KEY = "goose:context-panel-open";
export interface ChatSession { export interface ChatSession {
id: string; id: string;
title: string; title: string;
@@ -58,6 +60,7 @@ interface ChatSessionStoreState {
activeSessionId: string | null; activeSessionId: string | null;
isLoading: boolean; isLoading: boolean;
hasHydratedSessions: boolean; hasHydratedSessions: boolean;
isContextPanelOpen: boolean;
contextPanelOpenBySession: Record<string, boolean>; contextPanelOpenBySession: Record<string, boolean>;
activeWorkspaceBySession: Record<string, ActiveWorkspace>; activeWorkspaceBySession: Record<string, ActiveWorkspace>;
} }
@@ -116,6 +119,29 @@ function sortByUpdatedAtDesc(sessions: ChatSession[]): ChatSession[] {
); );
} }
function loadContextPanelOpenPreference(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(CONTEXT_PANEL_OPEN_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function persistContextPanelOpenPreference(open: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
CONTEXT_PANEL_OPEN_STORAGE_KEY,
open ? "1" : "0",
);
} catch {
// localStorage may be unavailable
}
}
export function sessionToChatSession(session: Session): ChatSession { export function sessionToChatSession(session: Session): ChatSession {
return { return {
id: session.id, id: session.id,
@@ -138,6 +164,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
hasHydratedSessions: false, hasHydratedSessions: false,
isContextPanelOpen: loadContextPanelOpenPreference(),
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
@@ -255,13 +282,9 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
set({ activeSessionId: sessionId }); set({ activeSessionId: sessionId });
}, },
setContextPanelOpen: (sessionId, open) => { setContextPanelOpen: (_sessionId, open) => {
set((state) => ({ persistContextPanelOpenPreference(open);
contextPanelOpenBySession: { set({ isContextPanelOpen: open, contextPanelOpenBySession: {} });
...state.contextPanelOpenBySession,
[sessionId]: open,
},
}));
}, },
setActiveWorkspace: (sessionId, context) => { setActiveWorkspace: (sessionId, context) => {
@@ -27,6 +27,7 @@ interface ChatContextPanelProps {
color?: string; color?: string;
workingDirs?: string[]; workingDirs?: string[];
} | null; } | null;
sessionWorkingDir?: string | null;
setOpen: (sessionId: string, open: boolean) => void; setOpen: (sessionId: string, open: boolean) => void;
} }
@@ -35,6 +36,7 @@ export function ChatContextPanel({
isOpen, isOpen,
label, label,
project, project,
sessionWorkingDir,
setOpen, setOpen,
}: ChatContextPanelProps) { }: ChatContextPanelProps) {
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
@@ -104,6 +106,7 @@ export function ChatContextPanel({
projectName={project?.name} projectName={project?.name}
projectColor={project?.color} projectColor={project?.color}
projectWorkingDirs={project?.workingDirs ?? []} projectWorkingDirs={project?.workingDirs ?? []}
sessionWorkingDir={sessionWorkingDir}
/> />
</aside> </aside>
</motion.div> </motion.div>
+2 -3
View File
@@ -47,9 +47,7 @@ export function ChatView({
}: ChatViewProps) { }: ChatViewProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const mountStart = useRef(performance.now()); const mountStart = useRef(performance.now());
const isContextPanelOpen = useChatSessionStore( const isContextPanelOpen = useChatSessionStore((s) => s.isContextPanelOpen);
(s) => s.contextPanelOpenBySession[sessionId] ?? false,
);
const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen); const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen);
const [isLoadingIndicatorMounted, setIsLoadingIndicatorMounted] = const [isLoadingIndicatorMounted, setIsLoadingIndicatorMounted] =
useState(false); useState(false);
@@ -189,6 +187,7 @@ export function ChatView({
isOpen={isContextPanelOpen} isOpen={isContextPanelOpen}
label={contextPanelLabel} label={contextPanelLabel}
project={controller.project} project={controller.project}
sessionWorkingDir={controller.session?.workingDir}
setOpen={setContextPanelOpen} setOpen={setContextPanelOpen}
/> />
</div> </div>
@@ -26,6 +26,7 @@ interface ContextPanelProps {
projectName?: string; projectName?: string;
projectColor?: string; projectColor?: string;
projectWorkingDirs?: string[]; projectWorkingDirs?: string[];
sessionWorkingDir?: string | null;
} }
type ContextPanelTab = "details" | "files"; type ContextPanelTab = "details" | "files";
@@ -64,6 +65,7 @@ export function ContextPanel({
projectName, projectName,
projectColor, projectColor,
projectWorkingDirs = [], projectWorkingDirs = [],
sessionWorkingDir,
}: ContextPanelProps) { }: ContextPanelProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const [activeTab, setActiveTab] = useState<ContextPanelTab>("details"); const [activeTab, setActiveTab] = useState<ContextPanelTab>("details");
@@ -77,7 +79,8 @@ export function ContextPanel({
); );
const setActiveWorkspace = useChatSessionStore((s) => s.setActiveWorkspace); const setActiveWorkspace = useChatSessionStore((s) => s.setActiveWorkspace);
const gitTargetPath = activeContext?.path ?? primaryWorkspaceRoot; const gitTargetPath =
activeContext?.path ?? sessionWorkingDir ?? primaryWorkspaceRoot;
const { const {
data: gitState, data: gitState,
error, error,
@@ -230,6 +233,7 @@ export function ContextPanel({
projectName={projectName} projectName={projectName}
projectColor={projectColor} projectColor={projectColor}
projectWorkingDirs={projectWorkingDirs} projectWorkingDirs={projectWorkingDirs}
sessionWorkingDir={sessionWorkingDir}
gitState={gitState} gitState={gitState}
isLoading={isLoading} isLoading={isLoading}
isFetching={isFetching} isFetching={isFetching}
@@ -55,6 +55,7 @@ vi.mock("../../hooks/useChatSessionController", () => ({
vi.mock("../../stores/chatSessionStore", () => ({ vi.mock("../../stores/chatSessionStore", () => ({
useChatSessionStore: (selector: (state: unknown) => unknown) => useChatSessionStore: (selector: (state: unknown) => unknown) =>
selector({ selector({
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
setContextPanelOpen: vi.fn(), setContextPanelOpen: vi.fn(),
}), }),
@@ -12,6 +12,7 @@ interface WorkspaceWidgetProps {
projectName?: string; projectName?: string;
projectColor?: string; projectColor?: string;
projectWorkingDirs: string[]; projectWorkingDirs: string[];
sessionWorkingDir?: string | null;
gitState: GitState | undefined; gitState: GitState | undefined;
isLoading: boolean; isLoading: boolean;
isFetching: boolean; isFetching: boolean;
@@ -44,6 +45,7 @@ export function WorkspaceWidget({
projectName, projectName,
projectColor, projectColor,
projectWorkingDirs, projectWorkingDirs,
sessionWorkingDir,
gitState, gitState,
isLoading, isLoading,
isFetching, isFetching,
@@ -62,7 +64,8 @@ export function WorkspaceWidget({
onToggleOpen, onToggleOpen,
}: WorkspaceWidgetProps) { }: WorkspaceWidgetProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null; const primaryWorkspaceRoot =
activeContext?.path ?? sessionWorkingDir ?? 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");
@@ -20,6 +20,7 @@ describe("ACP session info updates", () => {
activeSessionId: null, activeSessionId: null,
isLoading: false, isLoading: false,
hasHydratedSessions: false, hasHydratedSessions: false,
isContextPanelOpen: false,
contextPanelOpenBySession: {}, contextPanelOpenBySession: {},
activeWorkspaceBySession: {}, activeWorkspaceBySession: {},
}); });