feat: profiles slice

This commit is contained in:
Patrick Erichsen 2025-03-21 15:11:57 -07:00
parent 9c2db6b9c7
commit b82d29c2df
9 changed files with 124 additions and 88 deletions

View File

@ -51,7 +51,7 @@
"@continuedev/config-yaml": "^1.0.63",
"@continuedev/fetch": "^1.0.4",
"@continuedev/llm-info": "^1.0.2",
"@continuedev/openai-adapters": "^1.0.10",
"@continuedev/openai-adapters": "^1.0.18",
"@modelcontextprotocol/sdk": "^1.5.0",
"@mozilla/readability": "^0.5.0",
"@octokit/rest": "^20.1.1",

View File

@ -1,6 +1,11 @@
import { SlashCommandDescription } from "core";
import { useState } from "react";
import { useAppDispatch, useAppSelector } from "../../redux/hooks";
import {
bookmarkSlashCommand,
selectBookmarkedSlashCommands,
selectSelectedProfileId,
unbookmarkSlashCommand,
} from "../../redux/slices/profiles/slice";
import { setMainEditorContentTrigger } from "../../redux/slices/sessionSlice";
import { getParagraphNodeFromString } from "../mainInput/utils";
import { ConversationStarterCard } from "./ConversationStarterCard";
@ -10,12 +15,23 @@ const NUM_CARDS_TO_RENDER_COLLAPSED = 3;
export function ConversationStarterCards() {
const dispatch = useAppDispatch();
const [bookmarkedCommands, setBookmarkedCommands] = useState<string[]>([]);
const slashCommands =
useAppSelector((state) => state.config.config.slashCommands) ?? [];
const filteredSlashCommands = slashCommands?.filter(isDeprecatedCommandName);
const selectedProfileId = useAppSelector(selectSelectedProfileId);
const bookmarkedCommands = useAppSelector(selectBookmarkedSlashCommands);
const filteredSlashCommands = slashCommands.filter(isDeprecatedCommandName);
const bookmarkStatuses: Record<string, boolean> = {};
if (selectedProfileId) {
filteredSlashCommands.forEach((command) => {
bookmarkStatuses[command.name] = bookmarkedCommands.includes(
command.name,
);
});
}
function onClick(command: SlashCommandDescription) {
if (command.prompt) {
@ -26,15 +42,22 @@ export function ConversationStarterCards() {
}
function handleBookmarkCommand(command: SlashCommandDescription) {
setBookmarkedCommands((prev) => {
if (prev.includes(command.name)) {
return prev.filter((name) => name !== command.name);
} else {
return [...prev, command.name];
}
});
}
const isBookmarked = bookmarkStatuses[command.name];
if (isBookmarked) {
dispatch(
unbookmarkSlashCommand({
commandName: command.name,
}),
);
} else {
dispatch(
bookmarkSlashCommand({
commandName: command.name,
}),
);
}
}
if (!filteredSlashCommands || filteredSlashCommands.length === 0) {
return null;
}
@ -49,7 +72,7 @@ export function ConversationStarterCards() {
command={command}
onClick={onClick}
onBookmark={handleBookmarkCommand}
isBookmarked={bookmarkedCommands.includes(command.name)}
isBookmarked={bookmarkStatuses[command.name]}
/>
))}
</div>

View File

@ -13,9 +13,9 @@ import React, {
import ConfirmationDialog from "../components/dialogs/ConfirmationDialog";
import { useWebviewListener } from "../hooks/useWebviewListener";
import { updateOrgsThunk, updateProfilesThunk } from "../redux";
import { selectSelectedProfile } from "../redux/";
import { useAppDispatch, useAppSelector } from "../redux/hooks";
import { setLastControlServerBetaEnabledStatus } from "../redux/slices/miscSlice";
import { selectSelectedProfile } from "../redux/slices/sessionSlice";
import { setDialogMessage, setShowDialog } from "../redux/slices/uiSlice";
import { IdeMessengerContext } from "./IdeMessenger";
@ -45,9 +45,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
);
// Orgs
const orgs = useAppSelector((store) => store.session.organizations);
const orgs = useAppSelector((store) => store.profiles.organizations);
const selectedOrgId = useAppSelector(
(store) => store.session.selectedOrganizationId,
(store) => store.profiles.selectedOrganizationId,
);
const selectedOrganization = useMemo(() => {
if (!selectedOrgId) {
@ -57,7 +57,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
}, [orgs, selectedOrgId]);
// Profiles
const profiles = useAppSelector((store) => store.session.availableProfiles);
const profiles = useAppSelector((store) => store.profiles.availableProfiles);
const selectedProfile = useAppSelector(selectSelectedProfile);
const login: AuthContextType["login"] = (useOnboarding: boolean) => {

View File

@ -12,7 +12,7 @@ import { useAppDispatch, useAppSelector } from "../../redux/hooks";
export function ScopeSelect() {
const { organizations, selectedOrganization } = useAuth();
const selectedOrgId = useAppSelector(
(state) => state.session.selectedOrganizationId,
(state) => state.profiles.selectedOrganizationId,
);
const dispatch = useAppDispatch();

View File

@ -6,10 +6,10 @@ import { DiscordIcon } from "../../components/svg/DiscordIcon";
import { GithubIcon } from "../../components/svg/GithubIcon";
import { useAuth } from "../../context/Auth";
import { IdeMessengerContext } from "../../context/IdeMessenger";
import { selectSelectedProfile } from "../../redux/";
import { useAppDispatch, useAppSelector } from "../../redux/hooks";
import { selectUseHub } from "../../redux/selectors";
import { selectDefaultModel } from "../../redux/slices/configSlice";
import { selectSelectedProfile } from "../../redux/slices/sessionSlice";
import { setDialogMessage, setShowDialog } from "../../redux/slices/uiSlice";
import { isLocalProfile } from "../../util";
import { providers } from "../AddNewModel/configs/providers";

View File

@ -2,11 +2,16 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ProfileDescription } from "core/config/ConfigHandler";
import { OrganizationDescription } from "core/config/ProfileLifecycleManager";
interface PreferencesState {
bookmarksByName: string[];
}
interface ProfilesState {
availableProfiles: ProfileDescription[] | null;
selectedProfileId: string | null;
organizations: OrganizationDescription[];
selectedOrganizationId: string | null;
preferencesByProfileId: Record<string, PreferencesState>;
}
const initialState: ProfilesState = {
@ -14,6 +19,7 @@ const initialState: ProfilesState = {
selectedProfileId: null,
organizations: [],
selectedOrganizationId: null,
preferencesByProfileId: {},
};
export const profilesSlice = createSlice({
@ -41,6 +47,43 @@ export const profilesSlice = createSlice({
) => {
state.selectedOrganizationId = payload;
},
bookmarkSlashCommand: (
state,
action: PayloadAction<{ commandName: string }>,
) => {
const { commandName } = action.payload;
const profileId = state.selectedProfileId;
if (!profileId) return;
// Initialize preferences for this profile if needed
if (!state.preferencesByProfileId[profileId]) {
state.preferencesByProfileId[profileId] = {
bookmarksByName: [],
};
}
// Only add if not already bookmarked
const bookmarks = state.preferencesByProfileId[profileId].bookmarksByName;
if (!bookmarks.includes(commandName)) {
bookmarks.push(commandName);
}
},
unbookmarkSlashCommand: (
state,
action: PayloadAction<{ commandName: string }>,
) => {
const { commandName } = action.payload;
const profileId = state.selectedProfileId;
if (!profileId || !state.preferencesByProfileId[profileId]) return;
const preferences = state.preferencesByProfileId[profileId];
preferences.bookmarksByName = preferences.bookmarksByName.filter(
(cmd) => cmd !== commandName,
);
},
},
selectors: {
selectSelectedProfile: (state) => {
@ -50,6 +93,16 @@ export const profilesSlice = createSlice({
) ?? null
);
},
selectSelectedProfileId: (state) => state.selectedProfileId,
selectBookmarkedSlashCommands: (state) => {
if (!state.selectedProfileId) return [];
const preferences = state.preferencesByProfileId[state.selectedProfileId];
return preferences?.bookmarksByName || [];
},
selectPreferencesByProfileId: (state) => state.preferencesByProfileId,
},
});
@ -58,8 +111,15 @@ export const {
setSelectedProfile,
setOrganizations,
setSelectedOrganizationId,
bookmarkSlashCommand,
unbookmarkSlashCommand,
} = profilesSlice.actions;
export const { selectSelectedProfile } = profilesSlice.selectors;
export const {
selectSelectedProfile,
selectSelectedProfileId,
selectBookmarkedSlashCommands,
selectPreferencesByProfileId,
} = profilesSlice.selectors;
export const { reducer: profilesReducer } = profilesSlice;

View File

@ -16,29 +16,29 @@ export const selectProfileThunk = createAsyncThunk<
>("profiles/select", async (id, { dispatch, extra, getState }) => {
const state = getState();
if (state.session.availableProfiles === null) {
if (state.profiles.availableProfiles === null) {
// Currently in loading state
return;
}
const initialId = state.session.selectedProfileId;
const initialId = state.profiles.selectedProfileId;
let newId = id;
// If no profiles, force clear
if (state.session.availableProfiles.length === 0) {
if (state.profiles.availableProfiles.length === 0) {
newId = null;
} else {
// If new id doesn't match an existing profile, clear it
if (newId) {
if (!state.session.availableProfiles.find((p) => p.id === newId)) {
if (!state.profiles.availableProfiles.find((p) => p.id === newId)) {
newId = null;
}
}
if (!newId) {
// At this point if null ID and there ARE profiles,
// Fallback to a profile, prioritizing the first in the list
newId = state.session.availableProfiles[0].id;
newId = state.profiles.availableProfiles[0].id;
}
}
@ -56,11 +56,11 @@ export const cycleProfile = createAsyncThunk<void, undefined, ThunkApiType>(
async (_, { dispatch, getState }) => {
const state = getState();
if (state.session.availableProfiles === null) {
if (state.profiles.availableProfiles === null) {
return;
}
const profileIds = state.session.availableProfiles.map(
const profileIds = state.profiles.availableProfiles.map(
(profile) => profile.id,
);
// In case of no profiles just does nothing
@ -68,8 +68,8 @@ export const cycleProfile = createAsyncThunk<void, undefined, ThunkApiType>(
return;
}
let nextId = profileIds[0];
if (state.session.selectedProfileId) {
const curIndex = profileIds.indexOf(state.session.selectedProfileId);
if (state.profiles.selectedProfileId) {
const curIndex = profileIds.indexOf(state.profiles.selectedProfileId);
const nextIndex = (curIndex + 1) % profileIds.length;
nextId = profileIds[nextIndex];
}
@ -96,15 +96,15 @@ export const selectOrgThunk = createAsyncThunk<
ThunkApiType
>("session/selectOrg", async (id, { dispatch, extra, getState }) => {
const state = getState();
const initialId = state.session.selectedOrganizationId;
const initialId = state.profiles.selectedOrganizationId;
let newId = id;
// If no orgs, force clear
if (state.session.organizations.length === 0) {
if (state.profiles.organizations.length === 0) {
newId = null;
} else if (newId) {
// If new id doesn't match an existing org, clear it
if (!state.session.organizations.find((o) => o.id === newId)) {
if (!state.profiles.organizations.find((o) => o.id === newId)) {
newId = null;
}
}
@ -129,5 +129,5 @@ export const updateOrgsThunk = createAsyncThunk<
dispatch(setOrganizations(orgs));
// This will trigger reselection if needed
dispatch(selectOrgThunk(state.session.selectedOrganizationId));
dispatch(selectOrgThunk(state.profiles.selectedOrganizationId));
});

View File

@ -21,8 +21,6 @@ import {
ToolCallDelta,
ToolCallState,
} from "core";
import { ProfileDescription } from "core/config/ConfigHandler";
import { OrganizationDescription } from "core/config/ProfileLifecycleManager";
import { NEW_SESSION_TITLE } from "core/util/constants";
import { incrementalParseJson } from "core/util/incrementalParseJson";
import { renderChatMessage } from "core/util/messageContent";
@ -45,11 +43,6 @@ type SessionState = {
isStreaming: boolean;
title: string;
id: string;
/** null indicates loading state */
availableProfiles: ProfileDescription[] | null;
selectedProfileId: string | null;
organizations: OrganizationDescription[];
selectedOrganizationId: string | null;
streamAborter: AbortController;
codeToEdit: CodeToEdit[];
curCheckpointIndex: number;
@ -88,10 +81,6 @@ const initialState: SessionState = {
isStreaming: false,
title: NEW_SESSION_TITLE,
id: uuidv4(),
selectedProfileId: null,
availableProfiles: null,
organizations: [],
selectedOrganizationId: "",
curCheckpointIndex: 0,
streamAborter: new AbortController(),
codeToEdit: [],
@ -294,7 +283,6 @@ export const sessionSlice = createSlice({
state.streamAborter = new AbortController();
},
streamUpdate: (state, action: PayloadAction<ChatMessage[]>) => {
if (state.history.length) {
function toolCallDeltaToState(
toolCallDelta: ToolCallDelta,
@ -332,7 +320,7 @@ export const sessionSlice = createSlice({
id: uuidv4(),
},
contextItems: [],
})
});
continue;
}
@ -554,30 +542,6 @@ export const sessionSlice = createSlice({
state.history[state.history.length - 1].contextItems = contextItems;
},
// Important: these reducers don't handle selected profile/organization fallback logic
// That is done in thunks
setSelectedProfile: (state, { payload }: PayloadAction<string | null>) => {
state.selectedProfileId = payload;
},
setAvailableProfiles: (
state,
{ payload }: PayloadAction<ProfileDescription[] | null>,
) => {
state.availableProfiles = payload;
},
setOrganizations: (
state,
{ payload }: PayloadAction<OrganizationDescription[]>,
) => {
state.organizations = payload;
},
setSelectedOrganizationId: (
state,
{ payload }: PayloadAction<string | null>,
) => {
state.selectedOrganizationId = payload;
},
///////////////
updateCurCheckpoint: (
state,
@ -690,15 +654,6 @@ export const sessionSlice = createSlice({
selectIsInEditMode: (state) => {
return state.mode === "edit";
},
selectSelectedProfile: (state) => {
// state.session.availableProfiles.find((p) => p.id === newId) ?? null,
return (
state.availableProfiles?.find(
(profile) => profile.id === state.selectedProfileId,
) ?? null
);
},
selectIsSingleRangeEditOrInsertion: (state) => {
if (state.mode !== "edit") {
return false;
@ -784,17 +739,11 @@ export const {
updateSessionMetadata,
deleteSessionMetadata,
setNewestCodeblocksForInput,
setAvailableProfiles,
setSelectedProfile,
setOrganizations,
setSelectedOrganizationId,
} = sessionSlice.actions;
export const {
selectIsGatheringContext,
selectIsInEditMode,
selectSelectedProfile,
selectIsSingleRangeEditOrInsertion,
selectHasCodeToEdit,
} = sessionSlice.selectors;

View File

@ -10,7 +10,7 @@ import { createFilter } from "redux-persist-transform-filter";
import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2";
import storage from "redux-persist/lib/storage";
import { IdeMessenger, IIdeMessenger } from "../context/IdeMessenger";
import { profilesReducer } from "./redux";
import { profilesReducer } from "./slices";
import configReducer from "./slices/configSlice";
import editModeStateReducer from "./slices/editModeState";
import indexingReducer from "./slices/indexingSlice";
@ -38,8 +38,6 @@ const rootReducer = combineReducers({
const saveSubsetFilters = [
createFilter("session", [
"history",
"selectedOrganizationId",
"selectedProfile",
"id",
"lastSessionId",
"title",
@ -63,6 +61,12 @@ const saveSubsetFilters = [
createFilter("ui", ["toolSettings", "useTools"]),
createFilter("indexing", []),
createFilter("tabs", ["tabs"]),
// Add this new filter for the profiles slice
createFilter("profiles", [
"preferencesByProfileId",
"selectedOrganizationId",
"selectedProfileId",
]),
];
const migrations: MigrationManifest = {